@vzn/vx 0.0.641 → 0.0.643

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 (77) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. package/src/cli/docs-text.d.ts +0 -5
package/docs/schema.md DELETED
@@ -1,2468 +0,0 @@
1
- # Config schema
2
-
3
- Complete reference for every field accepted by `vx.config.{ts,mts,js,mjs,cts,cjs}`
4
- and the workspace-level `.vx/workspace.{ts,mts,js,mjs,cts,cjs}`, which is
5
- required: it marks the workspace root (cli.md § The workspace root). The
6
- authoritative TypeScript definitions live in `src/config.ts` and are
7
- re-exported from `@vzn/vx`.
8
-
9
- ## File layout
10
-
11
- ```
12
- <workspaceRoot>/
13
- ├── vx.config.ts (optional: the root project, named `//`)
14
- ├── .vx/
15
- │ ├── workspace.ts (required: marks the root; workspace-wide settings)
16
- │ ├── projects.lock.json (`vx lock`, minified; committed)
17
- │ ├── .gitattributes (`projects.lock.json linguist-generated=true`; written when missing)
18
- │ ├── .gitignore (`/cache`; written by `vx init` and the cache when missing)
19
- │ └── cache/ (machine-local state; the only part git ignores)
20
- └── <any>/<dir>/
21
- ├── vx.config.ts (this directory is a project; its tasks)
22
- └── vx.lock.json (`vx lock`: its resolved config, pretty, for review)
23
- ```
24
-
25
- **A project is a directory holding a `vx.config.*`**, in any language;
26
- core reads no `package.json` and no package-manager member list. `vx lock`
27
- finds them (git's tracked and unignored files; a config at or under a
28
- nested `.vx/workspace.*` is that workspace's) and records each by
29
- directory; a run takes its projects from the lock, so a config added since
30
- runs once `vx lock` records it. A `discover` plugin may add directories
31
- with no config (`@vzn/vx-lockfile` adds the package manager's members).
32
-
33
- A project's name is its config's `name`, else the name a `discover` plugin
34
- gives (`@vzn/vx-lockfile` gives `package.json#name`), else its
35
- root-relative path (`apps/web`); the root is `//`. Two projects of one
36
- name are refused, naming both directories.
37
-
38
- ## Project config
39
-
40
- ```ts
41
- import { defineProject } from '@vzn/vx/config'
42
-
43
- export default defineProject({
44
- name: 'web',
45
- tags: ['scope:web', 'type:app'],
46
- tasks: {
47
- <taskName>: TaskConfig,
48
- ...
49
- },
50
- })
51
- ```
52
-
53
- ```ts
54
- interface ProjectConfig {
55
- name?: string // what `<name>#<task>`, filters and `dependsOn` address; no "#"
56
- tags?: readonly string[] // labels for `--filter tag:<name>`
57
- tasks?: Record<string, TaskConfig> // keyed by task name
58
- }
59
- ```
60
-
61
- `defineProject` is an identity function — it exists purely so
62
- TypeScript narrows literal types in your config (clean autocomplete,
63
- strict validation against the schema). It has zero runtime effect.
64
- The same typing without a runtime import is
65
- `import type { ProjectConfig } from '@vzn/vx/config'` plus
66
- `export default { … } satisfies ProjectConfig` — the form `vx init`
67
- and `@vzn/vx-migrate` generate, because Bun erases the type import and the
68
- file then loads in a workspace that runs the `vx` binary without the
69
- package installed. Either form is fine; the object is what vx reads.
70
- `@vzn/vx/config` is the schema alone (`src/config.ts`, which imports
71
- nothing): your own `tsc` checks that one file, where `@vzn/vx` would
72
- walk core's Bun-only sources and fail without `@types/bun`.
73
-
74
- The object is JSON data. The cache key folds `JSON.stringify` of each
75
- task's config, `vx lock` stores the same JSON, and `vx watch` re-reads a
76
- config through a worker that hands it back as JSON, so a value JSON
77
- cannot carry would be dropped, rewritten or refused depending on the
78
- path. vx refuses it on every path, before the schema: a function, a
79
- symbol, a bigint, `NaN` or `±Infinity`, `undefined` inside an array
80
- (JSON writes `null`), a cycle, a getter or setter (code that each read
81
- may answer differently; vx never calls it), and any object that is not a
82
- plain object or an array (a `Date`, `Map`, `Set`, `RegExp` or class
83
- instance). An
84
- `undefined` property is fine: JSON drops it and the schema reads it as
85
- absent, which is what a conditional spread
86
- (`...(ci ? { retries: 2 } : {})`) relies on. The workspace config is not
87
- held to this: its plugins are objects of functions.
88
-
89
- `tasks` is a `Record<string, TaskConfig>`. Task names are strings
90
- referenced by `dependsOn`, by `inputs.tasks`, and by the CLI
91
- (`vx run <taskName>` or `vx run <pkg>#<taskName>`), so a name those
92
- could not spell is refused at load: an empty one, one with surrounding
93
- whitespace, one holding `#` (the project separator) or `*` (a pattern),
94
- and one starting with `^` (dependencies' tasks) or `!` (a negation).
95
-
96
- `tags` label the project for selection: `--filter tag:<name>` selects
97
- the projects carrying one (`cli.md` § Filter DSL), as Nx's `tag:` does.
98
- Each is a non-empty string a tag filter can name, or it is refused at
99
- load: no surrounding whitespace, no `*` (a pattern), and no ending in
100
- `...` (a dependency walk) or `[<ref>]` (a git range). A
101
- `project` plugin may set or edit them (`nx()` gives each project its Nx
102
- `tags` unless the vx.config has its own). A tag is in no cache key: it
103
- changes no task's behaviour, so editing one re-runs nothing.
104
-
105
- ## `TaskConfig`
106
-
107
- ```ts
108
- interface TaskConfig {
109
- description?: string // one-line blurb for the picker / --dry view
110
- exec?: ExecConfig // omit to declare a group task
111
- dependsOn?: readonly string[] // Turbo/Nx micro-syntax
112
- inputs?: InputsConfig // what the task reads: its key, cached or not
113
- cache?: CacheConfig // caching is opt-in; needs `exec` and `inputs`
114
- }
115
- ```
116
-
117
- A task either has an `exec` (it does work) or omits `exec` and declares
118
- `dependsOn` or `inputs` (it's a **group task**: an aggregator, or a key).
119
- The loader rejects a task that has none of them — a no-op standalone
120
- task is almost always a config mistake. `dependsOn: []` is the deliberate form: an
121
- explicit empty group, for a package that wants a task by that name to
122
- exist and do nothing.
123
-
124
- A project whose config and plugins declare no `build` gets one (owner,
125
- 2026-10-04): a group with `dependsOn: ['^build']` keyed on every file of
126
- the project (`inputs.files: ['**']`, no outputs). It runs nothing,
127
- but a dependant behind `^build` folds its key, so a package consumed as
128
- source moves its dependants' keys and reaches them under `--affected`.
129
- A config can declare a keyed group too (Group tasks, below). A
130
- bare `vx run build` does not select it: run where no selected project
131
- declares `build`, it is refused as any undeclared name is, while
132
- `lib#build` names it and a dependant's `^build` reaches it. `vx show`
133
- and `vx info` count it; `vx show build` and the `vx build` hint, which
134
- name declared tasks, do not. Its `^build` needs an edge source as any
135
- `^` does: with no plugin returning `deps`, running it is refused.
136
-
137
- ### `description` (optional)
138
-
139
- ```ts
140
- description?: string
141
- ```
142
-
143
- A short one-line blurb describing what the task does. No effect on
144
- scheduling or execution, but it **does** participate in the cache key —
145
- the key hashes the whole resolved task config (see `caching.md`, step 5),
146
- and carving exceptions out of that object is what invites stale hits.
147
- Editing a description therefore costs one re-run. Surfaced in three
148
- places:
149
-
150
- - The interactive task picker (`vx run` with no positional in a TTY) —
151
- printed to the right of each `pkg#task` id.
152
- - The `--dry` text preview — printed on a second indented line under
153
- the cache-status row.
154
- - The `--dry=json` output — `description` lives on each task entry.
155
-
156
- ```ts
157
- test: {
158
- description: 'bun test against the tests/ tree',
159
- exec: { command: 'bun test' },
160
- inputs: { files: ['src/**', 'tests/**'] },
161
- cache: {
162
- outputs: { files: [] },
163
- },
164
- }
165
- ```
166
-
167
- ### `exec` (optional — required for non-group tasks)
168
-
169
- ```ts
170
- interface ExecConfig {
171
- command: string // shell command, run from the project's dir
172
- remote?: boolean | 'only' // placement: pin here, or remote-only (see below)
173
- env?: ExecEnv // optional per-task env layering
174
- timeout?: number // ms before vx SIGTERMs the child (see below)
175
- retries?: number // max additional attempts after a failure (see below)
176
- persistent?: PersistentConfig // long-running task (dev server, watcher)
177
- interactive?: boolean // reads the terminal: a prompt, a REPL (see below)
178
- sandbox?: SandboxConfig // opt-in OS sandbox for this command
179
- }
180
- ```
181
-
182
- `command` is a string. Run via `sh -c` (`dash` on macOS, as Linux's
183
- `sh` is), so POSIX
184
- shell semantics work directly — pipes, redirects, `&&` chaining:
185
-
186
- ```ts
187
- exec: {
188
- command: 'tsc -b'
189
- }
190
- exec: {
191
- command: 'gen && tsc && cp -r assets dist/'
192
- }
193
- exec: {
194
- command: 'find src -name "*.snap" | xargs rm -f'
195
- }
196
- ```
197
-
198
- CLI args after `--` are appended to `command`, each POSIX single-quoted
199
- unless it holds only safe characters:
200
-
201
- ```sh
202
- vx run test -- --bail 'a b'
203
- # child sees: bun test --bail 'a b'
204
- ```
205
-
206
- Forwarded args are folded into the cache key — different args produce
207
- distinct cache entries.
208
-
209
- #### `timeout` (optional)
210
-
211
- Maximum time in milliseconds before vx SIGTERMs the child. Omitted →
212
- no limit.
213
-
214
- ```ts
215
- build: { exec: { command: 'tsc -b', timeout: '2m' } }
216
- ```
217
-
218
- - For a **normal task**, `timeout` bounds the total run time, counted
219
- from the spawn (a sandbox's setup, its wrap included, is vx's, not the
220
- task's). A task
221
- that overruns is killed — its whole process group, so what it forked
222
- goes with it — and reported `failed` (timed out) — never cached. (A timeout SIGTERM is a real failure, distinct from a Ctrl-C
223
- teardown, which is reported `aborted`.)
224
- - For a **persistent task**, `timeout` bounds the **readiness wait**
225
- instead: if `readyWhen` hasn't matched within the window the child is
226
- SIGTERMed and the task fails — see `persistent` below. A persistent
227
- task that's ready on spawn (no `readyWhen`) becomes ready before the
228
- timer can fire, so the timeout is a no-op for it.
229
- - On a **plugin executor**, the timeout aborts the request's `signal`
230
- instead (core cannot kill a process the executor spawned); any exit
231
- after it is reported timed out, a 0 from a child that trapped the stop
232
- included (X-125), and an executor still running after the kill grace
233
- is abandoned (H-12, H-14).
234
-
235
- **Upper bound.** `timeout` must be at most **2147483647 ms (~24.8 days)**,
236
- the largest delay a timer can hold. A larger value does _not_ mean "no
237
- limit" — the platform silently reduces it to **1 ms**, so the task would
238
- be killed the moment it spawns and reported `failed`. vx refuses it at
239
- load rather than let that happen. **Omit `timeout` entirely for no
240
- limit.** The same bound applies to `--timeout` and to the workspace-level
241
- `run.timeout`; the `VX_RUN_TIMEOUT` env rung is clamped to it instead of
242
- refused, since that rung never fails a run.
243
-
244
- A task with **no** `exec.timeout` falls back to a run-level default, if
245
- one is set. Precedence, highest first: **per-task `exec.timeout` →
246
- `--timeout <duration>` / `RunOptions.timeout` → `VX_RUN_TIMEOUT` →
247
- workspace `run.timeout`** (see workspace config below). Per-task always
248
- wins. The run-level defaults are threaded as run options, so — like
249
- `--retries` — they never touch a cache key: a `--timeout` run cache-hits a
250
- plain run's entry.
251
-
252
- **Why no multi-step `commands: string[]`?** Per-task caching is the
253
- right granularity for invalidation. If you'd benefit from caching each
254
- step independently (e.g. `codegen` then `build`), split into two
255
- tasks linked by `dependsOn`. If you don't need that, `&&` in shell is
256
- the right tool.
257
-
258
- #### `retries` (optional)
259
-
260
- Maximum ADDITIONAL attempts after a failed attempt. `retries: 2` means
261
- up to 3 executions total. `0` / omitted → no retries (fail on the first
262
- non-zero exit).
263
-
264
- ```ts
265
- test: { exec: { command: 'bun test', retries: 1 } }
266
- ```
267
-
268
- - A retry fires after ANY failure, `timeout` kills included. A Ctrl-C
269
- teardown (`aborted`) is never retried — the run is tearing down.
270
- Nor is a task in flight when `--continue=never` stops the run: its
271
- attempt finishes, and its failure is the last, even when its retry
272
- was already announced and preparing.
273
- - Declared outputs are re-cleaned before each retry, exactly like the
274
- first attempt — a failed attempt's partial outputs can't leak into
275
- the next.
276
- - Every attempt streams its output live. Between attempts vx emits one
277
- stderr line into the task's stream, ending in the reason:
278
- `vx: retrying <id> (attempt <k>/<total>) after exit <code>`, or
279
- `after a timeout` when the attempt was killed by `timeout`.
280
- - The final outcome is the LAST attempt's: the first success wins (and
281
- is what gets cached — its output only, not a concatenation of failed
282
- attempts); if every attempt fails, the task is `failed` with the last
283
- exit code and nothing is cached, as today.
284
- - The task's reported duration sums every attempt; a saved entry keeps
285
- the producing attempt's time.
286
- - `retries` is part of the resolved config, so declaring it derives a
287
- distinct cache key (like every config field); tasks without it keep
288
- byte-identical keys.
289
- - Not allowed with `persistent` — a persistent task has no exit to
290
- retry (config error, like `cache` + `persistent`).
291
-
292
- The run-level default is `run.retries` (`vx run --retries <n>`, `VX_RUN_RETRIES`) — it applies to tasks
293
- that don't declare their own `retries`, never to a persistent one (a
294
- server that exits before it is ready fails at once); explicit config
295
- always wins, including an explicit `retries: 0`. The CLI flag never
296
- affects cache keys.
297
-
298
- #### `remote` (optional)
299
-
300
- ```ts
301
- remote?: boolean | 'only' // default true
302
- ```
303
-
304
- **Placement.** `false` pins the task to the machine that started the run:
305
- it is never offered to an executor that declared itself `remote`. Use it
306
- for a task that talks to something only this machine has — a local
307
- daemon, a Docker socket, a device, a VPN-only host.
308
-
309
- ```ts
310
- 'docker:build': {
311
- exec: { command: 'docker build -t app .', remote: false },
312
- }
313
- ```
314
-
315
- - **Only meaningful with a remote executor declared.** A workspace with
316
- no executor plugin already runs everything here, so the field changes
317
- nothing.
318
- - **Pinned by inference too.** A `persistent` task, and anything that
319
- depends on one (transitively), is pinned regardless of what it
320
- declares — a worker cannot reach a port served on the submitter. So is
321
- a task with a `sandbox` block, and its dependants: the sandbox is this
322
- machine's machinery, and a boundary a worker does not enforce would
323
- pass vacuously. So is a task whose key folds `inputs.runtime` or
324
- `workspaceRuntime`: the key holds this machine's answer, which a worker
325
- cannot prove it shares. Its dependants are not pinned.
326
- - **Placement is decided once per task**, before scheduling, so the
327
- scheduler knows which pool a task will occupy (see
328
- [execution.md](execution.md#executor-pools)).
329
- - **`'only'` is the inverse pin — the install-as-action recipe.** The task
330
- exists to produce a REMOTE input tree (canonically `pnpm install` feeding
331
- remote workers) and is a NO-OP on this machine: never executed locally,
332
- its declared outputs never cleaned or restored on this disk. With a remote
333
- executor declared it runs on a worker, its outputs stay in the remote
334
- store, and dependents' input trees reference them there — the bytes flow
335
- worker→store→worker without ever transiting the submitter. With no remote
336
- executor it succeeds without running and dependents use the machine's
337
- ambient state (the dev's own `node_modules`), exactly as before the field
338
- existed. An `'only'` task must declare `cache` — its inputs are what a
339
- worker reproduces and its key is the address of its remote record. One
340
- without is refused at load.
341
-
342
- ```ts
343
- install: {
344
- exec: { command: 'pnpm install --frozen-lockfile', remote: 'only' },
345
- inputs: { files: ['package.json', 'pnpm-lock.yaml'] },
346
- cache: {
347
- outputs: { files: ['node_modules/**'] },
348
- },
349
- },
350
- build: { dependsOn: ['install'], /* … */ },
351
- ```
352
-
353
- - **Never busts a cache:** `remote` is stripped from the cache key —
354
- `'only'` included, and it is the only `exec` field that is. The contract of a remote executor
355
- is that the same command over the same inputs produces the same outputs,
356
- so a key that moved with placement would split a laptop from a worker pool
357
- over nothing.
358
-
359
- #### `persistent` (optional)
360
-
361
- ```ts
362
- interface PersistentConfig {
363
- readyWhen?: string // regex (as string); first matching line marks ready
364
- }
365
- ```
366
-
367
- Marks the task as a long-running process — a dev server, a file
368
- watcher, a daemon. The runner spawns the command but does NOT wait
369
- for it to exit. Instead it considers the task "ready":
370
-
371
- - Immediately on successful spawn when no `readyWhen` is given.
372
- - On the first stdout/stderr line that matches the `readyWhen`
373
- regex string. Each line is tested on its own, without its line
374
- break (`\n`, `\r\n` or a bare `\r`) and without terminal escapes
375
- (colour, OSC titles), so `^` and `$` anchor to a line as you read
376
- it and `Local:` matches Vite's bold `Local` under `FORCE_COLOR`.
377
- The trailing partial line is tested too, so prompt-style banners
378
- without a newline (`printf 'Listening on :3000'`) count, and so does
379
- output read up to 250 ms after the shell exits. A server
380
- not ready after 10 s is said once, naming
381
- the pattern it waits for, and whether `exec.timeout` bounds the wait.
382
-
383
- ```ts
384
- dev: {
385
- exec: {
386
- command: 'vite',
387
- timeout: '30s', // bound the readiness wait
388
- persistent: { readyWhen: 'Local:' },
389
- },
390
- }
391
-
392
- watch: {
393
- exec: {
394
- command: 'tsc --watch --preserveWatchOutput',
395
- persistent: { readyWhen: 'Watching for file changes' },
396
- },
397
- }
398
-
399
- // No readyWhen — ready as soon as spawn succeeds. Useful for daemons
400
- // that have no observable "ready" signal but downstream doesn't need
401
- // to gate on one.
402
- agent: {
403
- exec: {
404
- command: 'my-agent --daemon',
405
- persistent: {},
406
- },
407
- }
408
- ```
409
-
410
- Semantics:
411
-
412
- - **Downstream unblocks on ready, not on exit.** Useful for e2e tests
413
- that need a dev server up first:
414
- ```ts
415
- 'dev:run': { exec: { command: 'vite', persistent: { readyWhen: 'Local:' } } },
416
- 'e2e': { dependsOn: ['dev:run'], exec: { command: 'playwright test' } },
417
- ```
418
- - **Not started when nothing needs it.** A persistent task you did not
419
- request, whose every dependant is a confirmed local cache hit, is
420
- never spawned: a fully cached `vx run e2e` does not boot the server
421
- `e2e` depends on, and the server is in no outcome or count. It starts
422
- if one of those hits turns out to need running (its artifact went),
423
- and that task runs once it is ready. A dependant whose key the
424
- server's writes could change (one in its own project, unless a sandbox
425
- bounds the server's writes) is not probed ahead of it, so there the
426
- server still starts. `vx run --dry` says so: such a server is
427
- `not started` (`not-started` in `--dry=json`), not `would exec`.
428
- - **Exit before ready ⇒ failed.** If the persistent task crashes or
429
- exits before `readyWhen` matches, the task is reported as `failed`.
430
- - **Crash after ready ⇒ failed run.** A persistent task that exits
431
- non-zero (or is killed) on its own after it became ready fails the
432
- run, and vx names it: `vx: <id> exited with code <n>` (a signal death
433
- as its `128 + n` code), at once while the graph still runs (`… while
434
- the run went on`), so a dependant failing against it reads why. Its own outcome is `failed` with that exit
435
- code, and the footer counts it so (item 1071). An exit 0 on its own is
436
- fine (a daemon that forks and returns), and one kept only as a
437
- dependency does not end the foreground hold (WD-3).
438
- - **End-of-graph SIGTERM.** Once the rest of the graph finishes
439
- (success OR failure of downstream), the orchestrator sends `SIGTERM`
440
- to every persistent subprocess it does not keep, and waits for them to
441
- exit (`SIGKILL` past the kill grace). In the foreground it KEEPS the
442
- persistent tasks you requested, those a requested group stands for,
443
- and the persistent tasks they depend on: vx stays up after the summary
444
- until one of them exits or you press Ctrl-C, streaming what they write.
445
- A run where anything else failed keeps none and exits 1, unless
446
- `--continue=always` (`cli.md` § Output, "Pinned persistent tasks").
447
- - **`cache` is rejected.** The config loader throws on
448
- `cache + persistent` — persistent tasks don't terminate, so there's
449
- no exit code to cache and no outputs to capture at a well-defined
450
- moment.
451
-
452
- #### `interactive` (optional)
453
-
454
- `true` hands the task the terminal: a prompt (`drizzle-kit push`), a
455
- REPL, a watch mode that reads keys. Turbo's `interactive`.
456
-
457
- - **On a TTY** (vx's stdin is one) the task gets vx's own stdin,
458
- stdout and stderr. Its output is not framed, masked, kept or
459
- replayed: it goes to the terminal as the task writes it. A frame
460
- line opens before it and closes after (`--output-logs full`, and
461
- the default views), and the live status region goes for the rest
462
- of the run, as it would repaint over a prompt.
463
- - **Alone.** It starts once nothing else runs, and nothing starts
464
- until it exits. A persistent one holds the terminal from its spawn
465
- to the run's end, so other tasks may run once it is spawned (their
466
- output goes to the same terminal), a run may hold one, and every
467
- other interactive task must be its dependency. A run that breaks
468
- either is refused before any task runs.
469
- - **Off a TTY** (CI, a pipe) it runs as any task: stdin is empty, or
470
- a pipe vx holds for a persistent one, and it runs beside others.
471
- A prompt there reads end of input; it never hangs.
472
- - **Here, never cached.** No executor plugin is offered it, and
473
- `cache` is refused. `sandbox` is refused (a sandboxed task's
474
- output passes through vx), and so is `persistent.readyWhen` (vx
475
- reads none of its output; it is ready once spawned).
476
- - It runs in its own session, as every task does, so a tool that
477
- opens `/dev/tty` itself rather than its stdin still cannot, and
478
- the terminal's resize signal does not reach it. Ctrl-C reaches vx,
479
- which stops the run; a task that puts the terminal in raw mode
480
- reads the key itself.
481
- - **Its terminal modes end with it.** vx reads them (`stty -g`)
482
- before the task starts and puts them back when it exits, so a task
483
- killed in raw mode, or one that never undid its `stty raw -echo`,
484
- leaves no terminal without echo or Ctrl-C behind it
485
- (`tests/terminal.unsafe.test.ts` › "a task that leaves the terminal
486
- raw"). Without `stty` on PATH the modes stay the task's.
487
-
488
- ```ts
489
- 'db:push': {
490
- exec: { command: 'drizzle-kit push', interactive: true },
491
- }
492
- ```
493
-
494
- #### `ExecEnv` (optional)
495
-
496
- ```ts
497
- interface ExecEnv {
498
- passThrough?: readonly string[] // names taken from host process.env; exact names, a wildcard is refused
499
- define?: Record<string, string> // explicit name=value pairs
500
- secret?: readonly string[] // names whose values are masked (`***`) whatever the name; see Masking
501
- }
502
- ```
503
-
504
- The child process sees a deliberately limited env. From lowest to
505
- highest priority:
506
-
507
- 1. **Essential allowlist** (hard-coded in `src/exec/env.ts`, and pinned
508
- against this list by a test): `PATH`, `HOME`, `SHELL`, `USER`,
509
- `LOGNAME`, `TMPDIR`, `TEMP`, `TMP`, `LANG`, `LC_ALL`, `LC_CTYPE`,
510
- `TERM`, `COLORTERM`, `FORCE_COLOR`, `NO_COLOR`, `CI`, `NODE_OPTIONS`,
511
- `COREPACK_HOME`, `PNPM_HOME`.
512
- Nothing else from the parent environment reaches a task —
513
- that is the whole list. When neither `FORCE_COLOR` nor a non-empty
514
- `NO_COLOR` reaches the task by any layer, vx sets `FORCE_COLOR=1`
515
- (its own output strips the colour where it prints plain; `docs/cli.md`
516
- § Colors). Without these, typical CLI tools break. vx
517
- adds two markers of its own on top, `VX_RUN_WORKSPACE` (the root of
518
- the workspace running the task) and `VX_RUN_TASK` (`project#task`):
519
- a task whose command shells out to `vx run` in that same workspace
520
- is refused — a loop back to itself forks a run per run without
521
- bound, and a nested run that terminates is still invisible to the
522
- outer graph (its tasks escape the schedule, the concurrency budget
523
- and the cache key); declare it with `dependsOn`. Driving a
524
- _different_ workspace from a task (a fixture suite, a benchmark) is
525
- fine. **_NOT_ folded into the cache key** — the
526
- same rule `passThrough` states below, and for the same reason.
527
-
528
- Read that carefully if your build's _output_ depends on one of
529
- them, because three can change what a task produces:
530
- **`NODE_OPTIONS`** (`--require` / `--import` / `--loader` /
531
- `--conditions` inject code or switch package-export resolution),
532
- **`LC_ALL` / `LANG`** (collation — anything shelling out to `sort`
533
- orders differently), and **`CI` / `FORCE_COLOR` / `TERM`** (output
534
- bytes, and vx caches and replays output).
535
-
536
- They are not hashed because hashing them would mean a laptop and a
537
- CI runner could **never share a remote cache entry** — `PATH`,
538
- `HOME` and `TERM` differ on every machine, which is the whole point
539
- of a remote cache. vx does not guess which ones matter to your
540
- build; if one does, say so explicitly:
541
-
542
- ```ts
543
- inputs: { files: ['src/**'], env: ['NODE_OPTIONS'] }, cache: { outputs: { files: [] } }
544
- ```
545
-
546
- That folds its value into the key. The two axes are orthogonal on
547
- purpose: `inputs.env` decides what the key _sees_,
548
- `exec.env.passThrough` decides what the child _gets_.
549
-
550
- 2. **`passThrough`** names → value taken from host `process.env` at
551
- spawn time. A project config cannot set one for it: a first load
552
- that writes `process.env` is refused (D-76). _NOT_ folded into the cache key — for secrets,
553
- regional values, CI flags that legitimately vary between machines.
554
- 3. **`define`** → explicit literal values. _ARE_ folded into the
555
- cache key via the task config hash (the values are in your config
556
- file, so they participate naturally).
557
-
558
- ```ts
559
- exec: {
560
- command: 'bun test',
561
- env: {
562
- passThrough: ['CI', 'GH_TOKEN'], // values forwarded; cache-invariant
563
- define: { NODE_ENV: 'test' }, // literal; in the cache key
564
- },
565
- }
566
- ```
567
-
568
- Every name in these lists (and in `inputs.env`) must be one an
569
- environment can hold: non-empty, with no `=` and no NUL, and a `define`
570
- value holds no NUL. Such a name is refused at load; it used to reach the
571
- child split at its `=` or not at all. A `passThrough` or `define` name is
572
- also a shell variable name, `[A-Za-z_][A-Za-z0-9_]*`: the task runs under
573
- `sh -c`, and dash (Linux's `sh`) drops any other name from the environment
574
- it hands the command, so `my.var` reached a task on macOS and never on
575
- Linux. `inputs.env` takes any such name: the key reads it from vx's
576
- own environment.
577
-
578
- vx also sets `npm_execpath` to the workspace's package manager (the root
579
- `package.json`'s `packageManager`, else its lockfile, found on the root's
580
- `node_modules/.bin` or `PATH`) unless a layer gives one, as `pnpm run`
581
- does: npm-run-all calls that manager back and falls back to a global `npm`
582
- without it. It names a binary on this machine, so it is not in the key.
583
-
584
- Anything outside these three layers, the two variables vx sets for
585
- the run (`VX_RUN_WORKSPACE`, `VX_RUN_TASK`) and `npm_execpath`, is absent from the child's environment:
586
- a host credential (`SSH_AUTH_SOCK`, `GITHUB_TOKEN`) reaches a task only
587
- when `passThrough` names it, held end to end by `env.test.ts` (a
588
- sandboxed task with a restricted network also gets the sandbox's own
589
- proxy, CA and temp-directory (`TMPDIR`, `TMP`, `TEMP`) values over these names:
590
- `modules/sandbox-runtime.md` § The environment SRT sets). This
591
- matches Turbo's `passThroughEnv` semantics and exists for two reasons:
592
-
593
- - **Cache stability.** If every host env var entered the key, every
594
- shell with a different `PS1` would cache-miss.
595
- - **Determinism.** If host env leaked into the child, two machines
596
- with different `FOO=bar` set would produce different outputs and
597
- the user would never know why.
598
-
599
- This is hygiene, not a boundary: an unsandboxed task runs as you, so it
600
- can read your files. vx hides its own environment from a task
601
- (`modules/util-hide-env.md`), not your files. Only `exec.sandbox` keeps them out (`security.md` § What the sandbox stops).
602
-
603
- **Masking.** The value of a variable whose name holds `TOKEN`, `SECRET`,
604
- `KEY`, `PASSWORD`, `PASSWD` or `CREDENTIAL` (vx's own environment or a
605
- task's `define`, six characters or more; not a name ending `_FILE`,
606
- `_PATH` or `_DIR`, nor git's `GIT_CONFIG_KEY_<n>`) is printed as `***` wherever vx
607
- shows it: the task's output and the line vx adds under a shell's 127 or
608
- 126 (the command's first word), the stdout the cache keeps and a hit
609
- replays, the command a cache entry stores (what `vx why` prints and a
610
- remote cache receives), the `$ command` line, telemetry records,
611
- `vx show`, the hashes `vx why` gives for such a variable in
612
- `inputs.env` (its value, unsalted: the row names it and its change), an executor's error, a plugin's warning or failure (a remote's reply; the failure masked in the error `run()` rejects with), a failed `inputs` runtime probe's error (its command and output), and the run's own invocation line that `vx last` prints (a
613
- secret passed after `--`). So are every string of a
614
- task `vx show` prints (a runtime probe, `readyWhen`, the description),
615
- the description `--dry` and the task picker print, a runtime probe's
616
- command as the run history stores it and `vx why` names its row (two
617
- probes that mask alike are numbered, `echo *** (2)`), the readiness
618
- notice's pattern, a sandbox violation line,
619
- the error a verb ends on (a plugin's failure, a config's own throw) and
620
- `vx watch`'s `cycle failed` line. A multi-line value (a PEM
621
- key) is also masked line by line, each line of six characters or more,
622
- and a value holding a `'` also as a shell-quoted line spells it (`'\''`).
623
- A value
624
- split across two output chunks is still caught; the output holds back
625
- that many characters until the next chunk. A plugin that reads a task's
626
- config directly sees it as written. A secret whose name holds none of
627
- those words (`GH_PAT`, `NPM_AUTH`) is listed in `exec.env.secret`, and is
628
- then masked in the same places.
629
-
630
- Two `node_modules/.bin` directories are prepended to `PATH` so
631
- installed tools (`oxlint`, `vite`, etc.) work without `npx`: the
632
- project's own, then the WORKSPACE ROOT's, where a monorepo's shared
633
- tooling lives. Never a sibling project's, so sibling bins stay invisible
634
- (project isolation). A directory whose path holds `:` (PATH's delimiter)
635
- cannot be named in PATH and is left out; a task's 127 then says so.
636
-
637
- ### `dependsOn` (optional)
638
-
639
- Tasks that must complete successfully before this task runs.
640
- Turbo/Nx-style micro-syntax — a flat array of strings:
641
-
642
- ```ts
643
- dependsOn?: readonly string[]
644
- ```
645
-
646
- | Form | Meaning |
647
- | ------------ | ------------------------------------------------------------------------------- |
648
- | `'name'` | Same-project task `name`. |
649
- | `'^name'` | The `name` task in the nearest project deps (a plugin's edges) that declare it. |
650
- | `'pkg#name'` | The `name` task in a specific other package (cross-project edge). |
651
- | `'name.*'` | Every OTHER same-project task matching the pattern (`*` = any). |
652
- | `'^name.*'` | All matching tasks of the nearest deps that declare any (pattern). |
653
-
654
- Examples:
655
-
656
- ```ts
657
- dependsOn: ['build'] // same-project build first
658
- dependsOn: ['^build'] // build in every workspace dep first
659
- dependsOn: ['codegen', '^build'] // both
660
- dependsOn: ['lib#build', 'shared#test'] // cross-project edges
661
- dependsOn: ['lint.*'] // every lint.<x> task in this project
662
- dependsOn: ['^build.*'] // all build.<x> tasks of the nearest deps
663
- ```
664
-
665
- Semantics:
666
-
667
- - **Same-project (`'name'`)** — name must exist in this project's
668
- `tasks` map; missing is a hard error at graph-build time. When the
669
- config is authored through `defineProject`, a bare entry is also
670
- **type-checked against this config's task keys** — a typo is a
671
- compile error (`'^name'` / `'pkg#name'` reference other projects
672
- and stay free strings).
673
- - **`'^name'`** — nearest-holder frontier. Walk the project graph
674
- from this project's direct deps: the edges a plugin's `discover`
675
- returns as `deps` (the lockfile plugins `pnpm()` / `bun()` / `npm()` /
676
- `yarn()` return what the package manager installed; core reads no
677
- edge from a `package.json`) and the cross-project `pkg#task` edges.
678
- With no plugin returning `deps` the workspace has no edge source and
679
- a `'^name'` is refused (`VX_E_DEPENDS_ON`: "`^build` needs project
680
- edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile
681
- exists, or a plugin that returns `deps`"): it would resolve to nothing and fold no upstream into the
682
- key. With one, a project with no deps is a leaf; each path stops at the first dep
683
- that declares the task and an edge is added to it (Turbo/Nx
684
- direct-deps parity). The holder's own `dependsOn` is responsible
685
- for anything deeper — chain `'^name'` in the holder to keep the
686
- cascade going (the universal pattern). Deps that don't declare the
687
- task are passed through, so a sparse dep doesn't break ordering to
688
- deeper packages that do (sparse tasks across a workspace are normal
689
- — not every package has a `lint`). Finding no holder at all is legal
690
- too, as long as SOME project in the workspace declares `name`: a
691
- preset spreads `'^build'` over packages whose deps have no `build`.
692
- A `'^name'` that **no project in the whole workspace declares** is a
693
- hard error at graph-build time, since it can only be a typo: "app#test:
694
- dependsOn ^biuld but no project in the workspace declares
695
- biuld". Until 2026-09-24 it resolved to no edges and the run went
696
- green (Nx#32779 is the same bug). A scoped run (an anchored
697
- `app#test`, `--filter`, a project directory) loads only its projects
698
- and their dependency closure; when a `'^name'` there finds no holder
699
- and nothing loaded declares it, vx evaluates the remaining configs to
700
- decide, and if one of them fails to load the name is let through
701
- rather than failing the scoped run. A `'^name.*'` pattern matching
702
- nothing anywhere stays legal, as `'name.*'` does.
703
- - **`'pkg#name'`** — missing pkg or task is a hard error (you named
704
- them explicitly). `'//#name'`, Turbo's spelling of the root package,
705
- names the root project (D-39) here and in `inputs.tasks`; with no
706
- root project it is refused as such.
707
- - **Patterns (`'name.*'` / `'^name.*'`)** — `*` matches any run of
708
- characters in a task NAME; everything else is literal (the dotted
709
- namespace convention needs no escaping). A same-project pattern
710
- expands to every other matching task (the declaring task never
711
- matches itself); **zero matches is legal** — a preset-spread pattern
712
- needn't match in every project. A `'^pattern'` walks the same
713
- nearest-holder frontier as `'^name'`, where a holder is a dep
714
- declaring at least one matching task; the holder receives edges to
715
- ALL its matches and stops the walk. Patterns are not supported in
716
- the `'pkg#task'` form. (Nx 19.5 `build-*` parity.)
717
- - **No bare wildcards or negation here.** `'*'` / `'^*'` / `'!form'`
718
- belong in `inputs.tasks` (filtering which upstream hashes
719
- participate in this task's cache key), not in `dependsOn` (declaring
720
- graph edges). The micro-syntax parser rejects them in this position.
721
- - **Cycle detection** runs across the resolved graph at the end of
722
- `buildTaskGraph`. Cycles throw with a path-formatted message.
723
-
724
- ### `inputs` (optional)
725
-
726
- ```ts
727
- inputs?: InputsConfig
728
- ```
729
-
730
- What the task reads, and so what its key folds. A task is keyed on its
731
- inputs whether it caches or not, as Turbo and Nx hash every task: a
732
- dependant folds that key, so an edit to an uncached generator's inputs
733
- re-runs the cached task behind it. A group with `inputs` is a keyed
734
- group (Group tasks, below). The default `build` is
735
- `{ dependsOn: ['^build'], inputs: { files: ['**'] } }`.
736
-
737
- A task that runs a command and declares no `inputs` has no key: nothing
738
- says what it read. A key that folds it is this run's alone, so every
739
- task behind it misses and saves nothing, never a stale hit. Declare
740
- `inputs` on an uncached task a cached one depends on;
741
- `inputs: { files: [] }` is a declaration too: it keys the task on its
742
- config and its dependencies' keys, and a task behind it can hit. An
743
- absent `inputs` and an empty one differ.
744
-
745
- #### `InputsConfig`
746
-
747
- ```ts
748
- interface InputsConfig {
749
- files: readonly string[] // required
750
- workspaceFiles?: readonly string[] // optional; workspace-root-relative
751
- env?: readonly string[] // optional
752
- runtime?: readonly string[] // optional; project-dir shell commands
753
- workspaceRuntime?: readonly string[] // optional; workspace-root shell commands
754
- tasks?: readonly string[] // optional; same micro-syntax as dependsOn
755
- }
756
- ```
757
-
758
- ##### `inputs.files` (required)
759
-
760
- Project-relative globs. `!`-prefix negates. A **literal** entry (no
761
- glob character) names a file or a whole directory tree — `src` and
762
- `src/` both mean everything under `src`, as in Turbo and `.gitignore`;
763
- `!src` subtracts the tree. A `!` entry subtracts wherever it sits in the
764
- list, so a literal file it covers (`['src/**', '!src/gen/**',
765
- 'src/gen/keep.ts']`) would never be an input: that is refused at load.
766
- Spellings a matcher would otherwise turn into
767
- nothing are normalized: a leading `./` (`./src/**` is `src/**`, `!./gen`
768
- is `!gen`), an inner `/./` segment, a doubled `//`, and a trailing `/` on
769
- a pattern (`src/*/` is the trees under `src`, `src/*/**`). A bare `.` or
770
- `./` names the project directory itself and is refused at load — write
771
- `**`. Turbo's `$TURBO_DEFAULT$` and `$TURBO_ROOT$` and Nx's
772
- `{projectRoot}`, `{workspaceRoot}`, `{projectName}` and `{options.…}`
773
- (D-52), pasted from turbo.json or
774
- project.json, are refused in every glob list with what to write instead
775
- (D-50): vx expands none of them, so each matched no file and a task
776
- keyed on `$TURBO_DEFAULT$` alone replayed a stale output. A glob that
777
- starts with `^` (Nx's `^production`, the dependencies' named input) is
778
- refused too (D-51): vx keys a task on its dependencies through
779
- `dependsOn`. A bare named input (`default`) can be a directory, so it
780
- is taken as one and only warns when it matches nothing.
781
-
782
- The wildcards are `*`, `**`, `?` and a brace set `{a,b}`, which matches
783
- what its alternatives match on their own: `{src/**,lib/**}` is `src/**`
784
- plus `lib/**`. A bracket is a
785
- **literal character**, not a character class: `app/[id]/**` is the route
786
- directory `app/[id]` (Next.js, SvelteKit, Astro), never `app/i` or
787
- `app/d`. The escaped spelling `app/\[id\]/**` (Turbo's) means the same
788
- path. An input brace of one alternative (`{b}.ts`) is refused: it
789
- would match `b.ts` and never a file named `{b}.ts`; write the one you
790
- mean, `\{b\}.ts` for the braces. An extglob (`@(a|b)`, `!(x)`,
791
- `+(…)`, `*(…)`, `?(…)`) is refused too: `Bun.Glob` reads it as literal
792
- text or a plain wildcard, so the key would miss the files it names;
793
- write `{a,b}`. A leading `!` is still the negation, so `!(group)/**`
794
- takes a route group back. This holds for every task glob — `inputs.files`,
795
- `inputs.workspaceFiles`, `outputs.files`, `outputs.workspaceFiles` — and
796
- for everything read from them (`--affected`, `vx watch`, the
797
- overlapping-output refusal). Package-manager member globs (`workspaces`,
798
- `pnpm-workspace.yaml`) and `--filter` path globs keep the package
799
- manager's grammar, class included — all but extglob (`packages/!(x)`),
800
- which a member glob refuses by name: `Bun.Glob` cannot read it, and
801
- list the exclusion as its own `!packages/x` entry instead.
802
-
803
- ```ts
804
- files: ['**/*'] // all project files
805
- files: ['src/**', '!**/*.test.ts'] // narrow with exclusion
806
- files: [] // no file inputs at all
807
- files: ['src/**', 'tsconfig.json', 'package.json'] // specific paths
808
- files: ['src', 'package.json'] // a directory literal is its tree
809
- files: ['app/[id]/**'] // a bracket is literal: the route dir `[id]`
810
- ```
811
-
812
- Empty array is valid — the cache key still incorporates command, env,
813
- upstream hashes and any plugin's key material. A task with no _file_
814
- inputs (e.g. a pure "download-and-verify") still cache-misses on a
815
- lockfile change when a lockfile plugin (`@vzn/vx-lockfile`) is declared.
816
-
817
- Always applied to every glob pass (regardless of what you wrote):
818
-
819
- - **gitignore filter** — workspace-root + project `.gitignore`, unless
820
- an entry names the ignored path outright: a literal (`.env.local`), or
821
- a glob whose fixed prefix git ignores (`dist/**`, `dist/*.js`,
822
- `.cache/gen/**`), which is then walked on disk under that prefix only.
823
- Such a file is hashed from disk and never stored or restored; a
824
- missing one is not an input until it exists. A broad glob (`**`,
825
- `src/**` over a listed `src`) still skips ignored files, so it never
826
- walks `node_modules`.
827
- Its bytes differ per machine, so machines with different files miss
828
- each other's shared entries. An ignored file never selects a task
829
- under `--affected` on its own, as in Nx (only what git sees does); `vx
830
- watch` re-runs it on an edit there.
831
- - **Always-ignored** — `.git/**`, `.vx/cache/**`,
832
- `.vx/projects.lock.json`, `vx.lock.json`, and `.vx/tmp/**`, where vx's
833
- restores stage, at any depth. Nothing else: a tool's temp files are the
834
- task's to exclude. A broad glob on a `bun build --compile` task, or on
835
- one that runs beside it, excludes Bun's intermediate and its
836
- cross-compile extraction directory itself:
837
- `['**/*', '!**/*.bun-build', '!**/.????????????????-????????.tmp/**']`.
838
- - **Installs** — an untracked file under any `node_modules/` that git
839
- does not ignore. An ignored one follows the gitignore filter above
840
- like any other ignored path: named outright, it is an input
841
- (`node_modules/.prisma/client/**`, a generated client); a broad glob
842
- skips it because it is ignored. A file git TRACKS there, such as a committed test fixture, is an input
843
- like any other.
844
- - **Declared `outputs.files`** are excluded — a task never invalidates
845
- itself via its own output. A path an output `!` entry takes back is
846
- no output, so it stays an input (A-44). Another task's outputs are
847
- not excluded for you: an input glob that can match them is refused
848
- (X-54), unless git ignores them whole, or the producer is a
849
- dependency the key does not fold (see Inputs over outputs in §
850
- Settings). Write the exclusion,
851
- `['**/*', '!dist/**']`, or ignore `dist/`. An input entry the task's OWN outputs take
852
- back whole is always refused (X-55): vx removes outputs before a run,
853
- so a formatter declaring `src/**` as both would delete its sources. A
854
- task that rewrites files in place declares no outputs.
855
- - **Nested-project subtree** — files belonging to a project rooted
856
- inside this one's dir are excluded. No cross-project leakage via
857
- globs; the only cross-project relationship is `dependsOn` +
858
- upstream-hash propagation.
859
-
860
- ##### `inputs.workspaceFiles` (optional, default `[]`)
861
-
862
- Workspace-root-relative globs — the Turbo `$TURBO_ROOT$` / Nx
863
- `{workspaceRoot}` equivalent, for inputs that live outside the project
864
- dir (a root `tsconfig.base.json`, shared codegen output, …). Same
865
- syntax as `files` (`!` negation subtracts, in any order), same git-aware
866
- resolution (tracked + untracked-not-ignored; a gitignored file only when
867
- named outright, as in `files`), and the resolved paths join the same
868
- cache-key file list.
869
-
870
- ```ts
871
- inputs: {
872
- files: ['src/**'],
873
- workspaceFiles: ['tsconfig.base.json', 'shared/**', '!shared/README.md'],
874
- }
875
- ```
876
-
877
- **No boundary rule — deliberate escape hatch.** Unlike `files`,
878
- `workspaceFiles` globs may match files anywhere in the workspace,
879
- including inside other projects' directories. That's bad practice
880
- (prefer project-relative declarations plus `dependsOn` + upstream-hash
881
- propagation for cross-project relationships), but it is there for the
882
- cases that genuinely need root-anchored inputs. The hard
883
- project-boundary rule continues to apply to project-relative `files`
884
- globs only.
885
-
886
- No input or output glob, `files` or `workspaceFiles`, may be absolute
887
- or hold a `..` segment, and a brace arm counts: `{../shared,src}/**` and
888
- `{/etc,src}/*` are refused at load, where the glob engine would have
889
- matched nothing under that arm and said so nowhere. A Windows spelling
890
- is refused the same way, with the forward-slash glob to write: a
891
- backslash separator (`src\**`, `src\*.ts`) or a drive (`C:\src\**`,
892
- `C:/src/**`). A backslash before a bracket, a brace, a `!` or a
893
- backslash stays an escape.
894
-
895
- Still applied: the always-ignored set (`.git/**`, `.vx/cache/**`,
896
- `.vx/projects.lock.json`, `vx.lock.json`, `.vx/tmp/**`),
897
- untracked files under `node_modules/`, and the task's own declared
898
- outputs, `outputs.workspaceFiles` and the `outputs.files` its globs reach
899
- in its own project (a task never invalidates itself).
900
-
901
- `vx watch`: when any config declares `inputs.workspaceFiles`, the loop
902
- watches the workspace root recursively (any file can be an input once
903
- boundaries are off), so root-subdir changes re-trigger cycles like any
904
- other edit. The always-ignored set still filters the noise.
905
-
906
- ##### `inputs.env` (optional, default `[]`)
907
-
908
- Env var names whose host values are folded into the cache key.
909
- **Independent of `exec.env`:**
910
-
911
- - Listing a name in `exec.env.passThrough` does NOT make its value
912
- affect the cache.
913
- - Listing a name here does NOT forward it to the child process.
914
-
915
- To both forward AND track, list it in both places. Yes, the
916
- double-declaration is mild noise; a preset helper
917
- (`envTracked('NODE_ENV')`) can sugar it.
918
-
919
- Names are exact: a wildcard (`NEXT_PUBLIC_*`) is refused, and so is
920
- Turbo's `!NAME` exclusion, here, in `exec.env.passThrough` and in
921
- `exec.env.secret` (D-53). With no wildcard to take it out of, `!NAME`
922
- was a variable of that name, and nothing was excluded.
923
-
924
- ```ts
925
- inputs: {
926
- files: ['src/**'],
927
- env: ['NODE_ENV', 'TARGET_BROWSER'], // changes here bust the cache
928
- }
929
- ```
930
-
931
- A cache-input env var the task actually reads must ALSO appear in
932
- `exec.env.passThrough` (or `define`) — the child environment is
933
- isolated, so without it the key varies on a value the command never
934
- sees. Tracking-only declarations (an env var that influences inputs
935
- some other way) are legal but rare; if in doubt, declare both.
936
-
937
- An unset name, a name set to the empty string, and a name never
938
- listed are three different keys: the key holds the count of names,
939
- the names, and each value, with "unset" folded apart from every value
940
- (the child tells unset from empty too — `process.env.X ?? 'dflt'`,
941
- `${X-dflt}`). A name holding a NUL is refused.
942
-
943
- ##### `inputs.runtime` (optional, default `[]`)
944
-
945
- Shell commands whose **trimmed stdout and stderr, kept apart,** are folded
946
- into the cache key — the runtime-output analog of `inputs.env`. It runs
947
- in the project dir; the Nx
948
- [`runtime` input](https://nx.dev/recipes/running-tasks/configure-inputs),
949
- which Nx runs at the workspace root, is `workspaceRuntime` below (Turbo
950
- has no built-in for this — see
951
- [vercel/turborepo#4124](https://github.com/vercel/turborepo/issues/4124)).
952
- Use it for tool/runtime versions, OS info, or a project-local probe
953
- script whose value should bust the cache when it changes.
954
-
955
- ```ts
956
- inputs: {
957
- files: ['src/**'],
958
- runtime: ['./scripts/probe.sh', 'rustc --version'],
959
- }
960
- ```
961
-
962
- Semantics:
963
-
964
- - Each command runs in the **project dir** (cwd = the project's
965
- directory), via `sh -c`, so pipelines and redirects work ("shell is
966
- the API"). Deduped per `(projectDir, command)` within a run — a
967
- command declared by both `build` and `test` in the same project runs
968
- once.
969
- - The **output is resolved live at hash time on every run** (inside
970
- `resolveInputs`), exactly like `inputs.env` reads host env values
971
- live. `vx lock` freezes the command _strings_ (they're in the
972
- resolved config), not their output, so the field stays correct under
973
- the lock every run reads — the changed output of a locked command
974
- still busts the cache.
975
- - A **non-zero exit fails the run** (a hard `UserError` naming the
976
- command and exit code) — fail-loud, like a missing git binary. A
977
- flaky probe should not silently degrade to a stale hit.
978
- - **Output that is not UTF-8 fails the run** too: a lossy decode keyed
979
- every invalid byte alike. Pipe binary output through a hash or `od`.
980
- - The command **inherits vx's full environment**, _not_ the isolated
981
- env that task `exec` commands get — `exec.env.define` and
982
- `passThrough` describe the command's environment, not the probe's.
983
- This is deliberate — `node -v` / `rustc --version` need the real
984
- `PATH` and toolchain env, and it is what lets two tasks sharing a
985
- probe share one spawn (the per-run dedup above). The flip side (same
986
- as `inputs.env`): an env var that differs between machines silently
987
- changes the key; declare such inputs explicitly if you want them
988
- visible.
989
- - Its `PATH` does lead with the **same `node_modules/.bin` directories
990
- the task's does** (the project's, then the workspace root's), so
991
- `tsc --version` keys the `tsc` the task runs. Before item 996 it keyed
992
- the global one, or failed with 127 where only the workspace had it, and
993
- a changed local tool replayed the old output.
994
- - The command **runs whenever a task's key is derived** — that's every
995
- run (warm runs included, since the key decides hit vs miss), plus
996
- `vx run --dry` / `--graph` (which predict the key) and `vx run
997
- --no-cache` (which still derives the key). Keep runtime commands pure
998
- probes with no side effects.
999
- - The command runs as part of **hash derivation, before** the task's
1000
- `exec` — so it is **not** constrained by the task's own `sandbox`
1001
- policy (the probe runs unsandboxed even on a sandboxed task).
1002
-
1003
- ##### `inputs.workspaceRuntime` (optional, default `[]`)
1004
-
1005
- Like `runtime`, but commands run at the **workspace root** (cwd = the
1006
- workspace root) and are deduped **globally per command** across the
1007
- whole run — a `node -v` declared in 500 projects spawns exactly once.
1008
- The runtime-input analog of `workspaceFiles`: per-task, root-anchored.
1009
- Use it for global tool versions, OS info, or any probe whose value is
1010
- the same for every project. Its `PATH` leads with the workspace root's
1011
- `node_modules/.bin` only (no project's: the value is shared by all).
1012
-
1013
- ```ts
1014
- inputs: {
1015
- files: ['src/**'],
1016
- workspaceRuntime: ['node -v'], // runs once per run, root cwd
1017
- }
1018
- ```
1019
-
1020
- Same `sh -c` execution, live-at-hash-time resolution (frozen-safe), and
1021
- non-zero-exit-fails semantics as `runtime`. The two fields are folded
1022
- into the cache key in **distinct namespaces**, so an identical
1023
- `(command, output)` pair never aliases between them.
1024
-
1025
- ##### `inputs.tasks` (optional, default = all upstream)
1026
-
1027
- Same micro-syntax as `dependsOn`, plus two filter-only extras:
1028
-
1029
- | Form | Meaning |
1030
- | ------------ | ---------------------------------------------- |
1031
- | `'*'` | Include every same-project upstream hash. |
1032
- | `'^*'` | Include every dep-workspace upstream hash. |
1033
- | `'name'` | Include same-project task `name`. |
1034
- | `'^name'` | Include `name` in every dep workspace. |
1035
- | `'pkg#name'` | Include the specific package's `name` task. |
1036
- | `'!<form>'` | Exclude — any of the above with a leading `!`. |
1037
-
1038
- **Patterns** work here too, in EITHER half of every form:
1039
- `'build.*'`, `'^build.*'`, `'pkg#build.*'`, `'@acme/*#build'`,
1040
- `'!codegen.*'` — the same `*`-glob `dependsOn` patterns use. A filter
1041
- only ever selects from upstreams that already exist, so a package
1042
- pattern is unambiguous here, unlike `dependsOn`, which must
1043
- materialize concrete edges and rejects it. (A filter that matched
1044
- patterns literally would silently select zero hashes and decouple the
1045
- task from its dependencies — a stale-hit trap.)
1046
-
1047
- Patterns are applied in order; **last write wins**. So
1048
- `['*', '^*', '!^noisy']` reads as "all upstream except deps' noisy
1049
- task". Defaults:
1050
-
1051
- - **Omitted** → all upstream contribute (`['*', '^*']`). Most common.
1052
- - **`[]`** → fully decoupled; no upstream contributes.
1053
-
1054
- An **exact** entry (`'codegen'`, `'^build'`, `'lib#build'`) must be
1055
- named by some `dependsOn` entry of the same task — exactly, or by that
1056
- entry's own `*` pattern (`dependsOn: ['build.*']` names
1057
- `tasks: ['build.bun']`), and in a form that reaches the same task:
1058
- `build` is this project's, `^build` only the dependencies', so
1059
- `tasks: ['build']` against `dependsOn: ['^build']` names nothing. A
1060
- `pkg#task` entry may be this project or a dependency, so it pairs with
1061
- either form; two `pkg#task` entries must agree on the project. One that
1062
- is not named (`['buidl']`) is refused at load: it would match nothing at hash time and fold no upstream hash,
1063
- decoupling the task from its dependencies with no diagnostic — a stale
1064
- hit waiting for the next upstream change. Patterns, wildcards and
1065
- negations stay silent (a preset-spread pattern legitimately matches
1066
- nothing in some projects); `[]` is the explicit way to decouple.
1067
-
1068
- A filter selects among the task's own dependencies, never through a
1069
- group: over `dependsOn: ['build']`, where `build` is a group of
1070
- `build.a` and `build.b`, the filter sees `build`, not its members. A
1071
- pattern that matches no dependency but does match a group's member
1072
- (`'build.*'` there) is refused when the run plans: it would select
1073
- nothing and stop the key moving with that member. Name the group, or
1074
- depend on the member directly.
1075
-
1076
- Examples:
1077
-
1078
- ```ts
1079
- tasks: ['^build'] // only ^build from deps; nothing from self
1080
- tasks: ['codegen', '^*'] // self.codegen + everything from deps
1081
- tasks: ['*', '^*', '!^noisy'] // all upstream except deps.noisy
1082
- tasks: ['lib#build'] // a single cross-project hash
1083
- tasks: [] // fully decoupled
1084
- ```
1085
-
1086
- **When to use this:** when a task `dependsOn`s another for ordering
1087
- (it has to run after) but the upstream's outputs do NOT affect this
1088
- task's outputs. Typical case: an integration-test task `dependsOn`s
1089
- `build` for ordering, but its inputs are only the test source — so
1090
- `inputs.tasks: []` to keep `build` from invalidating it.
1091
-
1092
- ### `cache` (optional)
1093
-
1094
- ```ts
1095
- interface CacheConfig {
1096
- outputs: CacheOutputs // required when `cache` is provided
1097
- inputStability?: InputStability // optional
1098
- }
1099
-
1100
- interface InputStability {
1101
- strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable'
1102
- severity?: 'warn' | 'error' // default 'warn'
1103
- }
1104
- ```
1105
-
1106
- `inputStability` says what a write to an input while the command runs
1107
- means. `strategy` says which writes count:
1108
-
1109
- - `'immutable'` (the default, on CI too): any write. An edit undone
1110
- mid-run, or another task changing the file and putting it back, cannot
1111
- be told from no edit.
1112
- - `'mutable'`: a file rewritten to the bytes the key folded counts as
1113
- unchanged, for a task that rewrites its own input identically
1114
- (tsdown's `package.json`); different bytes still count, and so does
1115
- any write while another task that may write the file was running (one
1116
- of the same project, or one whose sandbox grants writes elsewhere in
1117
- the workspace; a server `vx watch` keeps from an earlier run counts),
1118
- since it may have changed the file and put it back.
1119
- The line names that task; order the two with `dependsOn` to save.
1120
- - `'unchecked'`: none. The entry is saved under the key taken before the
1121
- command, whatever the command did to its inputs; `severity` is ignored.
1122
- A key that no longer held before the command started still withholds
1123
- it.
1124
-
1125
- A write that counts withholds the entry, and `severity` says what else
1126
- happens: `'warn'` (the default) keeps the result, says why on the status
1127
- line, and the run goes on; `'error'` fails the task, so the run fails,
1128
- and its label names the file: `failed (src/a.ts changed during the run, exit 1)`
1129
- (`inputMoved` in `vx last --format json`).
1130
- Either way, every task that ran over the task's outputs, down the graph,
1131
- withholds its save too, and a task of any project that declares the
1132
- moved file as an input withholds its own.
1133
-
1134
- Each field is resolved on its own, highest first:
1135
-
1136
- 1. `VX_CACHE_INPUT_STABILITY_STRATEGY` (`immutable`, `mutable`,
1137
- `unchecked`) and `VX_CACHE_INPUT_STABILITY_SEVERITY` (`warn`, `error`), for the whole run
1138
- (a CI job that wants `error` sets it without editing configs); any
1139
- other value is refused before a task runs;
1140
- 2. the task's own `cache.inputStability`;
1141
- 3. the workspace's `inputStability` (`.vx/workspace.ts`);
1142
- 4. the default, `'immutable'` and `'warn'`.
1143
-
1144
- Never folded into a cache key.
1145
-
1146
- ```ts
1147
- import { defineProject } from '@vzn/vx/config'
1148
-
1149
- export default defineProject({
1150
- tasks: {
1151
- build: {
1152
- // tsdown rewrites package.json with the same bytes on every build;
1153
- // different bytes fail the run rather than rebuild next time.
1154
- exec: { command: 'tsdown' },
1155
- inputs: { files: ['src/**', 'package.json'] },
1156
- cache: {
1157
- outputs: { files: ['dist/**'] },
1158
- inputStability: { strategy: 'mutable', severity: 'error' },
1159
- },
1160
- },
1161
- },
1162
- })
1163
- ```
1164
-
1165
- **Caching is opt-in.** Omit `cache` and the task always runs (no read,
1166
- no write). Provide it and caching is active for that task. The
1167
- project loader requires `exec`, the task's `inputs` and `cache.outputs`
1168
- when `cache` is set: declaring "what does this read?" and "what does
1169
- it produce?" is a deliberate, conscious choice, and a task with no
1170
- command produces nothing to cache.
1171
-
1172
- **Why opt-in?** Defaulting caching to ON with implicit "all files / no
1173
- outputs" silently produces stale builds the moment a user forgets
1174
- to revisit the config. The cost of a single forgotten cache miss
1175
- (re-run a task) is much less than the cost of a stale cache hit
1176
- (ship broken artifacts).
1177
-
1178
- #### `CacheOutputs`
1179
-
1180
- ```ts
1181
- interface CacheOutputs {
1182
- files: readonly string[] // required
1183
- workspaceFiles?: readonly string[] // optional; workspace-root-relative
1184
- clean?: boolean // optional; false keeps outputs on a miss
1185
- }
1186
- ```
1187
-
1188
- Project-relative globs of files the task produces. Captured on cache
1189
- write, restored on cache hit (overwriting any local modifications),
1190
- and **wiped before exec AND before restore** so the project dir ends
1191
- every run bit-identical to the cached snapshot. A literal entry names
1192
- a file or a whole directory tree: `dist` and `dist/` are `dist/**`
1193
- (the turbo.json shape `"outputs": ["dist"]` migrates as it is); a
1194
- leading `./` is accepted and dropped; `.` alone is refused.
1195
-
1196
- ```ts
1197
- outputs: {
1198
- files: ['dist/**']
1199
- } // typical build output
1200
- outputs: {
1201
- files: []
1202
- } // tasks with no file output (lint, test)
1203
- outputs: {
1204
- files: ['dist/**', 'coverage/**']
1205
- }
1206
- ```
1207
-
1208
- Outputs are **NOT** filtered through gitignore — typical artifact
1209
- dirs like `dist/`, `coverage/`, `.next/`, `pkg/` are captured normally
1210
- even when gitignored (they usually are).
1211
-
1212
- A **symlink** the globs match is an output: it is captured as its
1213
- target's bytes and restored as a regular file, and the clean unlinks
1214
- it (never following it). An output DIRECTORY that is a symlink
1215
- (`dist -> real-out`) is followed: its target is cleaned, saved and
1216
- restored as the output; one that resolves outside the project refuses
1217
- the task, naming the link. A link to a directory, a dangling one, or
1218
- one whose target is outside the project or, for a sandboxed task, a file its grants do not let it read cannot be stored — the save refuses it by name and caches nothing, so
1219
- the next run executes again. A link to another output of the same task
1220
- is stored wherever it is: `gen/latest -> v2.txt` under a
1221
- `workspaceFiles` output caches (A-53). The clean also prunes the directories it
1222
- emptied, so an output that is a directory one run and a file the next
1223
- restores either way.
1224
-
1225
- Empty `[]` is valid for tasks that produce no files (e.g. `lint`,
1226
- `typecheck`, `test`); you still cache the no-op success so the next
1227
- run is a no-op too.
1228
-
1229
- **`clean: false`** keeps the outputs on a miss instead of wiping them
1230
- first, for a tool that reads its own last output (tsc's
1231
- `.tsbuildinfo`). It opts out of the clean-tree guarantee: the save
1232
- stores every file the outputs match after the run, so a file an older
1233
- build left behind is cached and restored on every machine. A retry
1234
- still starts clean, and a hit still restores bit-identically. Omitted → the workspace's
1235
- `cache.outputs.clean`, else `true`.
1236
-
1237
- A **`!` entry takes a path back** from what the positive globs select
1238
- (A-44; a scratch dir under `dist` stays out of the clean this way; a
1239
- tracked file needs no `!`, below):
1240
-
1241
- ```ts
1242
- outputs: {
1243
- files: ['dist/**', '!**/*.tmp']
1244
- }
1245
- ```
1246
-
1247
- A path taken back is not cleaned before a run or a restore, not saved,
1248
- not accepted from a remote artifact, and not hidden from `vx watch`;
1249
- it stays an input, so an edit to it moves the key. A list of only `!`
1250
- entries selects nothing and is refused, and `!!x` is refused as it is
1251
- for inputs. The directory short-circuit on a warm hit reads the
1252
- positive globs, so a task with a `!` entry keeps it (A-46).
1253
- `workspaceFiles` takes `!` the same way.
1254
-
1255
- **Cleaning semantics** (one of vx's strict-output-ownership rules):
1256
-
1257
- - Before exec on a cache miss: the declared output globs are resolved
1258
- and matching files / dirs are wiped. So a leftover `dist/old.js`
1259
- from a prior build can't survive into a fresh build that doesn't
1260
- rewrite it.
1261
- - Before restore on a cache hit: same wipe, then files are copied
1262
- from the cache entry into the project dir. The post-restore tree
1263
- is the cached snapshot, byte-for-byte.
1264
- - A file git tracks is never wiped (2026-10-14). It is still saved and
1265
- restored, and stays in the task's key, so an edit to it moves the key.
1266
-
1267
- Skipped when `cache.outputs.files` is empty (nothing declared as
1268
- output) and when no cache-write axis is enabled (e.g. `--no-cache`,
1269
- or a read-only `--cache-policy=local:r`) — the user is debugging and
1270
- managing the tree themselves. `--force` keeps writes on, so it does
1271
- clean.
1272
-
1273
- ##### `outputs.workspaceFiles` (optional, default `[]`)
1274
-
1275
- Workspace-root-relative globs for outputs the task writes OUTSIDE its
1276
- project dir (e.g. a root-level generated file). Same capture / restore
1277
- / wipe semantics as `files`, anchored at the workspace root; packed
1278
- into the artifact under a separate `workspace-outputs/<rel-to-root>`
1279
- namespace so project and workspace outputs never collide. A project
1280
- `outputs.files` glob under a top-level `workspace-outputs/` is refused
1281
- at load: that name is the namespace. A file another glob matches there
1282
- (`**/*.js`) is refused at save, and the task is not cached.
1283
-
1284
- ```ts
1285
- outputs: {
1286
- files: ['out.txt'],
1287
- workspaceFiles: ['generated/**'],
1288
- }
1289
- ```
1290
-
1291
- **No boundary rule — deliberate escape hatch.** These globs may
1292
- capture (and on restore, overwrite; on clean, wipe) files anywhere in
1293
- the workspace, including inside other projects' dirs. Prefer
1294
- project-relative `files` whenever the task can write inside its own
1295
- dir. Two tasks whose workspace outputs provably overlap (equal
1296
- literals, or a literal a glob matches) are refused at graph build, like
1297
- overlapping `files`: vx cleans declared outputs before a run and before
1298
- a restore, so the second would delete the first's. The comparison is by
1299
- PATH, not by spelling — `./dist/**` and `dist/**` are one declaration,
1300
- and so are `dist//**` and `dist/./app.js` (item 441) — and a literal
1301
- entry is read as the file OR its whole tree, the same rule the resolver
1302
- uses, so `dist` collides with `dist/app.js` (item 442). A whole subtree
1303
- collides with any glob whose literal head lies inside it: `dist/**` with
1304
- `dist/extra/**` or `dist/*.js` (item 941). Two globs a path built from
1305
- either one matches are one too: `**/*` and `**/dist/**` both match
1306
- `x/dist/x`. A pair is no overlap where one task's `!` output entry
1307
- takes the other's glob back whole: `['**/dist', '!types/**']` beside
1308
- `types/**` (a `!` entry is never cleaned or captured). Globs that only _might_
1309
- overlap (`dist/*` and `dist/sub/**`) are let through; there, last
1310
- restore wins. A workspace output is also compared with every other
1311
- project's `files` outputs, read from the root: `packages/b/dist/a.txt`
1312
- collides with `b`'s `dist/**` (item 1088). An overlap
1313
- between two tasks one of which depends on the other is refused too,
1314
- unless the workspace sets `run.rules.exclusiveOutputs: false` (X-53);
1315
- then the dependant is additive and owns only what its run adds to the
1316
- tree (`caching.md` § Additive outputs, item 588). Across the two namespaces
1317
- an upstream `files` task is told the dependant's globs in its own
1318
- project's terms, so a workspace glob that does not start inside that
1319
- project (`**/a.txt`) is refused even with the edge.
1320
-
1321
- ### `exec.sandbox` (optional)
1322
-
1323
- Opt this command into an OS-level sandbox. A persistent task runs
1324
- inside it too — enforced, but with no violation report, since the report
1325
- reads the trace after the child exits and a server exits only at
1326
- teardown. **Opt-in per task — omit it and the command runs
1327
- unsandboxed.** Full walkthrough in the
1328
- [sandboxing guide](https://vznjs.github.io/vx/guides/sandboxing/).
1329
-
1330
- ```ts
1331
- interface SandboxConfig {
1332
- allow?: SandboxGrants // what the command may do
1333
- deny?: { network?: string[] } // domains to refuse, run-wide (below)
1334
- ignore?: SandboxIgnore // violations to leave out of the report
1335
- weakerWhenNested?: boolean // Linux: let a sandboxed task sandbox (default false)
1336
- weakerNetworkIsolation?: boolean // macOS: host-proxy net, lower isolation (default false)
1337
- }
1338
-
1339
- interface SandboxGrants {
1340
- read?: readonly string[] // paths or globs, project-relative or absolute
1341
- write?: readonly string[] // paths or globs; a write grant is readable too
1342
- network?: true | readonly string[] // an allowlist of domains; `true` adds none (below)
1343
- systemInfo?: readonly string[] // sysctl names, e.g. 'vfs.disk-space'; each grants system-info and sysctl-read (macOS)
1344
- unixSockets?: true | readonly string[] // AF_UNIX bind/connect, all or by path (Linux: any path)
1345
- localBinding?: boolean | readonly number[] // bind and reach localhost ports (macOS; Linux needs no grant); a list also exposes them to the host (a port the host or another running task already holds fails the task)
1346
- machLookup?: readonly string[] // mach global-names (macOS)
1347
- pty?: boolean // acquire a TTY
1348
- gitConfig?: boolean // write the repository's .git/config (this task only)
1349
- }
1350
-
1351
- interface SandboxIgnore {
1352
- read?: readonly string[] // denied reads to leave out of the report
1353
- write?: readonly string[]
1354
- systemInfo?: readonly string[]
1355
- network?: readonly string[] // '<host>:<port>'
1356
- }
1357
- ```
1358
-
1359
- One shape describes what a task may do; vx translates it into a seatbelt
1360
- profile on macOS and bwrap mounts plus seccomp on Linux. `ignore` takes
1361
- patterns per class a denial is reported in — `read`, `write`,
1362
- `systemInfo`, `network` — so a noisy probe is silenced with the grant
1363
- that would have permitted it; any other name is refused. Every grant is
1364
- the task's own: `unixSockets` (or a `localBinding` port list, whose
1365
- bridge is a unix socket) lifts the `socket(AF_UNIX)` block for the task
1366
- that declares it, never for the run's other sandboxed tasks. On Linux
1367
- the block is the kernel's answer to the call itself, so it is reported
1368
- nowhere: the task reads only its tool's own `socket(1, 1, 0): Operation
1369
- not permitted` (a Docker, ssh-agent or database socket), and the grant
1370
- is `unixSockets`:
1371
-
1372
- ```ts
1373
- exec: {
1374
- command: 'bun build --compile --target=bun-linux-arm64 src/bin.ts --outfile dist/vx',
1375
- sandbox: {
1376
- allow: {
1377
- read: ['.'],
1378
- // A cross-compile target the Bun cache lacks is fetched from npm,
1379
- // extracted into `<cwd>/.<hash>-00000000.tmp/` and moved into the cache.
1380
- write: ['dist/vx', '~/.bun/install/cache/', '.*.tmp/**'],
1381
- network: ['registry.npmjs.org'],
1382
- systemInfo: ['vfs.disk-space'],
1383
- },
1384
- ignore: { write: ['*.bun-build'] },
1385
- },
1386
- }
1387
- ```
1388
-
1389
- **Globs.** `read` and `write` accept patterns. On macOS the pattern
1390
- reaches the policy and matches files created during the run; on Linux a
1391
- grant is a mount, so the pattern is expanded when the task starts and a
1392
- file created later is not covered — grant its directory instead. On both
1393
- platforms `<dir>/**` and `<dir>/**/*` collapse to `<dir>`, so
1394
- `read: ['**/*']` lets a task list its own cwd; a `<dir>` that is itself a
1395
- glob keeps its subtree (`.*.tmp/**` covers what is inside each match).
1396
- Unlike a task glob, a grant keeps `Bun.Glob`'s brackets: `[id]` is a
1397
- class, so a Next.js route is granted escaped, `read: ['pages/\\[id\\].tsx']`.
1398
- On Linux a WRITE path holding a bracket, `*` or `?` cannot be mounted
1399
- (the runtime drops it): vx says so once, names the directory above it to
1400
- grant instead, and a write under it is refused and reported. A READ path
1401
- whose name holds `*` or `?` (granted escaped, `a\\*b.txt`) cannot be
1402
- granted alone either — the runtime would grant its siblings too — so vx
1403
- leaves it out, says so once, and a read of it is refused and reported.
1404
- A Linux grant of either kind holding a backslash is left out the same
1405
- way: Bun's `realpath` refuses such a path, and the runtime mounts none
1406
- it cannot resolve.
1407
-
1408
- A Linux WRITE grant that matches nothing when the task starts therefore
1409
- mounts nothing. Where a read grant mounts its directory, the task's first
1410
- write under it fails with `Read-only file system` — a message naming
1411
- neither vx nor the grant — so vx reports that grant itself before the
1412
- task runs, once, and names the directory to grant instead, spelled as
1413
- the config spells it (`allow: { write: ['gen/'] }`). Where no mount
1414
- holds the directory, it is the sandbox's own scratch, the one mask left
1415
- writable: the task may create, write and remove what the glob matches
1416
- (anything else it writes there too), and nothing it leaves there
1417
- outlives the task. That is right for a tool's temp directory and
1418
- wrong for an output. A read grant matching nothing is ordinary
1419
- (an optional file, a cache not yet populated) and is not reported. A
1420
- pattern under a directory that does not exist yet matches nothing the
1421
- same way, where the scan once failed the task with a bare `ENOENT` (B-6).
1422
-
1423
- **A write grant's shape.** A write path that does not exist yet is
1424
- created before the task starts (a mount needs something to bind), and a
1425
- literal names a FILE: `write: ['dist/vx']` is an empty `dist/vx` the
1426
- build overwrites. A directory the task will create is spelled with a
1427
- trailing slash — `write: ['coverage/']` — or as a glob (`'dist/**'`);
1428
- outside the project (`'~/.bun/install/cache/'`) only these two shapes are
1429
- created, never a file.
1430
- Spell a directory as a bare literal and the task's own `mkdir` meets
1431
- "File exists" ("Not a directory" for a path inside it, `mkdir -p
1432
- coverage/lcov`); the failure then says so, names the `dir/` spelling, and
1433
- vx removes the empty file it made (it takes back any placeholder the
1434
- task never wrote, so an unwritten one is never archived as an output).
1435
- A grant that leaves the project through a symlink is refused: the grant
1436
- binds the path it names, and vx follows no link out of the project. So
1437
- is one whose bind would make `.git`, `.vx/cache`, the cache directory or a
1438
- nested project writable:
1439
- a file grant at a single-package workspace's root binds the root. A
1440
- directory grant is judged as the directory, made before the judgement
1441
- when it does not exist yet, so `dist/` or `dist/**` at that root stands.
1442
-
1443
- **A write grant is readable, and on Linux it reads WIDER than it looks.**
1444
- A write path is readable too (`tsc --incremental` re-reads its own
1445
- `.tsbuildinfo`). On Linux a grant is a mount and bwrap cannot rename onto
1446
- an active file mount — every atomic writer stages beside its target and
1447
- renames — so a FILE-shaped grant is bound as its DIRECTORY. That
1448
- directory is then readable AND writable in full: with
1449
- `write: ['out.txt']` in the project root, every file in the project root
1450
- can be read. Such a read succeeds, so there is no denial; vx reports it
1451
- from the trace instead, as a violation: a read of anything that was in
1452
- that directory when the task started and that no grant covers, the
1453
- directory's listing included. The declared file and what the task made
1454
- there itself stay readable. Put declared outputs in a subdirectory and
1455
- the rest stays denied outright. macOS matches paths rather than
1456
- mounting, so a file grant stays exact there. Pinned in
1457
- `tests/sandbox-runtime.unsafe.test.ts` (2026-09-20) and
1458
- `tests/sandbox-widened-reads.unsafe.test.ts`.
1459
-
1460
- **A `network` entry is a host pattern**: `example.com`, `*.example.com`,
1461
- either with a port (`example.com:443`), or `localhost`. A scheme or path
1462
- (`https://example.com`), a dotless host, a bad port, and `*` or `*.com`
1463
- (too broad) refuse the run with the entry named; `deny.network` also
1464
- takes a bare `*` (deny all, `*:22` for one port). Until 2026-10-02 such an
1465
- entry matched nothing with no word, and an allowed `*` opened every host
1466
- to every sandboxed task of the run.
1467
-
1468
- **`network` is per-RUN, not per-task.** SRT runs one filtering proxy
1469
- per `vx run` and checks every request against the allowlist that proxy
1470
- was started with: the union of every domain list any sandboxed task in
1471
- the graph declared. Every sandboxed task is handed that proxy. So a task
1472
- that declares no network reaches the domains another task of the run
1473
- listed, and `network: true` reaches only those (nothing in a run with no
1474
- list). `deny.network` is the run's too: the proxy starts with the union
1475
- of every task's denies and refuses those domains to every task, checked
1476
- before the allowlist (B-21). A refused request is a violation on both
1477
- platforms, `deny network-outbound <host>:<port> (<reason>)` from the
1478
- proxy, and fails the task even when it survived the refusal;
1479
- `ignore: { network: ['<host>:<port>'] }` silences one, matched as the
1480
- proxy matches: case-blind, a trailing dot dropped (until 2026-10-08 the
1481
- client's spelling, `EXAMPLE.Com` or `example.com.`, escaped it). Until
1482
- 2026-10-02 Linux reported none, and the line could not be ignored on macOS.
1483
-
1484
- **Baseline** (`sandbox: {}`): the task reads nothing in the workspace,
1485
- writes nothing but its own `TMPDIR` and reaches no domain no task of the run lists — not even its own project
1486
- directory, which is why `allow: { read: ['.'] }` is the first line of
1487
- almost every real block. The task still starts in its own directory, an empty one
1488
- then: a relative read is refused and reported, never resolved elsewhere. The read wall is the WORKSPACE ROOT: a path
1489
- outside it (`~/.cache`, `/etc`, the toolchain) is readable and folds into
1490
- no key, so a task whose output depends on one declares it as a key input
1491
- (`inputs.runtime`, `inputs.env`) — the sandbox does not catch it (item
1492
- 966).
1493
- The one exception is where tools keep credentials, denied unless a
1494
- grant names one (`read: ['.', '~/.npmrc']` for a publish; a write grant at
1495
- or inside a store frees that path alone), since a
1496
- dependency the task ran could copy a key into an output the cache
1497
- shares (L-41): `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.azure`, `~/.kube`,
1498
- `~/.config/gcloud`, `~/.config/gh`, `~/.docker/config.json`, `~/.netrc`,
1499
- `~/.git-credentials`, `~/.npmrc`, `~/.yarnrc.yml`, `~/.pypirc`,
1500
- `~/.bunfig.toml`, `~/.config/.bunfig.toml`, `~/.cargo/credentials`,
1501
- `~/.cargo/credentials.toml`, `~/.gem/credentials`, `~/.config/hub`,
1502
- `~/.config/containers/auth.json`, `~/.terraform.d/credentials.tfrc.json`,
1503
- `~/.vault-token`, `~/.pgpass`, `~/.config/configstore/firebase-tools.json`,
1504
- `~/.local/share/com.vercel.cli/auth.json`,
1505
- `~/Library/Application Support/com.vercel.cli/auth.json`,
1506
- `~/.netlify/config.json`, `~/.config/netlify/config.json`,
1507
- `~/Library/Preferences/netlify/config.json`, `~/.wrangler/config`,
1508
- `~/.config/.wrangler/config`, `~/Library/Preferences/.wrangler/config`,
1509
- `~/.fly/config.yml`, `~/.supabase/access-token`, `~/.config/doctl`,
1510
- `~/Library/Application Support/doctl`, `~/.config/stripe/config.toml`,
1511
- `~/.config/op`, `~/.expo/state.json`, `~/.claude/.credentials.json`,
1512
- `~/.codex/auth.json`.
1513
- What it grants from there is the union of the read grants and, on Linux,
1514
- the DIRECTORY holding each file-shaped write grant (above).
1515
- Nothing is inherited from `cache` — `inputs` says what INVALIDATES a task, `sandbox.allow`
1516
- says what it may TOUCH, and deriving one from the other made a
1517
- declaration added for caching silently widen the sandbox. The one grant
1518
- vx makes for you is dependencies: `node_modules` and, through it, the
1519
- real path of every workspace package linked there, and in turn in each
1520
- such package's own `node_modules`. A project never names
1521
- a sibling to import what its `package.json` already depends on. A link
1522
- back to the task's own project, or to a directory holding it, is not
1523
- followed: npm and Yarn link every workspace package at the root, the
1524
- task's own included, and following it would grant the whole project
1525
- past its `allow.read`.
1526
-
1527
- A task that declares `cache` gets a linked workspace package only when
1528
- its key already answers for it: some task of that package is folded into
1529
- the key through `dependsOn` (as `inputs.tasks` selects it, groups
1530
- passing everything through). Every other link inside the workspace root
1531
- is withheld, and a read through one fails the task with the denial plus
1532
- one line naming the package and the edge that would key it. Declaring
1533
- `cache` can only narrow this grant, never widen one: a task with no
1534
- `cache` keeps it whole, having no key to be stale, and your own `allow`
1535
- grants never depend on it. The key answers for the package, not the
1536
- file: an edge to `ui#source` (inputs `src/**`) covers a read of
1537
- `ui/README.md` too.
1538
-
1539
- **A missing write grant fails the task.** On macOS seatbelt refuses the
1540
- write and reports it. On Linux the write meets a read-only bind or a
1541
- read-only mask: the empty directory the sandbox lays over what it hides
1542
- (the workspace root around a project, a single-package workspace's root,
1543
- the project root around a write grant punched out of a read grant —
1544
- `read: ['.']` with `write: ['dist/']` binds the children one by one). It
1545
- is `Read-only file system` on both; until 2026-10-02 the Linux mask was
1546
- writable, and a write there succeeded and left nothing on disk. The
1547
- runtime's write observer saw the attempt, and a write no grant binds is
1548
- reported and fails the task, even when the command swallowed the error
1549
- and exited 0 (B-5; before it, both Linux shapes passed with nothing
1550
- reported, items 444 and 1011). A write outside the project is refused
1551
- the same way and named on a failed task, never counted. The remedy is to
1552
- declare it: `allow: { write: [...] }`.
1553
-
1554
- **A write grant cannot be removed on Linux.** A directory grant is
1555
- mounted in place, so `rm -rf dist && tsc` under `write: ['dist/']`
1556
- empties `dist` and then fails with `Read-only file system`; a failed
1557
- task names the grant. Remove its contents instead: `rm -rf dist/*`.
1558
-
1559
- **The boundary is the workspace root.** A task may not leave its own
1560
- project, so every sibling project and every root file is denied. Being
1561
- stopped at that wall is the sandbox working, not a finding: only
1562
- denials INSIDE the project are reported, because those are the reads
1563
- that make a cache key wrong. A write refused past the wall is named
1564
- beside a FAILED task, never counted, with the directory to grant, spelled
1565
- from the project when it is in the workspace (under the host's temp
1566
- directory, `$TMPDIR`, the task's own, instead): a
1567
- tool that cannot fill its cache (`~/.bun/install/cache`) rarely says
1568
- where it tried. So is a read the wall hid of a path that exists on the
1569
- host, with the grant spelled from the project (`'../../tsconfig.base.json'`):
1570
- the tool said only "not found". To reach a path outside the project but
1571
- inside the workspace — a workspace-level fixture — declare it; a path
1572
- outside the workspace is not walled (above).
1573
-
1574
- **git in a sandboxed task** reads the repository only if it is granted:
1575
- `read: ['.', '../../.git']` from a project two levels down (`.git` is a
1576
- wall, so `read: ['.']` in a root project leaves it out too). On Linux
1577
- a task whose grants name a `.git` gets `GIT_DISCOVERY_ACROSS_FILESYSTEM=1`
1578
- (a value the task sets wins), since every sandbox mount is a filesystem
1579
- boundary git's discovery stops at. `git rev-parse` and `git log` then answer as outside;
1580
- `git status` reports a file the task may not read as deleted.
1581
-
1582
- **Policy: fail on violation.** An undeclared read, or a write the
1583
- sandbox refuses, fails the task, and a failed task is never cached.
1584
- On Linux a denied read is reported only where `strace` is on PATH and
1585
- may attach; elsewhere the sandbox still denies it, the task fails
1586
- only if the command does, and the run says once that reads go unreported. Activation is lazy (only when
1587
- some task declares `exec.sandbox`); on an unsupported platform a
1588
- sandboxed task fails fast rather than running unsandboxed. Linux needs
1589
- `bubblewrap`, `socat` and `ripgrep` installed (the runtime expands its
1590
- mandatory deny globs with `rg`); a missing one is named with the install.
1591
- Inside a write grant, a file named like a shell or tool config
1592
- (`.bashrc`, `.zshrc`, `.gitconfig`, `.mcp.json`, …), anything under
1593
- `.vscode/` or `.idea/`, and `.git/hooks` or `.git/config` stay read-only
1594
- to the task, down to three levels below the workspace root, ignored by
1595
- git or not.
1596
-
1597
- **No `[`, `]`, `*` or `?` in the project's path.** The runtime reads a
1598
- path holding one as a pattern: on Linux it mounts no write path that
1599
- does and a read grant matches its siblings, and macOS's rules compile it
1600
- as a pattern too. So a sandboxed task in a project under such a directory
1601
- (`~/[old]/repo`, `~/w*s/repo`) is refused, naming it: rename the
1602
- directory or drop `exec.sandbox`. On Linux so is a project under a
1603
- directory holding a backslash, which the runtime cannot mount at all (its
1604
- task saw no project and ran in `$HOME`).
1605
-
1606
- **macOS cannot nest.** `sandbox_apply` is refused inside a sandboxed
1607
- process, so a task that itself sandboxes something (vx's own test suite)
1608
- cannot be sandboxed on macOS. `weakerWhenNested` covers the Linux case;
1609
- there is no macOS equivalent to offer.
1610
-
1611
- ## Group tasks (no `exec`)
1612
-
1613
- A task with no `exec` and a `dependsOn` is a **group task** — a pure
1614
- aggregator. Running a group is equivalent to running its dependencies;
1615
- nothing else happens (no spawn, no I/O, no cache read/write). An empty
1616
- `dependsOn: []` is an explicit no-op group: it exists to be named — by a
1617
- dependant's `^build`, by `vx run build --all` — and runs nothing. A
1618
- project with no `build` gets a keyed one (above), which a bare name
1619
- does not select.
1620
-
1621
- A group with `inputs` is a **keyed group**: its `inputs` are a key
1622
- and nothing runs. A task that depends on it folds that key, so an edit
1623
- to those inputs re-runs the dependant, and `--affected` reaches it. It
1624
- takes no `cache` (a group writes nothing), and it needs no `dependsOn`. It spawns nothing, writes no cache entry, and the
1625
- run summary does not count it. `@vzn/vx-migrate` writes one for each
1626
- Nx `^production`-style input and each Turbo transit node.
1627
-
1628
- ```ts
1629
- // `test` re-runs when a file under fixtures/ changes; nothing spawns for it
1630
- fixtures: { inputs: { files: ['fixtures/**'] } },
1631
- test: { exec: { command: 'bun test' }, dependsOn: ['fixtures'], cache: { /* … */ } },
1632
- ```
1633
-
1634
- Bare `--exclude-dependencies` keeps a group's edges for the same reason:
1635
- `vx run ci --exclude-dependencies` runs `ci`'s members without their own
1636
- dependencies. A name list (`--exclude-dependencies=lint.oxfmt`) drops a
1637
- member it names, like any edge.
1638
-
1639
- ```ts
1640
- // `vx run install --all` → fans out to `build` in every workspace dep
1641
- install: {
1642
- description: 'build everything in workspace dependency order',
1643
- dependsOn: ['^build'],
1644
- }
1645
-
1646
- // `vx run ci` → runs format-check + lint + test in the cwd project
1647
- ci: {
1648
- description: 'format-check + lint + test (CI gate)',
1649
- dependsOn: ['format-check', 'lint', 'test'],
1650
- }
1651
- ```
1652
-
1653
- Group tasks:
1654
-
1655
- - **Are not counted in the run summary.** "3 tasks, 2 cached" reflects
1656
- real executable tasks — including a group in the count is confusing
1657
- ("3 of 4 cached" when one was a group that ran nothing).
1658
- - **Are not recorded in the `runs` table.** Analytics queries that
1659
- aggregate runtime won't see them.
1660
- - **Render no framed block** in the live output.
1661
- - **DO contribute a stable hash to downstream tasks** that filter
1662
- `inputs.tasks` to include the group. The hash is rolled up from
1663
- the group's upstream outcomes (sorted, joined, hashed). So any
1664
- change beneath the group cascades correctly.
1665
-
1666
- The loader rejects:
1667
-
1668
- - A task with no `exec`, no `dependsOn` and no `inputs` (literal no-op).
1669
- - A group with `cache` (a group writes nothing).
1670
-
1671
- ## Workspace config (`.vx/workspace.ts`)
1672
-
1673
- Loaded from `.vx/workspace.{ts,mts,js,mjs,cts,cjs}`: the directory holding
1674
- `.vx` is the workspace root. `.vx` also holds the lock (`.vx/projects.lock.json`)
1675
- `.vx/cache`, the machine-local state (the cache, the workspace id), and
1676
- `.vx/tmp`, where a restore stages files; git ignores only those two:
1677
- `.vx/.gitignore` says `/cache` and `/tmp`. Every field falls back to its
1678
- built-in default.
1679
-
1680
- ```ts
1681
- import { defineWorkspace } from '@vzn/vx/config'
1682
- import { otel } from '@vzn/vx-otel'
1683
-
1684
- export default defineWorkspace({
1685
- run: {
1686
- concurrency: 8,
1687
- timeout: '10m',
1688
- affectedBase: 'origin/main',
1689
- schedule: 'most-work',
1690
- continue: 'deps-ok',
1691
- rules: {
1692
- exclusiveOutputs: true,
1693
- dependencies: [{ from: 'tag:lib', deny: ['tag:app'] }],
1694
- },
1695
- },
1696
- cache: {
1697
- dir: 'build/.vx-cache',
1698
- download: 'all',
1699
- retention: { olderThan: '30d', maxSize: '10GB' },
1700
- policy: 'local:rw,remote:r',
1701
- scope: 'main',
1702
- inputStability: { strategy: 'immutable', severity: 'warn' },
1703
- outputs: { clean: true },
1704
- upload: { failures: 'ci' },
1705
- },
1706
- lock: { reviewFiles: true },
1707
- watch: { poll: false },
1708
- plugins: [otel()],
1709
- })
1710
- ```
1711
-
1712
- The workspace holds settings in groups (`run`, `cache`, `lock`, `watch`) and the
1713
- `plugins`. Every setting has one path, and its environment variable and
1714
- flag derive from it: `run.timeout` is `VX_RUN_TIMEOUT` and
1715
- `vx run --timeout`. A flag beats its variable, which beats this file,
1716
- which beats the built-in. A setting a task can set itself
1717
- (`exec.timeout`, `exec.retries`, `cache.inputStability`) takes the
1718
- task's value over all of them; the input-stability variables are the one
1719
- exception, overriding every task for the run. A duration carries its
1720
- unit (`'500ms'`, `'30s'`, `'10m'`, `'2h'`, `'30d'`), and a bare number is
1721
- refused naming the forms with units; a size carries one too (`'10GB'`).
1722
- `debug.timing`, `debug.watch`, `debug.dumpable`, `config.workerTimeout` and `run.outputLogs` are read
1723
- before the workspace loads, so they have a variable (and a flag) only.
1724
- None is folded into a cache key. `vx info` lists every setting not at its
1725
- default and where it came from.
1726
-
1727
- ### Settings
1728
-
1729
- <!-- settings table: generated by tests/settings.test.ts -->
1730
-
1731
- | Setting | Variable | Flag | Value | Default | What it does |
1732
- | ---------------------------------------------- | ----------------------------------- | ------------------ | ----------------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
1733
- | `run.concurrency` | `VX_RUN_CONCURRENCY` | `--concurrency` | a positive integer, or a share of the cores like '50%' | the cores this process may use | Tasks at once: a positive integer, or a share of the cores (`'50%'`). |
1734
- | `run.schedule` | `VX_RUN_SCHEDULE` | `--schedule` | one of 'most-work', 'critical-path', 'direct-dependents', 'ready-order' | most-work | Which ready task starts first when there are more than workers. |
1735
- | `run.affectedBase` | `VX_RUN_AFFECTED_BASE` | — | a non-blank string | origin/HEAD, then the trunk, else HEAD~1 | The git ref a bare `--affected` compares with. |
1736
- | `run.branch` (variable or flag only) | `VX_RUN_BRANCH` | `--branch` | a non-blank string | git's branch, then the CI provider's | The branch a run records in its history and forecasts from. Flag and env only: a committed branch would be wrong on every other branch. |
1737
- | `run.timeout` | `VX_RUN_TIMEOUT` | `--timeout` | a duration like '500ms', '30s' or '5m', at most 24 days | none | A task's time limit, for tasks without their own `exec.timeout` (`'30m'`). A task's own `exec.timeout` beats it. |
1738
- | `run.retries` | `VX_RUN_RETRIES` | `--retries` | a non-negative integer | 0 | Re-runs of a failed task, for tasks without their own `exec.retries`. A task's own `exec.retries` beats it. |
1739
- | `run.continue` | `VX_RUN_CONTINUE` | `--continue` | one of 'never', 'deps-ok', 'always' | deps-ok | What a failed task takes down: `never` stops the run, `deps-ok` skips its dependents, `always` runs them too. |
1740
- | `run.outputLogs` (variable or flag only) | `VX_RUN_OUTPUT_LOGS` | `--output-logs` | one of 'full', 'errors-only', 'hash-only', 'none' | `full` on CI, else by what the run selects | Which task output the run prints. Chosen before the workspace loads, so flag and env only. |
1741
- | `run.rules.exclusiveOutputs` | `VX_RUN_RULES_EXCLUSIVE_OUTPUTS` | — | true or false | true | Refuse two tasks whose outputs overlap even when an edge orders them. |
1742
- | `cache.dir` | `VX_CACHE_DIR` | `--cache-dir` | a non-blank string | .vx/cache, entries in ~/.vx/<repo>/cache | Where the cache lives, relative to the workspace root; named, it holds the whole cache. |
1743
- | `cache.retention.maxSize` | `VX_CACHE_RETENTION_MAX_SIZE` | — | a size like '500MB' or '10GB' | none | Evict least-recently-used entries at each run's end until the cache is under this (`'10GB'`). |
1744
- | `cache.retention.olderThan` | `VX_CACHE_RETENTION_OLDER_THAN` | — | a duration like '45s', '12h' or '30d' | none | Evict entries unused for this long at each run's end (`'30d'`). |
1745
- | `cache.policy` | `VX_CACHE_POLICY` | `--cache-policy` | a non-blank string | every layer reads and writes | Which layers a run reads and writes (`'local:rw,remote:r'`); `--no-cache` and `--force` apply on top. |
1746
- | `cache.scope` | `VX_CACHE_SCOPE` | `--cache-scope` | a non-blank string | none (the default keys) | Where cache writes land (`'pr-123'`): reads try the default key, then the scope's. |
1747
- | `cache.download` | `VX_CACHE_DOWNLOAD` | `--cache-download` | one of 'all', 'toplevel', 'none' | all | Which remote hits land on disk: every one, only the requested tasks, or none. |
1748
- | `cache.inputStability.strategy` | `VX_CACHE_INPUT_STABILITY_STRATEGY` | — | one of 'immutable', 'mutable', 'unchecked' | immutable | What a write to an input during the command means for the save, for tasks without their own. A task's own `cache.inputStability.strategy` beats it. |
1749
- | `cache.inputStability.severity` | `VX_CACHE_INPUT_STABILITY_SEVERITY` | — | one of 'warn', 'error' | warn | Whether a skipped save warns or fails the task, for tasks without their own. A task's own `cache.inputStability.severity` beats it. |
1750
- | `cache.outputs.clean` | — | — | true or false | true | Remove a task's declared outputs before it runs on a miss, for tasks without their own. A task's own `cache.outputs.clean` beats it. |
1751
- | `cache.upload.failures` | — | — | one of 'ci', 'local', 'always' | ci | Where a failed task's log and outputs are kept in the remote: CI runs, local runs, or both. |
1752
- | `lock.reviewFiles` | `VX_LOCK_REVIEW_FILES` | — | true or false | true | `vx lock` writes each project's resolved config, pretty, to `vx.lock.json` beside its `vx.config`, for review; vx never reads it. |
1753
- | `watch.poll` | `VX_WATCH_POLL` | `vx watch --poll` | true or false | false | Poll for changes from the start instead of the OS watcher. |
1754
- | `config.workerTimeout` (variable or flag only) | `VX_CONFIG_WORKER_TIMEOUT` | — | a duration like '500ms', '30s' or '5m', at most 24 days | 30s | How long one config evaluation may take. |
1755
- | `debug.timing` (variable or flag only) | `VX_DEBUG_TIMING` | — | true or false | false | Print the run's stage timings to stderr. |
1756
- | `debug.watch` (variable or flag only) | `VX_DEBUG_WATCH` | — | true or false | false | `vx watch`: say on stderr each time the loop arms, with the projects it watches. |
1757
- | `debug.dumpable` (variable or flag only) | `VX_DEBUG_DUMPABLE` | — | true or false | false | Linux: keep vx dumpable, so strace or a debugger can read its memory and `/proc/<pid>/fd`, and an unsandboxed task its env. |
1758
-
1759
- <!-- /settings table -->
1760
-
1761
- ```ts
1762
- interface WorkspaceConfig {
1763
- run?: RunSettings
1764
- cache?: CacheSettings
1765
- lock?: LockSettings
1766
- watch?: { poll?: boolean }
1767
- /** Run-level plugins (cache / executor / telemetry capabilities). */
1768
- plugins?: readonly (Plugin | undefined | null | false)[]
1769
- }
1770
-
1771
- interface RunSettings {
1772
- concurrency?: number | `${number}%`
1773
- schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
1774
- affectedBase?: string
1775
- timeout?: Duration
1776
- retries?: number
1777
- continue?: 'never' | 'deps-ok' | 'always'
1778
- /** Graph rules `vx lock` checks, never a run; each on unless set to false. */
1779
- rules?: { exclusiveOutputs?: boolean; dependencies?: DependencyRule[] }
1780
- }
1781
-
1782
- /** `run.rules.dependencies`, checked by `vx lock` only. */
1783
- type DependencyRule = { from: string; allow?: string[]; deny?: string[] }
1784
-
1785
- interface LockSettings {
1786
- /** A pretty vx.lock.json beside each config, for review; on unless false. */
1787
- reviewFiles?: boolean
1788
- }
1789
-
1790
- interface CacheSettings {
1791
- dir?: string
1792
- retention?: { olderThan?: Duration; maxSize?: string }
1793
- policy?: string
1794
- scope?: string
1795
- download?: 'all' | 'toplevel' | 'none'
1796
- inputStability?: InputStability
1797
- outputs?: { clean?: boolean }
1798
- upload?: { failures?: 'ci' | 'local' | 'always' }
1799
- }
1800
- ```
1801
-
1802
- - **`run.concurrency`** — the cap on parallel tasks: a positive integer,
1803
- or a share of the cores this process may use (`'50%'` on eight cores is
1804
- 4, never below 1; over 100% is allowed for I/O-bound work).
1805
- - **`run.timeout`** — a task's time limit, for any task that declares no
1806
- `exec.timeout` (which beats it, its flag and its variable). A runaway
1807
- task's process group is SIGTERMed and the task reported `failed`.
1808
- Purely a safety net — never folded into a cache key (a timed-out task
1809
- fails and is never cached).
1810
- - **`run.retries`** — re-runs of a failed task, for tasks without their
1811
- own `exec.retries` (which beats it, `retries: 0` included); never a
1812
- persistent one.
1813
- - **`run.continue`** — what a failed task takes down: `never` stops
1814
- dispatch, `deps-ok` (the default) skips its dependents, `always` runs
1815
- them (`docs/cli.md` § Failure propagation).
1816
- - **`run.rules`** — checked by `vx lock` and `vx lock --check` only;
1817
- no run checks them.
1818
- - **`cache.dir`** — unset, the workspace keeps its index and history in
1819
- `.vx/cache` and its entries and artifacts in its repository's shared
1820
- store (`~/.vx/<id>/cache`), where every other checkout of the
1821
- repository hits them (`docs/caching.md`). Named here, by
1822
- `VX_CACHE_DIR`, or by `--cache-dir`, the directory holds the whole
1823
- cache, shared with no other workspace. `--cache-dir` beats
1824
- `VX_CACHE_DIR`, which beats this.
1825
- Relative paths are resolved against the workspace
1826
- root, `~/` against the home directory; absolute paths are used
1827
- as-is. The home directory itself (`~`) is refused. `vx run`, `vx cache clean`,
1828
- and any other reader use the same resolution
1829
- (`src/workspace/workspace.ts:resolveCacheDir`). The cache is a
1830
- directory of its own: a first index in one that holds a
1831
- `package.json`, `pnpm-workspace.yaml`, a `vx.config.*` or a
1832
- `.vx/workspace.*` (`''` and `'.'` name the
1833
- root), whose subdirectory does (`'packages'`), or that holds the
1834
- workspace (`'..'`, `'/'`) is refused before anything is written, since its `*`
1835
- `.gitignore` would hide the sources from git and the cache keys. A
1836
- directory other than the default `.vx/cache` gets that `*` `.gitignore`;
1837
- the default is ignored by `.vx/.gitignore` (`/cache`, `/tmp`) instead, written
1838
- when missing and never edited.
1839
- - **`cache.retention`** — the `vx cache clean` policy, applied at the
1840
- end of every run: entries unused for
1841
- `olderThan` (`30d`, `12h`, `90m`, `45s`) go first, then the
1842
- least-recently-used until the cache is under `maxSize` (`10GB`,
1843
- `500MB`, `64KB`; a bare number is refused as a typo, `10B` is not).
1844
- Either or both (each also from `VX_CACHE_RETENTION_OLDER_THAN` /
1845
- `VX_CACHE_RETENTION_MAX_SIZE`), and neither may be zero: each would evict what every
1846
- run just saved (item 969). It runs after the run's saves
1847
- and uploads have landed, never on a run a signal or an abort stopped
1848
- (one stopped while it waited on the workspace lock never held it), only when something is due (a run with
1849
- nothing to evict pays one scan of the index), and says nothing:
1850
- housekeeping prints no line (owner, 2026-10-06). Under `olderThan` an entry the run just used is never due, but `maxSize` is least-recently-used first, so a bound below one run's outputs evicts that run's own. The prune's
1851
- orphan sweep (artifacts no index row counts, judged by file time —
1852
- see `vx cache clean`) also runs on its own clock, at most once an
1853
- hour, so their bytes go even when nothing the index holds is due;
1854
- listing the directory every run would cost 0.5 ms per 1,000
1855
- entries. Housekeeping,
1856
- not the run's work: a failure is silent, never a failed run. Not
1857
- folded into any cache key. Omitted → the cache grows until
1858
- `vx cache clean`.
1859
- - **`run.affectedBase`** — the git ref a bare `--affected` compares with
1860
- (`origin/develop`); `--affected=<base>` still wins. Omitted →
1861
- `origin/HEAD`, then the first of `origin/main`, `origin/master`, `main`, `master` that is not HEAD (D-93), else `HEAD~1`. A plugin's `config` stage may set it:
1862
- `nx()` from `NX_BASE` or nx.json's `defaultBase`, `turbo()` from
1863
- `TURBO_SCM_BASE`, else on GitHub Actions from `GITHUB_BASE_REF` or the
1864
- push event's `before`, as Turbo does. Not folded into any cache key.
1865
- - **`cache.policy`** — which layers a run reads and writes, in
1866
- `--cache-policy`'s spec (`'local:rw,remote:r'`; a layer the spec leaves out
1867
- stays on; the grammar is in `docs/cli.md` § Cache policy). Omitted,
1868
- every layer reads and writes on CI, and `vx run` off CI (`CI` unset,
1869
- `0` or `false`) reads the remote and does not write it: a key holds
1870
- only declared inputs, so a laptop's write could replay on CI. The
1871
- policy alone decides whether a layer is written; `cache.scope` never
1872
- does. `VX_CACHE_POLICY` beats it and
1873
- `vx run --cache-policy` (or `--cache-policy`) beats both; `--no-cache` and `--force` still apply on top. A spec
1874
- naming the remote (`remote:rw`) asks to write it, off CI too. Parsed
1875
- before any task: a bad spec is refused naming `cache.policy`. Whatever
1876
- the policy, a lookup asks the local cache first, then each remote in
1877
- the order the plugins declare them. Not folded into any cache key.
1878
- - **`cache.download`** — which remote hits land on this machine:
1879
- `all` (the default), only the requested tasks' (`toplevel`), or
1880
- `none` (`docs/cli.md` § `--cache-download`).
1881
- - **`cache.upload.failures`** — which runs keep a failed task's log and
1882
- the files its outputs left in the remote: CI runs (`'ci'`, the
1883
- default), runs off CI (`'local'`), or both (`'always'`). Only a run
1884
- whose policy writes a remote keeps any, and it always keeps the log of
1885
- a task that saved no entry. `ctx.cache.getRun` reads them
1886
- (`docs/modules/plugin.md` § Run report and run bytes).
1887
-
1888
- - **`cache.scope`** — where this run's cache writes land (2026-10-14).
1889
- Omitted, a run reads and writes the default keys. A name (`'pr-123'`;
1890
- letters, digits, `. _ - / @`, at most 128) reads the default key first,
1891
- then the scope's, and writes only the scope's, so a pull request never
1892
- writes what the default branch reads, and two scopes never see each
1893
- other. A default artifact the ingest refuses falls through to the
1894
- scope's key. A scope's key is derived from the task key, so every
1895
- remote wire stores it unchanged. The rule holds for the local cache
1896
- as for every remote, so on one disk an entry a scope saved is a miss
1897
- for another scope or none, and an unscoped one serves every run. The scope never decides whether a run writes; `cache.policy`
1898
- does.
1899
- `github()` sets it on Actions: `pr-<n>`, `ref-<name>` off
1900
- the default branch. `VX_CACHE_SCOPE` beats both, and `vx run --cache-scope` beats all three. A client-side convention, not a security
1901
- boundary: a run holding a write credential can write any key, so only
1902
- a cache server that scopes writes by token can refuse one
1903
- (`docs/security.md` § Cache poisoning). Not folded into any cache key.
1904
- The old top-level `cacheScope` is refused, naming `cache: { scope }`.
1905
- - **`run.schedule`** — which ready task starts first when more are ready
1906
- than workers, with no timings. `most-work` (the default): the one the
1907
- most tasks wait on, at any depth. `critical-path`: the head of the
1908
- longest chain of tasks below it. `direct-dependents`: the one the most
1909
- tasks wait on directly (Nx's rule). `ready-order`: the one that became
1910
- ready first (Turbo's rule). Each loses some graph shape the others win
1911
- (`blog/scheduling-strategies`). A `schedule` plugin's weights
1912
- (`@vzn/vx-schedule-history`) still sort first, and the strategy breaks
1913
- their ties. The default costs what it did; another strategy is
1914
- computed only when named. Any other value is refused. Never folded
1915
- into a cache key.
1916
- - **`cache.inputStability`** — the `cache.inputStability` of every task that
1917
- does not set its own field, same shape (`{ strategy, severity }`, §
1918
- `cache`). `VX_CACHE_INPUT_STABILITY_STRATEGY` and `_SEVERITY` still
1919
- override every task for the run. Never folded
1920
- into a cache key.
1921
- - **`run.rules`** — checks on the task graph, each on unless set to
1922
- `false`. Turning one off allows a shape vx runs correctly but more
1923
- slowly; never folded into a cache key. A value that is not a boolean,
1924
- or a rule vx does not know, is refused.
1925
- - **`exclusiveOutputs`** (X-53) — two tasks whose declared outputs
1926
- overlap are refused even when a `dependsOn` edge orders them:
1927
- `<a> and <b> both declare the output "<glob>" … Give each task its
1928
- own output path, or set run.rules.exclusiveOutputs: false in
1929
- .vx/workspace.ts to let a dependant add to its upstream's outputs.`
1930
- Off, the dependant adds to its upstream's tree (item 588;
1931
- `caching.md` § Additive outputs). Two overlapping tasks with no
1932
- edge are refused either way.
1933
- - **`dependencies`** — which workspace projects a project may depend
1934
- on, checked by `vx lock` and `vx lock --check` only, over each
1935
- project's direct dependencies (its `package.json` buckets and its
1936
- tasks' cross-project `dependsOn`). Each rule is
1937
- `{ from, allow?, deny? }`; a selector is `tag:<tag>` (a project
1938
- carrying the tag) or a project name, and `*` matches any characters
1939
- in either (`tag:scope:*`, `@acme/*`). Every rule whose `from`
1940
- selects a project applies: each dependency must match an `allow`
1941
- selector when `allow` is given, and none of `deny`, so a deny beats
1942
- any allow. A project no rule selects is free, and an untagged
1943
- dependency matches no `tag:` selector. A broken edge refuses the lock
1944
- with `VX_E_DEPENDENCY_RULE`:
1945
- `lib depends on app, which run.rules.dependencies[0] (from 'tag:lib') denies`,
1946
- every broken edge on its own line. A rule with neither `allow` nor
1947
- `deny` is refused.
1948
- - **Inputs over outputs** (X-54, always checked, no setting) — a cached task whose `inputs.files` can
1949
- match a same-project task's `outputs.files`, or whose
1950
- `inputs.workspaceFiles` can match any task's `outputs.workspaceFiles`
1951
- or another project's `outputs.files` read from the root, or whose
1952
- `inputs.files` can match another task's `outputs.workspaceFiles`
1953
- landing in its project (X-135), is refused by `vx lock` unless one of
1954
- the exceptions below applies: `<reader> reads "<glob>" in inputs.<field>,
1955
- which matches <writer>'s output "<glob>" … Exclude it: add "!<glob>"
1956
- to <reader>'s inputs.<field>, or depend on <writer> and leave it out of
1957
- <reader>'s inputs.tasks, so its key hashes those bytes instead.` Such a key
1958
- reads what the producer writes this run, so vx cannot know it before
1959
- the producer ran: it is not probed, prefetched or restored ahead of
1960
- the schedule (`caching.md` § Local restore tier). The upstream's key
1961
- already reaches the reader through `dependsOn`. A `!` entry that
1962
- takes the whole output back clears it, and an output the reader's
1963
- `!` entries take back no longer holds its key back either. Nor does
1964
- an output git ignores whole: a key reads only what git lists, so
1965
- `**/*` beside a gitignored `**/dist/**` passes and keys up front,
1966
- unless the reader's entry starts in the ignored path (`dist/**`):
1967
- naming it reads it, so the check applies. A reader may read the
1968
- outputs of a task in its `dependsOn` closure that its key does not
1969
- fold: it sets `inputs.tasks` and leaves the producer out, also
1970
- through the tasks it does fold (a producer folded by a folded task
1971
- counts as folded). Its key then waits for the producer and hashes
1972
- the bytes it wrote, so a producer rerun with identical output still
1973
- hits. `vx lock` checks all of it; a run reads only the lock. Git
1974
- is asked, never a `.gitignore` read: `git check-ignore` on the
1975
- output's directory (for `**/dist/**`, `dist/` and a fresh
1976
- `__vx_probe__/dist/`), and no tracked or listed file may match it,
1977
- so a tracked output is still refused. A glob no few paths stand for
1978
- (`*.js`) is refused as before. `vx lock` asks once for every
1979
- overlapped output; a run asks once beside its git walk for the
1980
- stability gate, so a rule changed after the lock keys the reader
1981
- late until the next `vx lock` refuses it. A task's
1982
- own outputs, a task with no `cache`, and a group are exempt.
1983
- - **`plugins`** — the run-level extension points. Optional: core
1984
- applies no plugin by default, and the local executor and the local
1985
- cache are its floor — the tail of every executor list, and the first
1986
- layer of every cache chain — so a workspace that declares none runs
1987
- and caches here.
1988
- Declaration order is precedence: every `executor` is consulted in
1989
- order per task (first to accept runs it; what all decline runs
1990
- locally); every `cache` layer is chained (lookup walks, save reaches
1991
- all, the local store last). An `undefined`, `null` or `false` entry
1992
- is skipped, as Vite skips one: a factory that declines (`github()` off
1993
- Actions) or `cond && plugin()` needs no filter. Each entry is a `VxPlugin` contributing
1994
- any subset of the fifteen hooks, in pipeline order: `config` (edit
1995
- the workspace config before anything derives from it), `discover`
1996
- (name directories to make projects beyond the members), `project`
1997
- (edit a loaded project's tasks — inject, remove, rewrite;
1998
- re-validated by core and hashed into the key like a hand edit),
1999
- `graph` (edit the task graph's edges), `key` (per-task
2000
- `{ name: value }` material folded into the cache key), `fingerprint`
2001
- (claim a lockfile and key it per project, or a root file the plugin's
2002
- stages read, such as `turbo.json`, so `--affected` asks the plugin
2003
- about it and `vx watch` re-runs on it), `schedule` (task id →
2004
- priority, merged over the scheduler's baseline), `admit` (may this
2005
- ready task start now beside what runs here), `executor` (return a
2006
- `TaskExecutor` — where one task's command runs — or decline), `cache`
2007
- (return a cache layer or decline), `telemetry` (observe-only export:
2008
- OTel, the GitHub job summary, a custom sink), `output` (return a
2009
- `Logger` that draws the run in place of vx's own, or decline), `setup`
2010
- and `teardown`
2011
- around the run, and CLI `commands` (`{ verb: { description, run } }`,
2012
- consulted for a verb core does not know). First-party plugins:
2013
- `@vzn/vx-otel`, `@vzn/vx-ci`, `@vzn/vx-reapi`, `@vzn/vx-lockfile`,
2014
- `@vzn/vx-schedule-history`, `@vzn/vx-migrate`. A
2015
- plugin that declines every capability (`otel()` with no OTLP endpoint
2016
- configured) costs nothing — a run with no active plugin is
2017
- byte-identical to one with none declared. Plugins observe, route and
2018
- execute; they never change what a task is.
2019
-
2020
- The loader validates the shape (each setting by its kind, plugin objects
2021
- with a name and at least one capability) and throws a `UserError` on
2022
- malformed input.
2023
-
2024
- There are no workspace-level `globalInputs` / task-default fields:
2025
- root-anchored file inputs are declared per task via
2026
- `inputs.workspaceFiles`, root-anchored probes via
2027
- `inputs.workspaceRuntime`, and shared task shapes compose
2028
- through plain TypeScript (import a preset and spread it) — the
2029
- language replaces Nx-style `namedInputs` / `targetDefaults` schema
2030
- machinery by design.
2031
-
2032
- ## Helpers
2033
-
2034
- ```ts
2035
- import { defineProject, defineWorkspace } from '@vzn/vx/config'
2036
-
2037
- // Identity functions; their purpose is type inference.
2038
- defineProject<const T extends ProjectConfig>(config: T): T
2039
- defineWorkspace<T extends WorkspaceConfig>(config: T): T
2040
- ```
2041
-
2042
- Use them so TypeScript narrows literal types in your config —
2043
- autocomplete for task names in `dependsOn` against your declared
2044
- tasks, strict validation against the schema, errors at edit time
2045
- rather than at `vx run` time.
2046
-
2047
- They cost one runtime import of `@vzn/vx/config` per config file:
2048
- `src/config.ts`, which imports nothing, so it is cheap, but it must
2049
- resolve from the config's directory. The type-only form gives the same
2050
- editor checking with no runtime import, and is what `vx init` /
2051
- `@vzn/vx-migrate` write:
2052
-
2053
- ```ts
2054
- import type { ProjectConfig, WorkspaceConfig } from '@vzn/vx/config'
2055
- export default { tasks: { … } } satisfies ProjectConfig
2056
- export default { plugins: [] } satisfies WorkspaceConfig
2057
- ```
2058
-
2059
- You _can_ skip the helpers and write
2060
- `export default { tasks: { … } }`, but the IDE experience is
2061
- strictly worse.
2062
-
2063
- ## Full example
2064
-
2065
- ```ts
2066
- import { defineProject } from '@vzn/vx/config'
2067
-
2068
- export default defineProject({
2069
- tasks: {
2070
- // Real cached task with file inputs, env tracking, and outputs.
2071
- build: {
2072
- description: 'compile TypeScript to dist/',
2073
- // NODE_ENV is a cache input below, so it must reach the child
2074
- // too — the exec env is isolated, not inherited.
2075
- exec: { command: 'tsc -b', env: { passThrough: ['NODE_ENV'] } },
2076
- dependsOn: ['^build'],
2077
- inputs: {
2078
- files: ['src/**', '!**/*.test.ts', 'tsconfig.json'],
2079
- env: ['NODE_ENV'],
2080
- },
2081
- cache: {
2082
- outputs: { files: ['dist/**'] },
2083
- },
2084
- },
2085
-
2086
- // Cached, depends on upstream build; CI is in passThrough only
2087
- // (CI=1 shouldn't bust the cache — it's true on every CI run).
2088
- test: {
2089
- description: 'run unit tests',
2090
- exec: { command: 'bun test', env: { passThrough: ['CI'] } },
2091
- dependsOn: ['build'],
2092
- inputs: { files: ['src/**', 'tests/**'] },
2093
- cache: {
2094
- outputs: { files: [] },
2095
- },
2096
- },
2097
-
2098
- // Cross-project edges. The pkg.tgz from one project's build is
2099
- // cached, and depends on builds in every workspace dep.
2100
- package: {
2101
- description: 'pack the npm tarball',
2102
- exec: { command: 'rm -rf pkg && npm pack --pack-destination ./pkg' },
2103
- dependsOn: ['build', 'test', '^build'],
2104
- inputs: {
2105
- files: ['package.json'],
2106
- tasks: ['build', '^build'], // hash these upstream too
2107
- },
2108
- cache: {
2109
- outputs: { files: ['pkg/*.tgz'] },
2110
- },
2111
- },
2112
-
2113
- // Persistent dev server. Downstream tasks unblock on the
2114
- // "Local:" line, not on exit (which never comes).
2115
- dev: {
2116
- description: 'vite dev server',
2117
- exec: {
2118
- command: 'vite',
2119
- timeout: '30s',
2120
- persistent: { readyWhen: 'Local:' },
2121
- env: { passThrough: ['VITE_API_URL'] },
2122
- },
2123
- },
2124
-
2125
- // E2E test gated on the dev server. Together: vx run e2e runs
2126
- // dev → e2e; e2e exits → orchestrator SIGTERMs dev → run ends.
2127
- e2e: {
2128
- description: 'end-to-end tests against dev server',
2129
- exec: { command: 'playwright test' },
2130
- dependsOn: ['dev'],
2131
- },
2132
-
2133
- // Two cached checks with no outputs: the verdict is the exit code.
2134
- // A name with a hyphen is quoted, as any object key is.
2135
- lint: {
2136
- description: 'oxlint',
2137
- exec: { command: 'oxlint .' },
2138
- inputs: { files: ['src/**', '.oxlintrc.json'] },
2139
- cache: { outputs: { files: [] } },
2140
- },
2141
- 'format-check': {
2142
- description: 'oxfmt --check',
2143
- exec: { command: 'oxfmt --check .' },
2144
- inputs: { files: ['src/**', '.oxfmtrc.json'] },
2145
- cache: { outputs: { files: [] } },
2146
- },
2147
-
2148
- // Pure group task — `vx run ci` fans out, the group itself is
2149
- // silent in the run output. `dependsOn` is typed against the task
2150
- // names above: a name declared nowhere is a type error, not a
2151
- // silent no-op.
2152
- ci: {
2153
- description: 'format-check + lint + test',
2154
- dependsOn: ['format-check', 'lint', 'test'],
2155
- },
2156
- },
2157
- })
2158
- ```
2159
-
2160
- The rest of the task fields, in one config:
2161
-
2162
- ```ts
2163
- import { defineProject } from '@vzn/vx/config'
2164
-
2165
- export default defineProject({
2166
- tasks: {
2167
- // Prompts on the terminal, so it runs alone and is never cached.
2168
- 'db:push': { exec: { command: 'drizzle-kit push', interactive: true } },
2169
- // Talks to this machine's Docker daemon: never sent to a remote executor.
2170
- 'docker:build': { exec: { command: 'docker build -t app .', remote: false } },
2171
- e2e: {
2172
- exec: {
2173
- command: 'playwright test',
2174
- env: {
2175
- define: { BASE_URL: 'http://localhost:3000' },
2176
- passThrough: ['E2E_TOKEN'],
2177
- secret: ['E2E_TOKEN'],
2178
- },
2179
- sandbox: {
2180
- allow: {
2181
- read: ['.'],
2182
- write: ['test-results/'],
2183
- localBinding: [3000],
2184
- unixSockets: true,
2185
- pty: true,
2186
- gitConfig: false,
2187
- systemInfo: ['hw.memsize'],
2188
- machLookup: ['com.apple.SecurityServer'],
2189
- },
2190
- weakerNetworkIsolation: false,
2191
- weakerWhenNested: false,
2192
- },
2193
- },
2194
- inputs: {
2195
- files: ['e2e/**'],
2196
- runtime: ['npx playwright --version'],
2197
- workspaceRuntime: ['node -v'],
2198
- },
2199
- cache: {
2200
- outputs: { files: ['test-results/**'] },
2201
- },
2202
- },
2203
- },
2204
- })
2205
- ```
2206
-
2207
- ## Common patterns
2208
-
2209
- ### Sharing inputs across tasks
2210
-
2211
- TypeScript composition is the mechanism (named-input schema machinery
2212
- was deliberately rejected — the language already does this). Plain TS
2213
- arrays:
2214
-
2215
- ```ts
2216
- import { defineProject } from '@vzn/vx/config'
2217
-
2218
- const srcInputs = ['src/**', 'tsconfig.json']
2219
-
2220
- export default defineProject({
2221
- tasks: {
2222
- build: {
2223
- exec: { command: 'tsc' },
2224
- inputs: { files: srcInputs },
2225
- cache: { outputs: { files: ['dist/**'] } },
2226
- },
2227
- test: {
2228
- exec: { command: 'bun test' },
2229
- inputs: { files: [...srcInputs, 'tests/**'] },
2230
- cache: { outputs: { files: [] } },
2231
- },
2232
- },
2233
- })
2234
- ```
2235
-
2236
- ### Presets
2237
-
2238
- A preset is a TypeScript function that returns a `TaskConfig`:
2239
-
2240
- ```ts
2241
- // presets/ts-build.ts
2242
- import type { TaskConfig } from '@vzn/vx/config'
2243
-
2244
- export function tsBuild(opts?: { tsconfig?: string }): TaskConfig {
2245
- return {
2246
- description: 'compile TypeScript',
2247
- exec: { command: `tsc -b ${opts?.tsconfig ?? ''}`.trim() },
2248
- inputs: { files: ['src/**', opts?.tsconfig ?? 'tsconfig.json'] },
2249
- cache: {
2250
- outputs: { files: ['dist/**'] },
2251
- },
2252
- }
2253
- }
2254
- ```
2255
-
2256
- ```ts
2257
- // packages/app/vx.config.ts
2258
- import { defineProject } from '@vzn/vx/config'
2259
- import { tsBuild } from '../../presets/ts-build.ts'
2260
-
2261
- export default defineProject({
2262
- tasks: {
2263
- build: tsBuild(),
2264
- test: {
2265
- exec: { command: 'bun test' },
2266
- dependsOn: ['build'],
2267
- inputs: { files: ['src/**'] },
2268
- cache: { outputs: { files: [] } },
2269
- },
2270
- },
2271
- })
2272
- ```
2273
-
2274
- When the preset is consumed at config-load time, it should resolve
2275
- to `.ts` source — see
2276
- [`architecture.md` § Config-time imports](./architecture.md#config-time-imports--the-bootstrap-problem)
2277
- for the bootstrap-cycle tradeoff.
2278
-
2279
- ### When to use `inputs.tasks: []`
2280
-
2281
- When a `dependsOn` exists for ordering only, and the upstream's
2282
- output doesn't influence this task's output. Common case: an
2283
- integration test that depends on a dev server starting up but whose
2284
- own inputs are just the test source.
2285
-
2286
- ```ts
2287
- e2e: {
2288
- dependsOn: ['dev'],
2289
- exec: { command: 'playwright test' },
2290
- inputs: {
2291
- files: ['e2e/**'],
2292
- tasks: [], // dev's hash does not affect e2e's cache identity
2293
- },
2294
- cache: {
2295
- outputs: { files: ['playwright-report/**'] },
2296
- },
2297
- }
2298
- ```
2299
-
2300
- ### When to use `--no-cache` instead of removing `cache`
2301
-
2302
- `--no-cache` is a runtime flag, not a config change. Reach for it
2303
- when:
2304
-
2305
- - You suspect cache corruption and want a clean run.
2306
- - You're benchmarking and need fresh timings.
2307
- - You're forwarding one-off args and don't want to pollute the cache
2308
- with a one-shot key (though note: forwarded args are already in
2309
- the key, so they form their own entry anyway).
2310
-
2311
- It applies to the whole run; per-task opt-out belongs in config (omit
2312
- the `cache` block).
2313
-
2314
- ## Schema validation errors
2315
-
2316
- The loader (`src/workspace/project-loader.ts`) validates at load time
2317
- and surfaces `UserError` (clean output, no stack). Every field's
2318
- accepted values and exact refusal text are recorded in
2319
- `tests/contract/config-schema.json`, which a test regenerates from the
2320
- validator and compares, so a change to either is deliberate
2321
- (`design/versioning-1.0.md` § How the contract is held).
2322
-
2323
- When the field a refusal names is in the file, the CLI prints the file
2324
- at its line and column, relative to the current directory (a terminal
2325
- opens it on click), and the lines up to it with a caret under the field
2326
- (`util/config-frame.ts`):
2327
-
2328
- ```
2329
- ✗ vx lock lock not written
2330
-
2331
- packages/b/vx.config.ts:4:15
2332
- tasks.build.exec has unknown field "comand"
2333
- allowed: …
2334
-
2335
- 2 | tasks: {
2336
- 3 | build: {
2337
- > 4 | exec: { comand: 'mkdir -p dist' },
2338
- | ^
2339
-
2340
- fix use command
2341
- ```
2342
-
2343
- A field the file does not hold (a required one missing, or one a plugin
2344
- set) prints without the frame. The table below lists the messages
2345
- a user meets most:
2346
-
2347
- | Symptom | Cause |
2348
- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
2349
- | `did not export a default object` | Forgot `export default`, or exported a non-object; a function (Vite's `defineConfig(() => …)` shape) adds that it is one and to export what it returns (D-110). |
2350
- | `tasks must be an object keyed by task name` | `tasks` is not an object — an ARRAY included. |
2351
- | `<path> is <what> — a config must be JSON data, because the cache key folds its JSON` | A value JSON cannot carry, anywhere in the config: a function, a symbol, a bigint, `NaN` / `±Infinity`, `undefined` in an array, a cycle, a getter or an object that is not plain (`Date`, `Map`, `RegExp`, a class instance). |
2352
- | `<level> has unknown field "<key>"` | Typo'd / unsupported key (see below). |
2353
- | `<level> must be an object (fields: <fields>), not an array` | An array where an object goes — `outputs: ['dist/**']` (Turbo's spelling) is `outputs: { files: ['dist/**'] }`, and the message says so. |
2354
- | `cannot find '<name>' — no node_modules above the config provides it; install the workspace's dependencies first` | A bare import nothing installed serves — a fresh clone before its install, or a typo. Refused before the config is evaluated, so Bun never auto-installs it from the registry (it would, when no `node_modules` exists above). |
2355
- | `cannot find '<name>' — Yarn Plug'n'Play installed the workspace's dependencies into .pnp.cjs, which Bun does not read; set <remedy>` | The same, with a `.pnp.cjs` at or above the config: the install is there and Bun cannot read it, so the remedy is `nodeLinker: node-modules` in `.yarnrc.yml` and `yarn install` (D-109). |
2356
- | `tasks.<name> must be an object` | The task value is null / a string / etc.; a string (package.json's `name: 'command'`) adds the `{ exec: { command } }` it goes in. |
2357
- | `exec must be an object with a command string` | `exec` is malformed. |
2358
- | `exec.command must be a non-empty string` | Forgot `command`, or an empty or whitespace-only string. |
2359
- | `exec.command holds a NUL, which no command line can carry` | A `\0` in the command (a template slip); the spawn refused it as exit 127, "not on this task's PATH", with the NUL printed as a space. |
2360
- | `exec.persistent must be an object (or omitted)` | Wrong shape. |
2361
- | `exec.persistent.readyWhen must be a string regex` | Non-string `readyWhen`. |
2362
- | `exec.persistent.readyWhen is not a valid regex (<error>)` | A `readyWhen` the runner could not compile (`(`); it failed the task as an internal error at run time. |
2363
- | `cache is not allowed on a persistent task` | persistent + cache combined. |
2364
- | `exec.interactive must be a boolean (or omitted)` | Wrong shape. |
2365
- | `cache is not allowed on an interactive task` | interactive + cache combined: what it does depends on what is typed. |
2366
- | `sandbox is not allowed on an interactive task` | interactive + sandbox combined. |
2367
- | `persistent.readyWhen is not allowed on an interactive task` | vx reads none of an interactive task's output. |
2368
- | `a task with no exec must declare dependsOn` | Group task with no edges. |
2369
- | `cache needs exec — a task with no command produces nothing to cache` | `cache` on a group: a task with no command writes nothing to cache. |
2370
- | `dependsOn must be an array of strings` | Wrong shape. |
2371
- | `cache needs inputs — the key is the task's inputs` | Forgot `inputs` on a cached task. |
2372
- | `inputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2373
- | `inputs.files must be an array` | Wrong shape. |
2374
- | `inputs.runtime must be an array of non-empty shell command strings with no NUL` | Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
2375
- | `inputs.workspaceRuntime must be an array of non-empty shell command strings with no NUL` | Non-string / blank entry (whitespace alone runs as a no-op), or one holding a NUL. |
2376
- | `inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
2377
- | `inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
2378
- | `cache.outputs is required when cache is set` | Forgot `outputs`. |
2379
- | `cache.inputStability must be an object` | A bare string (`inputStability: 'mutable'`). |
2380
- | `cache.inputStability.strategy must be 'immutable', 'mutable' or 'unchecked'` | Any other value (`'content'`, `false`). |
2381
- | `cache.inputStability.severity must be 'warn' or 'error'` | Any other value (`'fail'`, `true`). |
2382
- | `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2383
- | `cache.outputs.files must be an array` | Wrong shape. |
2384
- | `inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
2385
- | `inputs.files: "<file>" is taken back by "!<glob>"` | A literal input a `!` entry covers: the negation subtracts wherever it sits, so the file never entered the key. |
2386
- | `cache.outputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs: a `!` entry only takes back what a positive glob selected (A-44). |
2387
- | `cache.outputs.files: "<glob>" covers the project's own <file>` | An output glob that matches the project's own `vx.config.*` (`**`, `*.ts`): the clean before a run would delete it. A `!` entry that takes the file back (`!vx.config.ts`) lets it load. |
2388
- | `cache.outputs.files: "<glob>" is under workspace-outputs/` | A project output named into the artifact's namespace for `outputs.workspaceFiles`: every hit read it back as a workspace output. |
2389
- | `inputs.files: '!!' is not a double negation` | `!!x` inverts the set — it folds only `x`. |
2390
- | `exec.timeout of <value> exceeds the maximum timer delay` | Past 2^31-1 ms a timer fires at once, not never. |
2391
- | `description must be a string` | Non-string description. |
2392
- | `tag "<tag>" <why> — --filter tag:<name> could not name it` | A tag with surrounding whitespace or a `*`, or one ending in `...` or `[<ref>]`, which a filter reads as a pattern, a dependency walk or a git range. |
2393
-
2394
- **Unknown fields are rejected**, not ignored, at every object level —
2395
- the project's top level (`tasks`), the task itself, `exec`, `exec.env`,
2396
- `exec.persistent`, `exec.sandbox` and its `allow` /
2397
- `deny` / `ignore` blocks, `cache`, `inputs`, and `cache.outputs`
2398
- (`tests/schema-unknown-keys.test.ts` walks every one) — and at the top
2399
- of `.vx/workspace.ts`,
2400
- where `plugin:` (singular) would otherwise declare no plugins and run
2401
- the workspace bare. A silently-dropped
2402
- `workspaceFile` (singular) or `timeoutMs` would make the task hash as
2403
- though the field had never been written, so vx would replay an artifact
2404
- built from different inputs; a silently-dropped `env: { set: … }` would
2405
- run the task without the variables it was written to have. The error
2406
- names the offending key, lists what that level accepts, and adds the
2407
- nearest accepted spelling when one is within two edits:
2408
- `tasks.build.exec.env has unknown field "passthrough" (allowed: define,
2409
- passThrough, secret) — did you mean passThrough?`. A field another runner spells
2410
- on the task names vx's home for it instead (D-37): Turbo's `outputs`,
2411
- `inputs`, `env`, `passThroughEnv`, `persistent`, `outputLogs`, `interactive`
2412
- (`exec.interactive`) and `with`, Nx's target `executor`, `options`, `continuous` (D-49), `cwd`,
2413
- `parallelism` and `configurations` (D-89), and a `command`
2414
- (`cmd`, `script`) on the task or `cmd` on `exec`, and on `exec` Nx
2415
- run-commands' `cwd`, `args`, `commands`, `parallel` and `shell`
2416
- (D-100), and in a `cache` block wireit's `files` / `output`,
2417
- `env`, `dependencies`, `enabled`, and `globs` / `include` / `patterns` /
2418
- `exclude` / `ignore` under `inputs` or `outputs` (D-118). So `outputs` on a task
2419
- ends `— vx spells it cache.outputs.files` in code quotes. A `cache` that
2420
- is no object (Turbo's `cache: false`) and a `persistent` that is none
2421
- (`true`) name the shape to write.
2422
-
2423
- Project-discovery errors (`src/workspace/workspace.ts`, `src/orchestrator/projects.ts`):
2424
-
2425
- | Symptom | Cause |
2426
- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
2427
- | `two projects are named "<name>": <dir> and <dir> — give one another \`name\` in its vx.config` | Two directories got one name: a config's `name`, a plugin's, or the path. |
2428
- | `<dir>: a project is named by its path unless named, and this one holds "#" …` | A path no task spec can address: set `name` in its config. |
2429
- | `<dir>: "//" names the workspace root — set another \`name\`` | A config away from the root says `name: '//'`. |
2430
- | `.vx/projects.lock.json locks <dir>, but its <file> is gone — run \`vx lock\`` | A locked project's config was deleted or renamed (X-144). |
2431
- | `.vx/projects.lock.json locks <dir>, but its <file> is not readable by this user (<code>)` | This user may not search the project's directory (D-132). |
2432
- | `.vx/projects.lock.json has unsupported version <n> (this vx expects 3) — re-run \`vx lock\`` | A lock an earlier vx wrote (version 2 keyed by name). |
2433
- | `git ls-files failed (exit <n>): <why>` | `vx lock` could not list the configs (a torn index). |
2434
-
2435
- Core reads no `package.json` or `pnpm-workspace.yaml`; the errors reading them are `@vzn/vx-migrate`'s (its README, "Errors reading the members").
2436
-
2437
- Workspace-config errors:
2438
-
2439
- | Symptom | Cause |
2440
- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
2441
- | `<setting> must be <what its kind takes> (got <value>)` | A setting of the wrong type or out of range: `run.concurrency: 0`, `cache.dir: 42`, `run.schedule: 'fastest'`. The [settings table](#settings) says what each takes; a count is a number and a switch a boolean, never a string. |
2442
- | `<setting> takes a duration with its unit, like '<n>ms' or '<n>s' (got <n>)` | A bare number for a duration: `run.timeout: 30000`. Durations carry a unit, so `30000` cannot be read as seconds by mistake. |
2443
- | `<setting> of <value> exceeds the maximum timer delay` | A timer past 2^31-1 ms fires at once, not never. Omit `run.timeout` for no limit. |
2444
- | `<group> must be an object (fields: <fields>)` | `run`, `watch`, `run.rules` or `cache.retention` that is not an object. |
2445
- | `cache must be an object like { dir, policy, scope }; a task caches through its own cache in its project's vx.config` | Not an object: Turbo's and Nx's workspace `cache: true` is a task's own `cache` in vx. |
2446
- | `cache.dir holds a NUL, which no path can carry` | No path can hold a NUL; the cache directory's error blamed the workspace's permissions. |
2447
- | `run.affectedBase must be a git ref like 'origin/main'` | Empty, holding whitespace or a NUL, or starting with `-`, which git would read as an option. |
2448
- | `cache.scope must be a scope name of <rule>` | Not a string, or a scope name holding a character a cache key cannot carry. |
2449
- | `cache.retention names neither olderThan nor maxSize` | An empty policy would evict nothing and read as one. |
2450
- | `cache.retention.olderThan of 0 evicts every entry after every run` | Every run would evict what it just saved; `vx cache clean` refuses it too. |
2451
- | `cache.retention.maxSize of 0 evicts every entry after every run` | The same, for the size bound. |
2452
- | `cache.retention.maxSize '<n>' reads as <n> bytes — give a unit (e.g. '<n>MB', '<n>GB')` | A bare number is bytes; a cache capped at ten bytes keeps nothing. |
2453
- | `plugins must be an array of plugin objects` | Wrong shape. |
2454
- | `plugins[<i>] must be an object` | A non-object entry in `plugins`; a string (Nx's `'@nx/vite/plugin'`) adds that a plugin is what its package's function returns, not a module name (D-49). |
2455
- | `plugins[<i>] must come from definePlugin(import.meta, { … })` | A plain object where a plugin was expected: a plugin's name is its package name, and only `definePlugin` sets it. |
2456
- | `plugins[<i>] is a Promise — await the factory that returns the plugin` | An async plugin factory called without `await` (`plugins: [later()]`); the refusal said the plugin did not come from `definePlugin` (D-159). |
2457
- | `plugins[<i>].name overrides the package name` | A `name` set over `definePlugin`'s result; drop the field. |
2458
- | `plugins[<i>].<capability> of plugin '<name>' must be a function` | A capability key holding something that is not callable. |
2459
- | `plugins[<i>] (plugin '<name>') must contribute at least one of config/discover/project/graph/key/fingerprint/schedule/admit/executor/cache/telemetry/output/setup/commands/teardown` | A plugin object with no capability. |
2460
- | `plugin '<name>' declares '<key>', which is no plugin hook` | A key on a plugin that names no hook (`excutor`, `setUp`): it was never called. The nearest hook is hinted. |
2461
- | `<file> has unknown field "<key>"` | Typo'd / unsupported top-level key (`plugin`); the hint names the nearest spelling, or where vx keeps a Turbo or Nx key (`pipeline`, `remoteCache`, `parallel`, `cacheDirectory`, `defaultBase`, `tasksRunnerOptions`, `globalPassThroughEnv`; D-38, D-49; pnpm-workspace.yaml's, Lerna's and package.json's `packages`, `workspaces`, `catalog`, `npmClient`, and `ignore`, `cache`, `remote`, `env`, D-98; in a project's vx.config, `targets`, `extends`, `implicitDependencies` and `name`, D-56; `scripts`, `pipeline`, `namedInputs`, `root`, `sourceRoot`, `projectType`, and a task's `dependsOn`, `cache`, `exec`, `inputs`, `outputs`, `env` one level too high, D-99). |
2462
- | `plugin '<name>' declares command '<verb>', a core verb — core verbs cannot be shadowed` | A plugin verb the dispatcher matches first; it could never run. |
2463
- | `plugin '<name>' declares command '<verb>', which is not a word — a verb is a letter or digit, then letters, digits, ':', '.', '_' or '-'` | A verb like `--version`, `''` or `a b`: core reads a flag first, an empty word is no verb, and `vx completions` writes verbs unquoted into a shell script. |
2464
- | `plugins '<a>' and '<b>' both declare command '<verb>' — a verb has one owner` | Two packages on one verb; the first would win and hide the second. Plugins of one package are one owner, and the first declared runs. |
2465
- | `plugins[<i>].fingerprint of plugin '<name>' must be { files: [name, …], affected: function }` | A fingerprint claim without its file list or its `affected` answer. |
2466
- | `plugin '<name>' claims fingerprint file "<file>", which is not a file name at the workspace root` | A claim names a path, or no name: a claim is a bare file at the root (`vx watch`'s root arm is not recursive). |
2467
- | `plugins '<a>' and '<b>' both claim fingerprint file '<file>' — a file has one claimant` | Two plugins keying the same lockfile; the key would fold both and `--affected` could ask only one. |
2468
- | `plugin '<name>' claims fingerprint file '<file>' twice (plugins[<i>] and plugins[<j>]) — a file has one claimant; declare the plugin once` | One plugin package declared twice (`plugins: [bun(), bun()]`); the message named it as two plugins (D-158). |