@vzn/vx 0.0.635 → 0.0.636
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 +8 -8
- package/docs/caching.md +10 -4
- package/docs/cli.md +18 -27
- package/docs/execution.md +1 -1
- package/docs/features.md +3 -3
- package/docs/patterns.md +5 -7
- package/docs/schema.md +52 -14
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/show.json +8 -1
- package/src/cli/help.ts +3 -1
- package/src/cli/run.ts +26 -1
- package/src/config.ts +30 -9
- package/src/index.ts +1 -0
- package/src/orchestrator/execute-task.ts +41 -14
- package/src/orchestrator/options.ts +2 -0
- package/src/orchestrator/prepare.ts +2 -2
- package/src/orchestrator/run.ts +14 -1
- package/src/orchestrator/task-hash.ts +1 -1
- package/src/workspace/config-schema.ts +76 -7
- package/src/workspace/index.ts +2 -0
package/README.md
CHANGED
|
@@ -138,17 +138,17 @@ Every gap, and when another tool is the better pick:
|
|
|
138
138
|
|
|
139
139
|
<!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
|
|
140
140
|
|
|
141
|
-
| Time added over the ideal run, 1,601 projects, 9,603 tasks | vx | Turborepo
|
|
142
|
-
| ---------------------------------------------------------- | ----------- |
|
|
143
|
-
| Nothing changed | **
|
|
144
|
-
| Nothing changed, outputs restored | **1.
|
|
145
|
-
| Cold build | **7.
|
|
146
|
-
| Cold build: CPU the runner burns | **
|
|
141
|
+
| Time added over the ideal run, 1,601 projects, 9,603 tasks | vx | Turborepo | Nx | Vite Task |
|
|
142
|
+
| ---------------------------------------------------------- | ----------- | --------- | ---------- | --------- |
|
|
143
|
+
| Nothing changed | **856 ms** | 855 ms | 23.22 s | 11.24 s |
|
|
144
|
+
| Nothing changed, outputs restored | **1.45 s** | 1.50 s | 21.64 s | 11.21 s |
|
|
145
|
+
| Cold build | **7.73 s** | 10.32 s | 3 min 23 s | 9.44 s |
|
|
146
|
+
| Cold build: CPU the runner burns | **36.42 s** | 1 min 3 s | 5 min 35 s | 25.64 s |
|
|
147
147
|
|
|
148
|
-
Time each tool adds over the ideal run;
|
|
148
|
+
Time each tool adds over the ideal run; lower is better.
|
|
149
149
|
|
|
150
150
|
Benchmark workload: a synthetic monorepo of 1,601 projects and 9,603 tasks in 30 dependency levels, five core libraries a quarter of the projects use; build 300 ms, test and typecheck 150 ms, lint 75 ms, publish 30 ms; real repos with uneven task times will differ.
|
|
151
|
-
Run 2026-10-
|
|
151
|
+
Run 2026-10-12 on linux x64, 4 cores: vx at commit 126d60a93, Turborepo 2.11.7, Nx 23.3.0, Vite Task (vite-plus) 1.1.0.
|
|
152
152
|
|
|
153
153
|
Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
|
|
154
154
|
|
package/docs/caching.md
CHANGED
|
@@ -200,7 +200,11 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
|
|
|
200
200
|
|
|
201
201
|
Ref storage is not a key input: every ref vx reads comes from a git
|
|
202
202
|
command, so a repository in reftable storage (git 2.45) keys every
|
|
203
|
-
task as its files-backend twin
|
|
203
|
+
task as its files-backend twin. A sha256 repository keys from its
|
|
204
|
+
64-hex index OIDs, and a dirty file is hashed in-process as sha256,
|
|
205
|
+
so Git 3's defaults (reftable and sha256 together) run, hit and
|
|
206
|
+
select `--affected` as a sha1 repository does
|
|
207
|
+
(`tests/git-reftable.unsafe.test.ts`).
|
|
204
208
|
|
|
205
209
|
A **submodule or an embedded repository** is enumerated by its own
|
|
206
210
|
git: the workspace repository lists the nested one as a single entry
|
|
@@ -707,10 +711,12 @@ names a file other than `package.json` (TanStack Router's committed
|
|
|
707
711
|
`routeTree.gen.ts`, rewritten by every build). That holds on CI too: a
|
|
708
712
|
task of the same project running alongside can change an input and put
|
|
709
713
|
it back. A task that rewrites its own input to identical bytes (tsdown's
|
|
710
|
-
`package.json`) sets `cache.
|
|
714
|
+
`package.json`) sets `cache.inputStability: { strategy: 'mutable' }`, and a write
|
|
711
715
|
during its command is then judged by its bytes, as an earlier write is;
|
|
712
|
-
different bytes still withhold the entry. `
|
|
713
|
-
makes that the default (owner, 2026-10-12)
|
|
716
|
+
different bytes still withhold the entry. `VX_INPUT_STABILITY=mutable`
|
|
717
|
+
makes that the default (owner, 2026-10-12); `'unchecked'` skips the
|
|
718
|
+
check, and `severity: 'error'` fails the task instead of warning
|
|
719
|
+
(`docs/schema.md`). A file ADDED under an
|
|
714
720
|
input glob since the listing the key filtered (`addedInput`) withholds
|
|
715
721
|
the save the same way, named on the same line: the listing's
|
|
716
722
|
directories a glob reaches are `lstat`ed, one whose ctime moved since
|
package/docs/cli.md
CHANGED
|
@@ -480,6 +480,7 @@ than run with the args unheard.
|
|
|
480
480
|
| `--no-cache` | boolean | off | Disable caching entirely (no reads, no writes); output globs are NOT cleaned. |
|
|
481
481
|
| `--force` | boolean | off | Re-execute everything (skip cache reads) but still REFRESH the cache (writes stay on). Output globs are cleaned (so the saved snapshot is clean). |
|
|
482
482
|
| `--cache <spec>` | value | all axes on | Per-layer read/write control. See below. An EMPTY spec (`--cache=`) is a parse error — it applied nothing and left every axis on; pass `--no-cache` to disable them all. |
|
|
483
|
+
| `--cache-scope <name>` | value | `VX_CACHE_SCOPE` / workspace `cacheScope` | This run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `VX_CACHE_SCOPE`, `vx.workspace`'s and `github()`'s. A name `cacheScope` would refuse is refused. `--cache-scope=<name>` form too. Never folded into a cache key. |
|
|
483
484
|
| `--cache-dir <path>` | value | workspace `cacheDir` / `~/.vx/<id>/cache` | Cache directory override, resolved relative to cwd (absolute paths used as-is). Beats the `defineWorkspace({ cacheDir })` field, `VX_CACHE_DIR` and the default; like them, the directory then holds the whole cache, shared through no store, for every cache the run opens — the config-evaluation cache that `--affected` owners, the picker and the watch sweep read included, so the workspace's default dir is not created beside it. A per-run knob — never folded into a cache key. `--cache-dir=<path>` form too; the space form rejects a value starting with `-`. A directory this user cannot write into fails the run before any task with `cache directory <path> is not writable (EACCES: …)` — every run records its history there. |
|
|
484
485
|
| `--retry <n>` | value | `0` | Re-run a failed task up to `n` more times; never a persistent one. Run-level default only: a task's own `exec.retries` wins (even an explicit `0`). Never affects cache keys. `--retry=<n>` form too. |
|
|
485
486
|
| `--continue[=<mode>]` | value | `deps-ok` | What a failed task takes down with it. `never` stops dispatch on the first failure; `deps-ok` (default) skips only its dependents; `always` (bare `--continue`) runs dependents anyway. See § Failure propagation. |
|
|
@@ -571,7 +572,8 @@ The cache has four independent axes — **localRead**, **localWrite**,
|
|
|
571
572
|
them in this precedence order:
|
|
572
573
|
|
|
573
574
|
1. Start with every axis **on** (the default).
|
|
574
|
-
2. Apply each `--cache=<spec>` segment (the base)
|
|
575
|
+
2. Apply each `--cache=<spec>` segment (the base), or, with no
|
|
576
|
+
`--cache`, the `VX_CACHE` environment variable's spec.
|
|
575
577
|
3. If `--no-cache` was passed, force **all four off**.
|
|
576
578
|
4. If `--force` was passed, force **both reads off** (writes stay
|
|
577
579
|
whatever the base / `--cache` left them).
|
|
@@ -3023,32 +3025,21 @@ REAPI endpoints, the GitHub token) are in its README; what a TASK sees
|
|
|
3023
3025
|
is `exec.env` in `docs/schema.md`, and the two markers vx sets on every
|
|
3024
3026
|
task are the last row.
|
|
3025
3027
|
|
|
3026
|
-
| Var | Value
|
|
3027
|
-
| --------------------------------- |
|
|
3028
|
-
| `VX_TIMING` | any non-empty
|
|
3029
|
-
| `VX_TASK_TIMEOUT` | positive integer (ms)
|
|
3030
|
-
| `VX_CACHE_DIR` | a path
|
|
3031
|
-
| `
|
|
3032
|
-
| `
|
|
3033
|
-
| `
|
|
3034
|
-
| `
|
|
3035
|
-
| `
|
|
3036
|
-
| `
|
|
3037
|
-
| `
|
|
3038
|
-
| `
|
|
3039
|
-
|
|
|
3040
|
-
|
|
|
3041
|
-
| `VX_TIMING` | any non-empty | off | Print the stage table to stderr after a run, a `--dry`, or each `vx watch` cycle (`docs/modules/timing.md`). |
|
|
3042
|
-
| `VX_TASK_TIMEOUT` | positive integer (ms) | none | The default timeout for tasks without their own `exec.timeout`, one rung below `--timeout` / `RunOptions.timeout` and one above the workspace `timeout`. Empty, non-integer or non-positive is ignored; a value past the largest timer (~24.8 days) is clamped to it, never refused. |
|
|
3043
|
-
| `VX_CACHE_DIR` | a path | none | The workspace's cache dir, relative to the workspace root, below `--cache-dir` and `vx.workspace`'s `cacheDir`. A named cache dir holds the whole cache and shares nothing; unset, entries live in the user's shared store (`docs/caching.md`). Empty is ignored. |
|
|
3044
|
-
| `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
|
|
3045
|
-
| `VX_REWRITTEN_INPUTS` | `withhold`, `compare` | `withhold` | What a write to an input during the command means for a task that does not set `cache.rewrittenInputs`: `compare` saves the entry when the file holds the bytes the key folded, anything else withholds it (`docs/schema.md`). |
|
|
3046
|
-
| `VX_KILL_GRACE_MS` | positive integer (ms) | 2000 | The SIGTERM → SIGKILL grace a child that ignores SIGTERM gets: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. Out of range falls back to the default. |
|
|
3047
|
-
| `VX_READY_NOTICE_MS` | positive integer (ms) | 10000 | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. Out of range falls back to the default. |
|
|
3048
|
-
| `VX_TEARDOWN_TIMEOUT_MS` | integer (ms) | 3000 | The bound on one plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. Out of range falls back to the default, never clamps: a bound of 24.8 days is no bound. |
|
|
3049
|
-
| `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range (0, or past 2³¹−1) falls back to the default. |
|
|
3050
|
-
| `VX_WATCH_POLL` | any non-empty | off | `vx watch` polls every 250 ms from the start instead of probing the OS watcher (§ `vx watch` › How changes are seen). |
|
|
3051
|
-
| `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | set by vx | — | Set on every task's environment (the workspace root; `project#task`). Read back by a `vx run` a task starts: one in the same workspace is refused, since a nested run is invisible to the outer graph and a loop back to its own task forks without bound (`docs/schema.md` § `env`). |
|
|
3028
|
+
| Var | Value | Default | Effect |
|
|
3029
|
+
| --------------------------------- | ----------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
3030
|
+
| `VX_TIMING` | any non-empty | off | Print the stage table to stderr after a run, a `--dry`, or each `vx watch` cycle (`docs/modules/timing.md`). |
|
|
3031
|
+
| `VX_TASK_TIMEOUT` | positive integer (ms) | none | The default timeout for tasks without their own `exec.timeout`, one rung below `--timeout` / `RunOptions.timeout` and one above the workspace `timeout`. Empty, non-integer or non-positive is ignored; a value past the largest timer (~24.8 days) is clamped to it, never refused. |
|
|
3032
|
+
| `VX_CACHE_DIR` | a path | none | The workspace's cache dir, relative to the workspace root, below `--cache-dir` and `vx.workspace`'s `cacheDir`. A named cache dir holds the whole cache and shares nothing; unset, entries live in the user's shared store (`docs/caching.md`). Empty is ignored; whitespace alone is refused, as `cacheDir` is. |
|
|
3033
|
+
| `VX_CACHE` | a `--cache` spec | all axes on | The run's cache policy when no `--cache` is passed (`local:rw,remote:r`); `--no-cache` and `--force` still apply on top. Empty is ignored; a bad spec is refused before any task. |
|
|
3034
|
+
| `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s; `--cache-scope` beats it. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
|
|
3035
|
+
| `VX_INPUT_STABILITY` | `immutable`, `mutable`, `unchecked` | `immutable` | The `inputStability.strategy` of every task, over its own and the workspace's, for the run: which writes to an input during the command withhold the save. `mutable` lets a rewrite to the bytes the key folded through, `unchecked` skips the check. Any other value is refused before a task runs (`docs/schema.md`). |
|
|
3036
|
+
| `VX_INPUT_STABILITY_SEVERITY` | `warn`, `error` | `warn` | The `inputStability.severity` of every task, over its own and the workspace's, for the run: `error` fails a task whose save a moved input withheld, so the run fails. Any other value is refused before a task runs. |
|
|
3037
|
+
| `VX_KILL_GRACE_MS` | positive integer (ms) | 2000 | The SIGTERM → SIGKILL grace a child that ignores SIGTERM gets: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. Out of range falls back to the default. |
|
|
3038
|
+
| `VX_READY_NOTICE_MS` | positive integer (ms) | 10000 | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. Out of range falls back to the default. |
|
|
3039
|
+
| `VX_TEARDOWN_TIMEOUT_MS` | integer (ms) | 3000 | The bound on one plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. Out of range falls back to the default, never clamps: a bound of 24.8 days is no bound. |
|
|
3040
|
+
| `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range falls back to the default. |
|
|
3041
|
+
| `VX_WATCH_POLL` | any non-empty | off | `vx watch` polls every 250 ms from the start instead of probing the OS watcher (§ `vx watch` › How changes are seen). |
|
|
3042
|
+
| `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | set by vx | — | Set on every task's environment (the workspace root; `project#task`). Read back by a `vx run` a task starts: one in the same workspace is refused, since a nested run is invisible to the outer graph and a loop back to its own task forks without bound (`docs/schema.md` § `env`). |
|
|
3052
3043
|
|
|
3053
3044
|
Colors are the two conventions in § Output format › Colors (`NO_COLOR`,
|
|
3054
3045
|
`FORCE_COLOR`). A truthy `CI` picks the CI output flow and
|
package/docs/execution.md
CHANGED
|
@@ -45,7 +45,7 @@ terminal and a task succeeding or failing. Read it alongside
|
|
|
45
45
|
│ 3. loadWorkspacePlugins — loadWorkspaceConfig reads the optional
|
|
46
46
|
│ vx.workspace.{ts,mts,js,mjs,cts,cjs} at the root (concurrency /
|
|
47
47
|
│ cacheDir / timeout / cacheRetention / affectedBase / cacheScope /
|
|
48
|
-
│ rules / schedule / plugins),
|
|
48
|
+
│ rules / schedule / inputStability / plugins),
|
|
49
49
|
│ then the plugin `config` stage runs on it.
|
|
50
50
|
│ 4. listProjects — globs every workspace member's package.json,
|
|
51
51
|
│ finds sibling vx.config.* files, detects duplicate package
|
package/docs/features.md
CHANGED
|
@@ -81,16 +81,16 @@ config key or an environment variable is missing here.
|
|
|
81
81
|
- **Opt-in, explicit inputs** (`cache.inputs.files`) — a task caches only when it names its inputs. [page](https://vznjs.github.io/vx/features/explicit-inputs/) · post: [Explicit over magical](https://vznjs.github.io/vx/blog/explicit-over-magical/)
|
|
82
82
|
- **Inputs** (`cache.inputs.env`, `cache.inputs.runtime`, `cache.inputs.tasks`, `cache.inputs.workspaceFiles`, `cache.inputs.workspaceRuntime`) — env vars, tool versions, upstream tasks and workspace files in the key. [page](https://vznjs.github.io/vx/features/key-inputs/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/)
|
|
83
83
|
- **Outputs** (`cache.outputs.files`, `cache.outputs.workspaceFiles`) — what a hit restores. [page](https://vznjs.github.io/vx/features/cache-outputs/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/)
|
|
84
|
-
- **
|
|
84
|
+
- **Input stability** (`cache.inputStability`, workspace `inputStability`, `VX_INPUT_STABILITY`, `VX_INPUT_STABILITY_SEVERITY`) — an input written while the command runs withholds the save, on CI too, and the saves of every task that ran over its outputs; `strategy: 'mutable'` accepts a task's identical rewrite of its own input, `'unchecked'` skips the check; `severity: 'error'` fails the run instead of warning. no post
|
|
85
85
|
- **Outputs are exactly the snapshot** (`exclusiveOutputs`) — a hit leaves the tree as the run did; two tasks may not own one output without an edge. [page](https://vznjs.github.io/vx/features/strict-outputs/) · post: [The tree is exactly the snapshot](https://vznjs.github.io/vx/blog/strict-output-ownership/)
|
|
86
86
|
- **Keys from git's index** — tracked clean files hash by their blob id, no read. [page](https://vznjs.github.io/vx/features/keys-from-git/) · post: [Your cache key is already in git's index](https://vznjs.github.io/vx/blog/keys-from-git/)
|
|
87
87
|
- **Config keyed as evaluated** — the key sees the resolved config object. [page](https://vznjs.github.io/vx/features/typescript-config/) · post: [Configs are programs](https://vznjs.github.io/vx/blog/resolved-config-hashing/)
|
|
88
88
|
- **Cascade through inputs** — a task's key folds its upstream input keys, never outputs. [page](https://vznjs.github.io/vx/features/cascade/) · post: [Cascade through dependencies](https://vznjs.github.io/vx/blog/cascade-through-inputs/)
|
|
89
89
|
- **Lockfile-aware keys** (`@vzn/vx-lockfile`: `pnpm()`, `bun()`, `npm()`, `yarn()`) — a bump re-keys only the projects whose closure changed. [page](https://vznjs.github.io/vx/features/lockfile-keys/) · post: [A lockfile bump should re-key two tasks](https://vznjs.github.io/vx/blog/lockfile-aware-keys/)
|
|
90
90
|
- **Upfront keys** (`upfrontKeys`) — refuses an input glob a same-project task's outputs could match, so every key is known before anything runs. [page](https://vznjs.github.io/vx/features/upfront-keys/) · post: [Cascade through dependencies by folding input keys, never outputs](https://vznjs.github.io/vx/blog/cascade-through-inputs/)
|
|
91
|
-
- **Cache controls** (`--no-cache`, `--force`, `--cache`) — off, refresh, or per-layer read/write. [page](https://vznjs.github.io/vx/features/cache-controls/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
|
|
91
|
+
- **Cache controls** (`--no-cache`, `--force`, `--cache`, `VX_CACHE`) — off, refresh, or per-layer read/write. [page](https://vznjs.github.io/vx/features/cache-controls/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
|
|
92
92
|
- **Cache location** (`--cache-dir`, `cacheDir`, `VX_CACHE_DIR`) — where the cache lives. [page](https://vznjs.github.io/vx/features/cache-location/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
|
|
93
|
-
- **Cache scope** (`cacheScope`, `VX_CACHE_SCOPE`) — trusted CI writes the remote cache; a laptop reads it. [page](https://vznjs.github.io/vx/features/cache-scope/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
|
|
93
|
+
- **Cache scope** (`cacheScope`, `VX_CACHE_SCOPE`, `--cache-scope`) — trusted CI writes the remote cache; a laptop reads it. [page](https://vznjs.github.io/vx/features/cache-scope/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
|
|
94
94
|
- **Cache pruning** (`vx cache prune`, `--older-than`, `--max-size`, `--dry-run`, `cacheRetention`, `maxSize`, `olderThan`) — evict by age or size, LRU. [page](https://vznjs.github.io/vx/features/cache-prune/) · post: [One command to know your workspace](https://vznjs.github.io/vx/blog/know-your-workspace/)
|
|
95
95
|
- **Remote outputs** (`--download`) — all, top-level only, or none. [page](https://vznjs.github.io/vx/features/remote-downloads/) · post: [A remote server you can trust in production](https://vznjs.github.io/vx/blog/reapi-in-production/)
|
|
96
96
|
- **One store for every checkout** (`~/.vx/<id>/cache`) — clones and worktrees of a repo share cache entries. [page](https://vznjs.github.io/vx/features/shared-store/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
|
package/docs/patterns.md
CHANGED
|
@@ -183,17 +183,15 @@ Sharing the patterns doesn't mean sharing the overhead. On the
|
|
|
183
183
|
476-package / 1,428-node synthetic workspace
|
|
184
184
|
(`packages/vx-bench/compare.ts 20 25 1`, 2026-09-02):
|
|
185
185
|
|
|
186
|
-
| Runner | Warm (no restore)
|
|
187
|
-
| ------ |
|
|
188
|
-
| **vx** | **297 ms**
|
|
189
|
-
| Turbo | 342 ms
|
|
190
|
-
| Nx | 1.38 s
|
|
186
|
+
| Runner | Warm (no restore) | Warm (restore) |
|
|
187
|
+
| ------ | ----------------- | -------------- |
|
|
188
|
+
| **vx** | **297 ms** | **416 ms** |
|
|
189
|
+
| Turbo | 342 ms | 612 ms |
|
|
190
|
+
| Nx | 1.38 s | 1.33 s |
|
|
191
191
|
|
|
192
192
|
A cached run is all runner overhead. Cold builds are compared by the
|
|
193
193
|
time the runner adds, on [`benchmarks.md`](./benchmarks.md).
|
|
194
194
|
|
|
195
|
-
vx N% faster: that tool takes N% longer than vx; N× faster: N times as long.
|
|
196
|
-
|
|
197
195
|
Full breakdown + methodology in [`benchmarks.md`](./benchmarks.md).
|
|
198
196
|
|
|
199
197
|
## Where the lineage diverges
|
package/docs/schema.md
CHANGED
|
@@ -704,19 +704,47 @@ Semantics:
|
|
|
704
704
|
interface CacheConfig {
|
|
705
705
|
inputs: CacheInputs // required when `cache` is provided
|
|
706
706
|
outputs: CacheOutputs // required when `cache` is provided
|
|
707
|
-
|
|
707
|
+
inputStability?: InputStability // optional
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
interface InputStability {
|
|
711
|
+
strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable'
|
|
712
|
+
severity?: 'warn' | 'error' // default 'warn'
|
|
708
713
|
}
|
|
709
714
|
```
|
|
710
715
|
|
|
711
|
-
`
|
|
712
|
-
means. `
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
716
|
+
`inputStability` says what a write to an input while the command runs
|
|
717
|
+
means. `strategy` says which writes count:
|
|
718
|
+
|
|
719
|
+
- `'immutable'` (the default, on CI too): any write. An edit undone
|
|
720
|
+
mid-run, or another task changing the file and putting it back, cannot
|
|
721
|
+
be told from no edit.
|
|
722
|
+
- `'mutable'`: a file rewritten to the bytes the key folded counts as
|
|
723
|
+
unchanged, for a task that rewrites its own input identically
|
|
724
|
+
(tsdown's `package.json`); different bytes still count.
|
|
725
|
+
- `'unchecked'`: none. The entry is saved under the key taken before the
|
|
726
|
+
command, whatever the command did to its inputs; `severity` is ignored.
|
|
727
|
+
A key that no longer held before the command started still withholds
|
|
728
|
+
it.
|
|
729
|
+
|
|
730
|
+
A write that counts withholds the entry, and `severity` says what else
|
|
731
|
+
happens: `'warn'` (the default) keeps the result, says why on the status
|
|
732
|
+
line, and the run goes on; `'error'` fails the task, so the run fails.
|
|
733
|
+
Either way, every task that ran over the task's outputs, down the graph,
|
|
734
|
+
withholds its save too, and a task of any project that declares the
|
|
735
|
+
moved file as an input withholds its own.
|
|
736
|
+
|
|
737
|
+
Each field is resolved on its own, highest first:
|
|
738
|
+
|
|
739
|
+
1. `VX_INPUT_STABILITY` (`immutable`, `mutable`, `unchecked`) and
|
|
740
|
+
`VX_INPUT_STABILITY_SEVERITY` (`warn`, `error`), for the whole run
|
|
741
|
+
(a CI job that wants `error` sets it without editing configs); any
|
|
742
|
+
other value is refused before a task runs;
|
|
743
|
+
2. the task's own `cache.inputStability`;
|
|
744
|
+
3. the workspace's `inputStability` (`vx.workspace.ts`);
|
|
745
|
+
4. the default, `'immutable'` and `'warn'`.
|
|
746
|
+
|
|
747
|
+
Never folded into a cache key.
|
|
720
748
|
|
|
721
749
|
```ts
|
|
722
750
|
import { defineProject } from '@vzn/vx/config'
|
|
@@ -724,12 +752,13 @@ import { defineProject } from '@vzn/vx/config'
|
|
|
724
752
|
export default defineProject({
|
|
725
753
|
tasks: {
|
|
726
754
|
build: {
|
|
727
|
-
// tsdown rewrites package.json with the same bytes on every build
|
|
755
|
+
// tsdown rewrites package.json with the same bytes on every build;
|
|
756
|
+
// different bytes fail the run rather than rebuild next time.
|
|
728
757
|
exec: { command: 'tsdown' },
|
|
729
758
|
cache: {
|
|
730
759
|
inputs: { files: ['src/**', 'package.json'] },
|
|
731
760
|
outputs: { files: ['dist/**'] },
|
|
732
|
-
|
|
761
|
+
inputStability: { strategy: 'mutable', severity: 'error' },
|
|
733
762
|
},
|
|
734
763
|
},
|
|
735
764
|
},
|
|
@@ -1564,6 +1593,7 @@ export default defineWorkspace({
|
|
|
1564
1593
|
cacheScope: 'trusted',
|
|
1565
1594
|
rules: { exclusiveOutputs: true, upfrontKeys: true },
|
|
1566
1595
|
schedule: 'most-work',
|
|
1596
|
+
inputStability: { strategy: 'immutable', severity: 'warn' },
|
|
1567
1597
|
plugins: [otel()],
|
|
1568
1598
|
})
|
|
1569
1599
|
```
|
|
@@ -1586,6 +1616,8 @@ interface WorkspaceConfig {
|
|
|
1586
1616
|
rules?: WorkspaceRules
|
|
1587
1617
|
/** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
|
|
1588
1618
|
schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
1619
|
+
/** Every task's cache.inputStability default: { strategy, severity }. */
|
|
1620
|
+
inputStability?: InputStability
|
|
1589
1621
|
/** Run-level plugins (cache / executor / telemetry capabilities). */
|
|
1590
1622
|
plugins?: readonly (Plugin | undefined | null | false)[]
|
|
1591
1623
|
}
|
|
@@ -1659,7 +1691,7 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
|
|
|
1659
1691
|
each other. A scope's key is derived from the task key, so every
|
|
1660
1692
|
remote wire stores it unchanged. A clamp on `--cache`, never a
|
|
1661
1693
|
widening. `github()` sets it on Actions: `pr-<n>`, `ref-<name>` off
|
|
1662
|
-
the default branch. `VX_CACHE_SCOPE` beats both. A client-side convention, not a security
|
|
1694
|
+
the default branch. `VX_CACHE_SCOPE` beats both, and `vx run --cache-scope` beats all three. A client-side convention, not a security
|
|
1663
1695
|
boundary: a run holding a write credential can write any key, so only
|
|
1664
1696
|
a cache server that scopes writes by token can refuse one
|
|
1665
1697
|
(`docs/security.md` § Cache poisoning). Not folded into any cache key.
|
|
@@ -1674,6 +1706,10 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
|
|
|
1674
1706
|
their ties. The default costs what it did; another strategy is
|
|
1675
1707
|
computed only when named. Any other value is refused. Never folded
|
|
1676
1708
|
into a cache key.
|
|
1709
|
+
- **`inputStability`** — the `cache.inputStability` of every task that
|
|
1710
|
+
does not set its own field, same shape (`{ strategy, severity }`, §
|
|
1711
|
+
`cache`). The env vars still override it for the run. Never folded
|
|
1712
|
+
into a cache key.
|
|
1677
1713
|
- **`rules`** — checks on the task graph, each on unless set to
|
|
1678
1714
|
`false`. Turning one off allows a shape vx runs correctly but more
|
|
1679
1715
|
slowly; never folded into a cache key. A value that is not a boolean,
|
|
@@ -2088,7 +2124,9 @@ a user meets most:
|
|
|
2088
2124
|
| `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
|
|
2089
2125
|
| `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
|
|
2090
2126
|
| `cache.outputs is required when cache is set` | Forgot `outputs`. |
|
|
2091
|
-
| `cache.
|
|
2127
|
+
| `cache.inputStability must be an object` | A bare string (`inputStability: 'mutable'`). |
|
|
2128
|
+
| `cache.inputStability.strategy must be 'immutable', 'mutable' or 'unchecked'` | Any other value (`'content'`, `false`). |
|
|
2129
|
+
| `cache.inputStability.severity must be 'warn' or 'error'` | Any other value (`'fail'`, `true`). |
|
|
2092
2130
|
| `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
|
|
2093
2131
|
| `cache.outputs.files must be an array` | Wrong shape. |
|
|
2094
2132
|
| `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
|
package/docs/security.md
CHANGED
|
@@ -42,7 +42,7 @@ namespace), or none.
|
|
|
42
42
|
`cacheScope` is the client half. `'pr-123'` reads its own keys,
|
|
43
43
|
then the trusted ones, and writes only its own, which `github()` sets on
|
|
44
44
|
Actions (`pr-<n>`, `ref-<name>`; the default branch stays trusted)
|
|
45
|
-
and `VX_CACHE_SCOPE` sets on any CI.
|
|
45
|
+
and `VX_CACHE_SCOPE` or `--cache-scope` sets on any CI.
|
|
46
46
|
It keeps honest runs apart and stops a non-hermetic PR build from
|
|
47
47
|
landing where main reads; it is not a boundary, since the PR's own
|
|
48
48
|
config decides it and any run holding a write credential can write any
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.636",
|
|
4
4
|
"description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.636",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.636",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.636",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.636"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/schemas/show.json
CHANGED
|
@@ -116,7 +116,14 @@
|
|
|
116
116
|
"workspaceFiles": { "$ref": "#/$defs/stringList" }
|
|
117
117
|
}
|
|
118
118
|
},
|
|
119
|
-
"
|
|
119
|
+
"inputStability": {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"additionalProperties": false,
|
|
122
|
+
"properties": {
|
|
123
|
+
"strategy": { "enum": ["immutable", "mutable", "unchecked"] },
|
|
124
|
+
"severity": { "enum": ["warn", "error"] }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
120
127
|
}
|
|
121
128
|
}
|
|
122
129
|
}
|
package/src/cli/help.ts
CHANGED
|
@@ -140,7 +140,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
140
140
|
' (layer = local|remote, flags ⊆ rw). A named layer is set EXACTLY',
|
|
141
141
|
' to its flags; unnamed layers keep their value. Examples:',
|
|
142
142
|
' local:rw,remote:r (remote read-only), remote: (remote off),',
|
|
143
|
-
' local:r (local read-only).',
|
|
143
|
+
' local:r (local read-only). Without it, VX_CACHE holds the spec.',
|
|
144
|
+
" --cache-scope <name> This run's cacheScope: trusted | read-only | a scope (pr-123).",
|
|
145
|
+
' Beats VX_CACHE_SCOPE and the workspace cacheScope.',
|
|
144
146
|
' --cache-dir <path> Cache directory override (cwd-relative). Beats VX_CACHE_DIR,',
|
|
145
147
|
' the workspace cacheDir field and the ~/.vx/<id>/cache default.',
|
|
146
148
|
' --retry <n> Re-run a failed task up to <n> more times (default for tasks',
|
package/src/cli/run.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { isatty } from 'node:tty'
|
|
4
|
-
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
4
|
+
import { cacheScopeError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
|
|
5
5
|
import { translateForeign } from './foreign-flags.js'
|
|
6
6
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
7
7
|
import {
|
|
@@ -52,6 +52,8 @@ export interface RunArgs {
|
|
|
52
52
|
remoteRequested?: boolean
|
|
53
53
|
/** `--cache-dir <path>`: override the cache directory (cwd-relative). */
|
|
54
54
|
cacheDir: string | undefined
|
|
55
|
+
/** `--cache-scope <name>`: this run's `cacheScope`, above `VX_CACHE_SCOPE` and the workspace's. */
|
|
56
|
+
cacheScope?: string
|
|
55
57
|
frozen: boolean
|
|
56
58
|
/**
|
|
57
59
|
* `--retry <n>` / `--retry=<n>`: run-level retry default for tasks
|
|
@@ -158,6 +160,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
158
160
|
// beats `--force`, and `--cache` is the base both may override.
|
|
159
161
|
let cachePolicy: CachePolicy = { ...FULL_CACHE_POLICY }
|
|
160
162
|
let cacheSpecError: string | undefined
|
|
163
|
+
let cacheFlag = false
|
|
161
164
|
let noCacheFlag = false
|
|
162
165
|
let forceFlag = false
|
|
163
166
|
|
|
@@ -282,7 +285,13 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
282
285
|
}
|
|
283
286
|
}
|
|
284
287
|
out.cacheDir = v
|
|
288
|
+
} else if (a === '--cache-scope' || a?.startsWith('--cache-scope=')) {
|
|
289
|
+
const v = a === '--cache-scope' ? before[++i] : a.slice('--cache-scope='.length)
|
|
290
|
+
if (v === undefined || !isCacheScope(v))
|
|
291
|
+
return { ...out, error: cacheScopeError('--cache-scope') }
|
|
292
|
+
out.cacheScope = v
|
|
285
293
|
} else if (a === '--cache' || a?.startsWith('--cache=')) {
|
|
294
|
+
cacheFlag = true
|
|
286
295
|
const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
|
|
287
296
|
if (v === undefined)
|
|
288
297
|
return {
|
|
@@ -407,6 +416,21 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
407
416
|
if (cacheSpecError !== undefined) {
|
|
408
417
|
return { ...out, error: cacheSpecError }
|
|
409
418
|
}
|
|
419
|
+
// `VX_CACHE` is the spec a run takes when no `--cache` names one.
|
|
420
|
+
const envSpec = process.env['VX_CACHE'] || undefined
|
|
421
|
+
if (!cacheFlag && envSpec !== undefined) {
|
|
422
|
+
if (!envSpec.split(',').some((seg) => seg.trim().length > 0))
|
|
423
|
+
return {
|
|
424
|
+
...out,
|
|
425
|
+
error: `VX_CACHE needs a spec like local:r, local:rw, remote:, or local:,remote:rw`,
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
cachePolicy = parseCachePolicy(envSpec, cachePolicy)
|
|
429
|
+
} catch (err) {
|
|
430
|
+
return { ...out, error: `VX_CACHE: ${err instanceof Error ? err.message : String(err)}` }
|
|
431
|
+
}
|
|
432
|
+
if (/remote:[rw]/.test(envSpec)) out.remoteRequested = true
|
|
433
|
+
}
|
|
410
434
|
// Precedence: --no-cache (all off) beats --force (reads off, writes
|
|
411
435
|
// kept), both layered on top of any --cache spec.
|
|
412
436
|
if (noCacheFlag) {
|
|
@@ -611,6 +635,7 @@ export async function resolveRunOptions(
|
|
|
611
635
|
tasks: [...tasks],
|
|
612
636
|
cache: parsed.cache,
|
|
613
637
|
...(parsed.remoteRequested ? { remoteRequested: true } : {}),
|
|
638
|
+
...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
|
|
614
639
|
// A laptop reads the shared cache and never writes it (owner,
|
|
615
640
|
// 2026-10-03); naming the remote in `--cache` is asking to.
|
|
616
641
|
...(!parsed.remoteRequested && !onCI(process.env['CI'])
|
package/src/config.ts
CHANGED
|
@@ -42,7 +42,8 @@ export interface WorkspaceConfig {
|
|
|
42
42
|
* then the trusted ones, and writes go to the scope's only, so a PR never
|
|
43
43
|
* writes what the default branch reads. A client-side convention, not a
|
|
44
44
|
* security boundary: only a cache server that scopes writes by token
|
|
45
|
-
* can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it
|
|
45
|
+
* can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it, and
|
|
46
|
+
* `vx run --cache-scope` beats both. Never
|
|
46
47
|
* folded into a cache key.
|
|
47
48
|
*/
|
|
48
49
|
cacheScope?: string
|
|
@@ -55,6 +56,12 @@ export interface WorkspaceConfig {
|
|
|
55
56
|
* `schedule` plugin's weights still come first; this breaks their ties.
|
|
56
57
|
*/
|
|
57
58
|
schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
59
|
+
/**
|
|
60
|
+
* The `cache.inputStability` of every task that does not set its own
|
|
61
|
+
* field. `VX_INPUT_STABILITY` / `VX_INPUT_STABILITY_SEVERITY` override
|
|
62
|
+
* both for the run.
|
|
63
|
+
*/
|
|
64
|
+
inputStability?: InputStability
|
|
58
65
|
/**
|
|
59
66
|
* Graph rules a run checks before it schedules anything. Each is on unless
|
|
60
67
|
* set to `false`; turning one off allows a shape vx handles correctly but
|
|
@@ -491,15 +498,29 @@ export interface CacheConfig {
|
|
|
491
498
|
/** What this task produces. Captured for restore on a cache hit. */
|
|
492
499
|
outputs: CacheOutputs
|
|
493
500
|
/**
|
|
494
|
-
* What a write to an input while the command runs means.
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
+
* What a write to an input while the command runs means. Each field,
|
|
502
|
+
* highest first: its env var, this, the workspace's `inputStability`,
|
|
503
|
+
* the default (`'immutable'`, `'warn'`).
|
|
504
|
+
*/
|
|
505
|
+
inputStability?: InputStability
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export interface InputStability {
|
|
509
|
+
/**
|
|
510
|
+
* `'immutable'` (the default): any write to an input during the command
|
|
511
|
+
* withholds the entry, whatever the file holds after, since an edit
|
|
512
|
+
* undone mid-run cannot be told from no edit. `'mutable'`: a file
|
|
513
|
+
* rewritten to the bytes the key folded counts as unchanged, for a task
|
|
514
|
+
* that rewrites its own input identically (tsdown's `package.json`).
|
|
515
|
+
* `'unchecked'`: no check.
|
|
516
|
+
*/
|
|
517
|
+
strategy?: 'immutable' | 'mutable' | 'unchecked'
|
|
518
|
+
/**
|
|
519
|
+
* What a withheld entry does to the run. `'warn'` (the default): the
|
|
520
|
+
* result stands, vx says why, and the run goes on. `'error'`: the task
|
|
521
|
+
* fails, so the run fails.
|
|
501
522
|
*/
|
|
502
|
-
|
|
523
|
+
severity?: 'warn' | 'error'
|
|
503
524
|
}
|
|
504
525
|
|
|
505
526
|
export interface CacheOutputs {
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import type { ExecConfig, TaskConfig, CacheConfig } from '../config.js'
|
|
2
|
+
import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
|
|
3
3
|
import {
|
|
4
4
|
ArtifactVanishedError,
|
|
5
5
|
type CacheEntry,
|
|
@@ -175,6 +175,11 @@ export interface ExecuteArgs {
|
|
|
175
175
|
* `exec.timeout` wins. Threaded as an option only — never hashed.
|
|
176
176
|
*/
|
|
177
177
|
timeout?: number
|
|
178
|
+
/**
|
|
179
|
+
* The run's `inputStability` layers around the task's own: `run` from
|
|
180
|
+
* the env vars (above the task), `workspace` from `vx.workspace` (below).
|
|
181
|
+
*/
|
|
182
|
+
inputStability?: { run: InputStability; workspace?: InputStability }
|
|
178
183
|
/** Per-run memo for `git ls-files` (one entry per project dir). */
|
|
179
184
|
gitFilesCache?: GitFilesCache
|
|
180
185
|
/** Per-run memo for derived hashes (package.json bytes + task config). */
|
|
@@ -338,15 +343,21 @@ async function restoreOrMiss(
|
|
|
338
343
|
}
|
|
339
344
|
|
|
340
345
|
/**
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* the
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* 2026-10-12).
|
|
346
|
+
* What a write to an input during the command means, each field highest
|
|
347
|
+
* first: the env var (`layers.run`), the task's `cache.inputStability`,
|
|
348
|
+
* the workspace's, then `'immutable'` and `'warn'` (owner, 2026-10-12), so
|
|
349
|
+
* by default any such write withholds the save, on CI too, since a
|
|
350
|
+
* concurrent task can change an input and put it back.
|
|
347
351
|
*/
|
|
348
|
-
function
|
|
349
|
-
|
|
352
|
+
function inputStabilityOf(
|
|
353
|
+
cache: CacheConfig | undefined,
|
|
354
|
+
layers: ExecuteArgs['inputStability'],
|
|
355
|
+
): Required<InputStability> {
|
|
356
|
+
const task = cache?.inputStability
|
|
357
|
+
return {
|
|
358
|
+
strategy: layers?.run.strategy ?? task?.strategy ?? layers?.workspace?.strategy ?? 'immutable',
|
|
359
|
+
severity: layers?.run.severity ?? task?.severity ?? layers?.workspace?.severity ?? 'warn',
|
|
360
|
+
}
|
|
350
361
|
}
|
|
351
362
|
|
|
352
363
|
/**
|
|
@@ -1462,6 +1473,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1462
1473
|
|
|
1463
1474
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
1464
1475
|
|
|
1476
|
+
const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
|
|
1477
|
+
let movedFails = false
|
|
1465
1478
|
let own: Awaited<ReturnType<typeof ownOutputs>>
|
|
1466
1479
|
if (effectiveExitCode === 0 && willSave && deferralRequested) {
|
|
1467
1480
|
// The outputs never landed here: no artifact, no rows. A partial local
|
|
@@ -1576,7 +1589,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1576
1589
|
described!.facts,
|
|
1577
1590
|
cache,
|
|
1578
1591
|
described!.describedAt,
|
|
1579
|
-
|
|
1592
|
+
stability.strategy === 'mutable',
|
|
1580
1593
|
)) ?? described!.added()
|
|
1581
1594
|
)
|
|
1582
1595
|
}
|
|
@@ -1597,6 +1610,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1597
1610
|
unkeyed = true
|
|
1598
1611
|
return false
|
|
1599
1612
|
}
|
|
1613
|
+
// `'unchecked'` skips the check of the command's own window; a key the
|
|
1614
|
+
// re-derive before the command already disagreed with still withholds,
|
|
1615
|
+
// since it never described the bytes the command read.
|
|
1616
|
+
if (stability.strategy === 'unchecked' && described!.hash === hash) return true
|
|
1600
1617
|
const endCheck = span('miss: recheck inputs')
|
|
1601
1618
|
const moved = await movedSinceKey()
|
|
1602
1619
|
endCheck()
|
|
@@ -1611,15 +1628,25 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1611
1628
|
moved === null || path.basename(moved) === 'package.json'
|
|
1612
1629
|
? ''
|
|
1613
1630
|
: '; if the task writes it, declare it in cache.outputs'
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
`
|
|
1617
|
-
|
|
1631
|
+
if (stability.severity === 'error') {
|
|
1632
|
+
log.status(
|
|
1633
|
+
`[vx] ${node.id}: ${what} changed after its key was taken — the result is not saved, ` +
|
|
1634
|
+
`and inputStability.severity is 'error', so the task fails${hint}`,
|
|
1635
|
+
)
|
|
1636
|
+
movedFails = true
|
|
1637
|
+
} else {
|
|
1638
|
+
log.status(
|
|
1639
|
+
`[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
|
|
1640
|
+
`but is not saved under a key that no longer describes it${hint}`,
|
|
1641
|
+
)
|
|
1642
|
+
}
|
|
1618
1643
|
forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
|
|
1619
1644
|
unkeyed = true
|
|
1620
1645
|
return false
|
|
1621
1646
|
}
|
|
1622
1647
|
|
|
1648
|
+
if (movedFails) effectiveExitCode = 1
|
|
1649
|
+
|
|
1623
1650
|
if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
|
|
1624
1651
|
log.status(
|
|
1625
1652
|
`[vx] ${node.id}: ran over ${unkeyedUpstream.node.id}'s outputs, which its key no longer ` +
|
|
@@ -86,6 +86,8 @@ export interface RunOptions {
|
|
|
86
86
|
* saying it is not.
|
|
87
87
|
*/
|
|
88
88
|
remoteRequested?: boolean
|
|
89
|
+
/** This run's `cacheScope` (the CLI's `--cache-scope`), above `VX_CACHE_SCOPE` and `vx.workspace`. */
|
|
90
|
+
cacheScope?: string
|
|
89
91
|
/**
|
|
90
92
|
* The `cacheScope` a run takes when `vx.workspace` names none. The CLI
|
|
91
93
|
* passes `'read-only'` off CI (`CI` unset, `0` or `false`) unless
|
|
@@ -288,13 +288,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
288
288
|
|
|
289
289
|
// The local cache opens BEFORE the configs load: it is also where their
|
|
290
290
|
// cached evaluations live.
|
|
291
|
-
// `VX_CACHE_SCOPE
|
|
291
|
+
// `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
|
|
292
292
|
// a CI job that knows its trust level says so without editing config.
|
|
293
293
|
const envScope = process.env['VX_CACHE_SCOPE'] || undefined
|
|
294
294
|
if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
|
|
295
295
|
const policy: CachePolicy = scopeCachePolicy(
|
|
296
296
|
options.cache ?? FULL_CACHE_POLICY,
|
|
297
|
-
envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
|
|
297
|
+
options.cacheScope ?? envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
|
|
298
298
|
)
|
|
299
299
|
const localCache = new Cache(
|
|
300
300
|
cacheDir,
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// so the layers can be swapped without touching the others.
|
|
4
4
|
|
|
5
5
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
inputStabilityEnv,
|
|
8
|
+
loadWorkspace,
|
|
9
|
+
unreachedHint,
|
|
10
|
+
unreachedPackages,
|
|
11
|
+
} from '../workspace/index.js'
|
|
7
12
|
import { realpathSync } from 'node:fs'
|
|
8
13
|
import path from 'node:path'
|
|
9
14
|
import {
|
|
@@ -610,6 +615,13 @@ async function runOnBus(
|
|
|
610
615
|
// ignored (a bad timeout must not silently disable a task's own limit).
|
|
611
616
|
const taskTimeoutDefault =
|
|
612
617
|
options.timeout ?? readTaskTimeoutEnv() ?? workspaceConfig?.timeout ?? undefined
|
|
618
|
+
// Read once, before any task: a bad value is refused here, not mid-run.
|
|
619
|
+
const inputStability = {
|
|
620
|
+
run: inputStabilityEnv(),
|
|
621
|
+
...(workspaceConfig?.inputStability !== undefined
|
|
622
|
+
? { workspace: workspaceConfig.inputStability }
|
|
623
|
+
: {}),
|
|
624
|
+
}
|
|
613
625
|
|
|
614
626
|
// Run-scoped registries of live subprocesses:
|
|
615
627
|
// - `liveChildren`: in-flight children. The runner adds/removes
|
|
@@ -1056,6 +1068,7 @@ async function runOnBus(
|
|
|
1056
1068
|
}
|
|
1057
1069
|
if (options.retries !== undefined) a.retries = options.retries
|
|
1058
1070
|
if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
|
|
1071
|
+
a.inputStability = inputStability
|
|
1059
1072
|
if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
|
|
1060
1073
|
if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
|
|
1061
1074
|
if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
|
|
@@ -238,7 +238,7 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
238
238
|
* input changed and changed BACK while the command ran matched its digest
|
|
239
239
|
* again, and the entry saved the other content's output under the key (item
|
|
240
240
|
* 1015). A write before it — an upstream's — is judged by content, as before.
|
|
241
|
-
* `writesByBytes` (`
|
|
241
|
+
* `writesByBytes` (`inputStability.strategy: 'mutable'`) judges a write
|
|
242
242
|
* during the command by content too: the task says the same bytes are its
|
|
243
243
|
* own identical rewrite (tsdown's package.json), not an edit undone.
|
|
244
244
|
*/
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
PLUGIN_FUNCTION_HOOKS,
|
|
11
11
|
PLUGIN_HOOKS,
|
|
12
12
|
PLUGIN_PACKAGE,
|
|
13
|
+
type InputStability,
|
|
13
14
|
type Plugin,
|
|
14
15
|
type ProjectConfig,
|
|
15
16
|
type WorkspaceConfig,
|
|
@@ -43,9 +44,65 @@ export function isCacheScope(v: unknown): v is string {
|
|
|
43
44
|
return typeof v === 'string' && /^[\w.@/-]{1,128}$/.test(v)
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
/** Why `VX_CACHE_SCOPE` is refused, in `cacheScope`'s words. */
|
|
47
|
+
/** Why a `VX_CACHE_SCOPE` or `--cache-scope` value is refused, in `cacheScope`'s words. */
|
|
48
|
+
export function cacheScopeError(source = 'VX_CACHE_SCOPE'): string {
|
|
49
|
+
return `${source} ${CACHE_SCOPE_RULE}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Why `VX_CACHE_SCOPE` is refused, as the run's error. */
|
|
47
53
|
export function cacheScopeEnvError(): UserError {
|
|
48
|
-
return new UserError(
|
|
54
|
+
return new UserError(cacheScopeError())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const STRATEGIES = ['immutable', 'mutable', 'unchecked'] as const
|
|
58
|
+
const SEVERITIES = ['warn', 'error'] as const
|
|
59
|
+
const INPUT_STABILITY_FIELDS = new Set(['strategy', 'severity'])
|
|
60
|
+
const oneOf = (values: readonly string[]): string =>
|
|
61
|
+
`must be ${values
|
|
62
|
+
.map((v) => `'${v}'`)
|
|
63
|
+
.join(', ')
|
|
64
|
+
.replace(/, ([^,]*)$/, ' or $1')}`
|
|
65
|
+
|
|
66
|
+
function envOneOf<T extends string>(
|
|
67
|
+
name: string,
|
|
68
|
+
raw: string | undefined,
|
|
69
|
+
values: readonly T[],
|
|
70
|
+
): T | undefined {
|
|
71
|
+
if (raw === undefined || raw === '') return undefined
|
|
72
|
+
if (!(values as readonly string[]).includes(raw)) throw new UserError(`${name} ${oneOf(values)}`)
|
|
73
|
+
return raw as T
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateInputStability(stability: unknown, at: string): void {
|
|
77
|
+
if (typeof stability !== 'object' || stability === null || Array.isArray(stability)) {
|
|
78
|
+
throw new UserError(`${at} must be an object — \`inputStability: { strategy: 'mutable' }\``)
|
|
79
|
+
}
|
|
80
|
+
assertKnownFields(stability, INPUT_STABILITY_FIELDS, at)
|
|
81
|
+
const { strategy, severity } = stability as Record<string, unknown>
|
|
82
|
+
if (strategy !== undefined && !STRATEGIES.includes(strategy as never)) {
|
|
83
|
+
throw new UserError(`${at}.strategy ${oneOf(STRATEGIES)}`)
|
|
84
|
+
}
|
|
85
|
+
if (severity !== undefined && !SEVERITIES.includes(severity as never)) {
|
|
86
|
+
throw new UserError(`${at}.severity ${oneOf(SEVERITIES)}`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `VX_INPUT_STABILITY` and `VX_INPUT_STABILITY_SEVERITY`, which override
|
|
92
|
+
* every task's `cache.inputStability` for the run: each undefined when
|
|
93
|
+
* unset or empty, refused when it names no value.
|
|
94
|
+
*/
|
|
95
|
+
export function inputStabilityEnv(): InputStability {
|
|
96
|
+
const strategy = envOneOf('VX_INPUT_STABILITY', process.env['VX_INPUT_STABILITY'], STRATEGIES)
|
|
97
|
+
const severity = envOneOf(
|
|
98
|
+
'VX_INPUT_STABILITY_SEVERITY',
|
|
99
|
+
process.env['VX_INPUT_STABILITY_SEVERITY'],
|
|
100
|
+
SEVERITIES,
|
|
101
|
+
)
|
|
102
|
+
return {
|
|
103
|
+
...(strategy !== undefined ? { strategy } : {}),
|
|
104
|
+
...(severity !== undefined ? { severity } : {}),
|
|
105
|
+
}
|
|
49
106
|
}
|
|
50
107
|
|
|
51
108
|
const WORKSPACE_FIELDS = new Set([
|
|
@@ -57,6 +114,7 @@ const WORKSPACE_FIELDS = new Set([
|
|
|
57
114
|
'cacheScope',
|
|
58
115
|
'rules',
|
|
59
116
|
'schedule',
|
|
117
|
+
'inputStability',
|
|
60
118
|
'plugins',
|
|
61
119
|
])
|
|
62
120
|
|
|
@@ -198,6 +256,9 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
198
256
|
throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
|
|
199
257
|
}
|
|
200
258
|
if (config.rules !== undefined) validateRules(config.rules, configPath)
|
|
259
|
+
if (config.inputStability !== undefined) {
|
|
260
|
+
validateInputStability(config.inputStability, `${configPath}: \`inputStability\``)
|
|
261
|
+
}
|
|
201
262
|
if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
|
|
202
263
|
throw new UserError(
|
|
203
264
|
`${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
|
|
@@ -668,10 +729,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
668
729
|
)
|
|
669
730
|
}
|
|
670
731
|
assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
|
|
671
|
-
const
|
|
672
|
-
if (
|
|
673
|
-
|
|
674
|
-
}
|
|
732
|
+
const stability = (cache as { inputStability?: unknown }).inputStability
|
|
733
|
+
if (stability !== undefined)
|
|
734
|
+
validateInputStability(stability, `${where}.cache.inputStability`)
|
|
675
735
|
const inputs = (cache as { inputs?: unknown }).inputs
|
|
676
736
|
const outputs = (cache as { outputs?: unknown }).outputs
|
|
677
737
|
// A present field of the wrong shape (`outputs: 'dist'`) was told it
|
|
@@ -908,7 +968,7 @@ const EXEC_FIELDS = new Set([
|
|
|
908
968
|
])
|
|
909
969
|
const PERSISTENT_FIELDS = new Set(['readyWhen'])
|
|
910
970
|
const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
|
|
911
|
-
const CACHE_FIELDS = new Set(['inputs', 'outputs', '
|
|
971
|
+
const CACHE_FIELDS = new Set(['inputs', 'outputs', 'inputStability'])
|
|
912
972
|
const CACHE_INPUT_FIELDS = new Set([
|
|
913
973
|
'files',
|
|
914
974
|
'workspaceFiles',
|
|
@@ -951,6 +1011,15 @@ export const REMOVED_FIELDS: ReadonlyMap<
|
|
|
951
1011
|
},
|
|
952
1012
|
},
|
|
953
1013
|
],
|
|
1014
|
+
[
|
|
1015
|
+
CACHE_FIELDS,
|
|
1016
|
+
{
|
|
1017
|
+
rewrittenInputs: {
|
|
1018
|
+
removedIn: '0.0.636',
|
|
1019
|
+
use: "`inputStability: { strategy: 'immutable' | 'mutable' }` ('withhold', 'compare')",
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
],
|
|
954
1023
|
])
|
|
955
1024
|
|
|
956
1025
|
/**
|