@vzn/vx 0.0.634 → 0.0.635

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.634",
4
- "description": "The fastest task runner and build cache for JavaScript monorepos.",
3
+ "version": "0.0.635",
4
+ "description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -15,16 +15,16 @@
15
15
  },
16
16
  "types": "./src/index.ts",
17
17
  "bin": {
18
- "vx": "./launcher.cjs"
18
+ "vx": "./launcher.sh"
19
19
  },
20
20
  "engines": {
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.634",
25
- "@vzn/vx-linux-arm64": "0.0.634",
26
- "@vzn/vx-darwin-x64": "0.0.634",
27
- "@vzn/vx-darwin-arm64": "0.0.634"
24
+ "@vzn/vx-linux-x64": "0.0.635",
25
+ "@vzn/vx-linux-arm64": "0.0.635",
26
+ "@vzn/vx-darwin-x64": "0.0.635",
27
+ "@vzn/vx-darwin-arm64": "0.0.635"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -42,6 +42,7 @@
42
42
  "docs/patterns.md",
43
43
  "docs/security.md",
44
44
  "docs/features.md",
45
+ "launcher.sh",
45
46
  "launcher.cjs",
46
47
  "README.md",
47
48
  "LICENSE"
@@ -64,6 +65,14 @@
64
65
  "nx",
65
66
  "bun",
66
67
  "workspaces",
67
- "ci"
68
+ "ci",
69
+ "task-orchestration",
70
+ "turborepo-alternative",
71
+ "nx-alternative",
72
+ "remote-cache",
73
+ "sandbox",
74
+ "incremental-build",
75
+ "mcp",
76
+ "pnpm"
68
77
  ]
69
78
  }
package/schemas/last.json CHANGED
@@ -170,7 +170,7 @@
170
170
  },
171
171
  "locations": {
172
172
  "type": "array",
173
- "description": "on a failed row: the existing files its output names, in order of first appearance",
173
+ "description": "on a failed row: the existing files its output names, in order of first appearance; a file it also names with a line drops its bare mention",
174
174
  "items": {
175
175
  "type": "object",
176
176
  "additionalProperties": false,
@@ -181,6 +181,10 @@
181
181
  "col": { "type": "integer" }
182
182
  }
183
183
  }
184
+ },
185
+ "fixedIn": {
186
+ "type": "string",
187
+ "description": "on a failed row: the first later run where this task passed (ran or hit); absent while it has not"
184
188
  }
185
189
  }
186
190
  }
package/schemas/show.json CHANGED
@@ -115,7 +115,8 @@
115
115
  "files": { "$ref": "#/$defs/stringList" },
116
116
  "workspaceFiles": { "$ref": "#/$defs/stringList" }
117
117
  }
118
- }
118
+ },
119
+ "rewrittenInputs": { "enum": ["withhold", "compare"] }
119
120
  }
120
121
  }
121
122
  }
package/schemas/why.json CHANGED
@@ -77,7 +77,10 @@
77
77
  "type": ["boolean", "null"],
78
78
  "description": "on a hit: true when outputs were restored, false when up-to-date"
79
79
  },
80
- "cached": { "type": ["boolean", "null"] },
80
+ "cached": {
81
+ "type": ["boolean", "null"],
82
+ "description": "whether the task declares a cache; not whether this run hit it"
83
+ },
81
84
  "startedAt": { "type": "integer", "description": "epoch ms" }
82
85
  }
83
86
  },
@@ -76,7 +76,9 @@ export interface RemoteCacheLayer {
76
76
  * straight into the local cache — an HTTP wire returns its `fetch`
77
77
  * `Response`, a chunked wire `new Response(stream)`, bytes in hand
78
78
  * `new Blob([bytes])`. `durationMs` is the producing task's duration
79
- * when the wire carries it.
79
+ * when the wire carries it. A body that errors before its end stores
80
+ * nothing and the read is a miss, so a layer can check bytes as they pass
81
+ * (a digest, a signature) and refuse at the end.
80
82
  */
81
83
  get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
82
84
  /**
package/src/cli/last.ts CHANGED
@@ -384,7 +384,15 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
384
384
  )
385
385
  const tasks = (detail?.tasks ?? []).map((t) => {
386
386
  const f = failures.get(`${t.project}#${t.task}`)
387
- return f === undefined ? t : { ...t, output: f.output, locations: f.locations }
387
+ return f === undefined
388
+ ? t
389
+ : {
390
+ ...t,
391
+ output: f.output,
392
+ locations: f.locations,
393
+ ...(f.sandboxFix !== undefined ? { sandboxFix: f.sandboxFix } : {}),
394
+ ...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
395
+ }
388
396
  })
389
397
  process.stdout.write(`${JSON.stringify({ invocation: inv, tasks })}\n`)
390
398
  return 0
@@ -10,7 +10,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
10
10
  },
11
11
  cache: {
12
12
  plugin:
13
- "// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { mkdir, rename } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then renamed: a reader never sees half an artifact.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n await rename(tmp, path.join(this.endpoint, hash))\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\n },\n })\n}\n",
13
+ "// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\n },\n })\n}\n",
14
14
  test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
15
15
  },
16
16
  commands: {
package/src/cli/run.ts CHANGED
@@ -479,7 +479,9 @@ function onCI(v: string | undefined): boolean {
479
479
  async function scopeFilters(
480
480
  parsed: RunArgs,
481
481
  cwd: string,
482
- ): Promise<{ filterStrings: string[]; affectedFilter?: string } | { error: string }> {
482
+ ): Promise<
483
+ { filterStrings: string[]; affectedFilter?: string } | { error: string; code?: string }
484
+ > {
483
485
  // `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
484
486
  // changed projects AND their dependents — what a CI gate must run, what
485
487
  // the flag's name says, what the guides promised while the sugar was the
@@ -523,7 +525,7 @@ export async function resolveRunOptions(
523
525
  verb: 'run' | 'watch' = 'run',
524
526
  /** Given, it learns the ref `--affected` diffed from (`--dry=json` states it). */
525
527
  scopeOut?: { affectedBase?: string },
526
- ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
528
+ ): Promise<RunOptions | { error: string; code?: string } | { nothingSelected: string }> {
527
529
  for (const t of tasks) {
528
530
  const idx = t.indexOf('#')
529
531
  if (idx >= 0) {
@@ -566,7 +568,7 @@ export async function resolveRunOptions(
566
568
  affectedFilter,
567
569
  )
568
570
  : undefined
569
- if (resolved !== undefined && 'error' in resolved) return { error: resolved.error }
571
+ if (resolved !== undefined && 'error' in resolved) return resolved
570
572
  if (bareTasks.length === 0) {
571
573
  projects = undefined
572
574
  } else if (resolved !== undefined) {
@@ -696,11 +698,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
696
698
  // `--filter` / `--affected` scope the menu, as they scope a bare task:
697
699
  // the menu listed every project and the anchored pick ran outside it.
698
700
  const scope = await scopeFilters(parsed, cwd)
699
- if ('error' in scope) return refuse('run', args, scope.error, 'VX_E_USAGE')
701
+ if ('error' in scope) return refuse('run', args, scope.error, scope.code ?? 'VX_E_USAGE')
700
702
  let only: ReadonlySet<string> | undefined
701
703
  if (scope.filterStrings.length > 0) {
702
704
  const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
703
- if ('error' in selected) return refuse('run', args, selected.error, 'VX_E_USAGE')
705
+ if ('error' in selected)
706
+ return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
704
707
  if ('empty' in selected) {
705
708
  process.stderr.write(`vx run: ${selected.empty}\n`)
706
709
  return 0
@@ -716,7 +719,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
716
719
 
717
720
  const scopeOut: { affectedBase?: string } = {}
718
721
  const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
719
- if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
722
+ if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
720
723
  if ('nothingSelected' in resolved) {
721
724
  process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
722
725
  return 0
package/src/cli/select.ts CHANGED
@@ -197,7 +197,7 @@ export type FilterResolution =
197
197
  /** What the other includes selected (`RunOptions.selectedOutright`). */
198
198
  outright?: string[]
199
199
  }
200
- | { error: string }
200
+ | Refusal
201
201
  | { empty: string }
202
202
 
203
203
  /**
@@ -239,16 +239,23 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
239
239
  return out
240
240
  }
241
241
 
242
+ /** A refusal a resolver returns; `code` is the thrown one's own, when it has one. */
243
+ type Refusal = { error: string; code?: string }
244
+
245
+ function refusalOf(err: unknown): Refusal {
246
+ if (!(err instanceof Error)) return { error: String(err) }
247
+ return isUserError(err) && err.code !== 'VX_E_REFUSED'
248
+ ? { error: err.message, code: err.code }
249
+ : { error: err.message }
250
+ }
251
+
242
252
  /**
243
253
  * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
244
254
  * projects and their dependents. An empty base is the workspace's
245
255
  * `affectedBase` (or a plugin's `config` stage, from nx.json's
246
256
  * `defaultBase` or `TURBO_SCM_BASE`), then the guess.
247
257
  */
248
- export async function affectedFilterFor(
249
- cwd: string,
250
- affected: string,
251
- ): Promise<string | { error: string }> {
258
+ export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
252
259
  const root = await findWorkspaceRoot(cwd)
253
260
  let base = affected
254
261
  if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
@@ -257,7 +264,7 @@ export async function affectedFilterFor(
257
264
  base = await defaultAffectedBase(root)
258
265
  } catch (err) {
259
266
  if (!isUserError(err)) throw err
260
- return { error: err.message }
267
+ return refusalOf(err)
261
268
  }
262
269
  }
263
270
  return `...[${base}]`
@@ -276,7 +283,7 @@ export async function resolveFilters(
276
283
  try {
277
284
  parsed = raw.map((r) => parseFilter(r, root))
278
285
  } catch (err) {
279
- return { error: err instanceof Error ? err.message : String(err) }
286
+ return refusalOf(err)
280
287
  }
281
288
  // `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
282
289
  const walksGraph = parsed.some(
@@ -299,7 +306,7 @@ export async function resolveFilters(
299
306
  try {
300
307
  edges = taskEdgesFrom(await stagedOnce())
301
308
  } catch (err) {
302
- return { error: err instanceof Error ? err.message : String(err) }
309
+ return refusalOf(err)
303
310
  }
304
311
  }
305
312
  // A tag lives in the config, so a `tag:` selector needs the staged load
@@ -309,7 +316,7 @@ export async function resolveFilters(
309
316
  try {
310
317
  tags = new Map([...(await stagedOnce()).values()].map((p) => [p.name, p.config.tags ?? []]))
311
318
  } catch (err) {
312
- return { error: err instanceof Error ? err.message : String(err) }
319
+ return refusalOf(err)
313
320
  }
314
321
  }
315
322
  const graph = buildPackageGraph(projects, edges)
@@ -353,8 +360,7 @@ export async function resolveFilters(
353
360
  }
354
361
  affectedByFilter.set(f, names)
355
362
  } catch (err) {
356
- const msg = err instanceof Error ? err.message : String(err)
357
- return { error: msg }
363
+ return refusalOf(err)
358
364
  }
359
365
  }
360
366
 
package/src/cli/show.ts CHANGED
@@ -145,12 +145,12 @@ export async function showCmd(args: readonly string[]): Promise<number> {
145
145
  let affected: string | undefined
146
146
  if (parsed.affected !== undefined) {
147
147
  const f = await affectedFilterFor(process.cwd(), parsed.affected)
148
- if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, 'VX_E_USAGE')
148
+ if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, f.code ?? 'VX_E_USAGE')
149
149
  affected = f
150
150
  raw.unshift(f)
151
151
  }
152
152
  const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
153
- if ('error' in r) throw new UserError(`vx show: ${r.error}`, 'VX_E_USAGE')
153
+ if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
154
154
  if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
155
155
  selected = new Set('empty' in r ? [] : r.names)
156
156
  if ('names' in r && r.affected !== undefined)
package/src/config.ts CHANGED
@@ -65,9 +65,11 @@ export interface WorkspaceConfig {
65
65
  * Plugins registered for this workspace, consulted in this order once
66
66
  * per `vx run`: each fills any of the pipeline stages and capabilities
67
67
  * below, and core's own executor and cache store sit at the tail of
68
- * every list. See `docs/design/pipeline-2026-09.md`.
68
+ * every list. See `docs/design/pipeline-2026-09.md`. An `undefined`,
69
+ * `null` or `false` entry is skipped, so a factory that declines
70
+ * (`github()` off Actions) or `cond && plugin()` needs no filter.
69
71
  */
70
- plugins?: readonly Plugin[]
72
+ plugins?: readonly (Plugin | undefined | null | false)[]
71
73
  }
72
74
 
73
75
  /** `WorkspaceConfig.rules`. */
@@ -488,6 +490,16 @@ export interface CacheConfig {
488
490
  inputs: CacheInputs
489
491
  /** What this task produces. Captured for restore on a cache hit. */
490
492
  outputs: CacheOutputs
493
+ /**
494
+ * What a write to an input while the command runs means. `'withhold'`
495
+ * (the default): the entry is not saved, whatever the file holds after,
496
+ * since an edit undone mid-run cannot be told from no edit. `'compare'`:
497
+ * a file rewritten to the bytes the key folded counts as unchanged, for
498
+ * a task that rewrites its own input identically (tsdown's
499
+ * `package.json`). `VX_REWRITTEN_INPUTS=compare` sets the default for
500
+ * every task that does not say.
501
+ */
502
+ rewrittenInputs?: 'withhold' | 'compare'
491
503
  }
492
504
 
493
505
  export interface CacheOutputs {
@@ -208,6 +208,15 @@ export interface TaskOutcome {
208
208
  * inline in the task's block instead of as loose status output.
209
209
  */
210
210
  sandboxViolationLines?: string[]
211
+ /** The `exec.sandbox.allow` entries that would have granted the denials. */
212
+ sandboxFix?: {
213
+ read?: string[]
214
+ write?: string[]
215
+ network?: string[]
216
+ systemInfo?: string[]
217
+ /** Credential paths denied and left out of the grants: an agent applies the fix as given. */
218
+ withheld?: string[]
219
+ }
211
220
  }
212
221
 
213
222
  export type ContinueMode = 'never' | 'deps-ok' | 'always'
@@ -804,14 +813,16 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
804
813
  // tasks are never asked, so they never park.
805
814
  const takeFitting = (): string | undefined => {
806
815
  if (holding) return undefined
807
- // No pools, no policy and a full exec lane: nothing on the
808
- // exec queue can be admitted, so it is not scanned — scanning it
816
+ // No pools and a full exec lane: nothing on the exec queue can be
817
+ // admitted, so it is not scanned — an admit policy only narrows
818
+ // the count gate, never widens it (scanning under one cost the
819
+ // history plugin's cold bench run 10 s, 2026-10-09) — scanning it
809
820
  // would pop and re-park every ready exec task on every tick, and
810
821
  // with a wide frontier that is O(R) per tick, O(R²) per run (the
811
822
  // 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
812
823
  // first cut let the scan run past a full exec lane, 2026-09-10).
813
824
  // This is the legacy O(1) gate, kept per lane.
814
- const execAdmissible = poolOf !== undefined || admitActive || execRoom()
825
+ const execAdmissible = poolOf !== undefined || execRoom()
815
826
  if (execAdmissible) {
816
827
  while (execReady.size > 0) {
817
828
  const seq = execReady.peekSeq()
@@ -302,6 +302,7 @@ export interface BuildGraphOptions {
302
302
  export function undeclaredDepsError(taskId: string, name: string): UserError {
303
303
  return new UserError(
304
304
  `Task ${taskId} depends on ^${name} but no project in the workspace declares ${name}`,
305
+ 'VX_E_DEPENDS_ON',
305
306
  )
306
307
  }
307
308
 
@@ -492,7 +493,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
492
493
  spec = parseDependencySpec(raw)
493
494
  } catch (err) {
494
495
  if (err instanceof DependencySpecError) {
495
- throw new UserError(`Task ${id}: ${err.message}`)
496
+ throw new UserError(`Task ${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
496
497
  }
497
498
  throw err
498
499
  }
@@ -503,14 +504,21 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
503
504
  // operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
504
505
  // they name a namespace of tasks to add (Nx 19.5 parity).
505
506
  if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
506
- throw new UserError(`Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`)
507
+ throw new UserError(
508
+ `Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`,
509
+ 'VX_E_DEPENDS_ON',
510
+ )
507
511
  }
508
512
  if (spec.negated) {
509
- throw new UserError(`Task ${id}: dependsOn does not accept negation (got "${raw}")`)
513
+ throw new UserError(
514
+ `Task ${id}: dependsOn does not accept negation (got "${raw}")`,
515
+ 'VX_E_DEPENDS_ON',
516
+ )
510
517
  }
511
518
  if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
512
519
  throw new UserError(
513
520
  `Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
521
+ 'VX_E_DEPENDS_ON',
514
522
  )
515
523
  }
516
524
  if (spec.kind === 'self') {
@@ -530,6 +538,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
530
538
  if (!visit(frame, projectName, spec.task, false)) {
531
539
  throw new UserError(
532
540
  `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
541
+ 'VX_E_DEPENDS_ON',
533
542
  )
534
543
  }
535
544
  goPast(frame, projectName, spec.task, new Set([projectName]))
@@ -594,9 +603,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
594
603
  if (spec.project === '//')
595
604
  throw new UserError(
596
605
  `Task ${id} depends on //#${spec.task}, Turbo's root package, but the workspace root is no project here: give it a package.json name and a vx.config with that task`,
606
+ 'VX_E_DEPENDS_ON',
597
607
  )
598
608
  throw new UserError(
599
609
  `Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
610
+ 'VX_E_DEPENDS_ON',
600
611
  )
601
612
  }
602
613
  goPast(frame, spec.project, spec.task, new Set([projectName, spec.project]))
@@ -1109,7 +1120,7 @@ function collideAcross(
1109
1120
  `cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
1110
1121
  `cache-hit restore, so whichever of these runs second DELETES the other's output`
1111
1122
  if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
1112
- throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
1123
+ throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
1113
1124
  }
1114
1125
  if (reaches(files.id, ws.id)) {
1115
1126
  ;(files.addsToOutputsOf ??= []).push(ws.id)
@@ -1132,11 +1143,13 @@ function collideAcross(
1132
1143
  `${head}. ${ws.id} depends on ${files.id}, but adding to its output needs every ` +
1133
1144
  `cache.outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
1134
1145
  `and ${JSON.stringify(outside)} is not. Give each task its own output path.`,
1146
+ 'VX_E_OUTPUT_OVERLAP',
1135
1147
  )
1136
1148
  }
1137
1149
  throw new UserError(
1138
1150
  `${head} and the run still reports success. Give each task its own output path` +
1139
1151
  (exclusive ? '.' : ', or make one depend on the other.'),
1152
+ 'VX_E_OUTPUT_OVERLAP',
1140
1153
  )
1141
1154
  }
1142
1155
  }
@@ -1373,7 +1386,8 @@ function collide(
1373
1386
  // `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
1374
1387
  // in either order, and the refusal below stands.
1375
1388
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
1376
- if (up !== undefined && exclusive) throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
1389
+ if (up !== undefined && exclusive)
1390
+ throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
1377
1391
  if (up !== undefined && down !== undefined) {
1378
1392
  const downGlobs = splitNegations(
1379
1393
  (field === 'files'
@@ -1386,6 +1400,7 @@ function collide(
1386
1400
  }
1387
1401
  throw new UserError(
1388
1402
  `${head} and the run still reports success. Give each task its own output path.`,
1403
+ 'VX_E_OUTPUT_OVERLAP',
1389
1404
  )
1390
1405
  }
1391
1406
  }
@@ -1440,6 +1455,7 @@ function refuseSelfClean(node: TaskNode): void {
1440
1455
  `its own output — vx removes a task's outputs before it runs, so the task would ` +
1441
1456
  `delete its own sources. A task that rewrites files in place (a formatter) declares ` +
1442
1457
  `no outputs.`,
1458
+ 'VX_E_INPUT_IS_OUTPUT',
1443
1459
  )
1444
1460
  }
1445
1461
  }
@@ -1663,6 +1679,7 @@ function readsOutputs(
1663
1679
  `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1664
1680
  `cache.inputs.${field}, or set rules: { upfrontKeys: false } in vx.workspace.ts to ` +
1665
1681
  `let it wait for its producer.`,
1682
+ 'VX_E_INPUT_IS_OUTPUT',
1666
1683
  )
1667
1684
  }
1668
1685
  }
package/src/index.ts CHANGED
@@ -65,7 +65,12 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
65
65
  // (`@vzn/vx-schedule-history` does; a plugin package reaches core only
66
66
  // through this façade, which is what put these here).
67
67
  export { LocalHistoryProvider } from './orchestrator/index.js'
68
- export type { HistoryProvider, HistoryTable, TaskHistory } from './orchestrator/index.js'
68
+ export type {
69
+ HistoryProvider,
70
+ HistoryTable,
71
+ TaskHistory,
72
+ TaskResources,
73
+ } from './orchestrator/index.js'
69
74
  export type { PreparedRun } from './orchestrator/index.js'
70
75
  // `RunOptions.affected`: what `--affected`'s diff changed, per project.
71
76
  export type { AffectedChanges } from './workspace/index.js'
@@ -78,6 +78,7 @@ import type { FingerprintWatch } from './fingerprint-watch.js'
78
78
  import { restoreHit } from './hit-restore.js'
79
79
  import type { MissExplainer } from './miss-reason.js'
80
80
  import { shellVerdict } from './shell-verdict.js'
81
+ import { sandboxFix } from './sandbox-fix.js'
81
82
  // The hit path's entry stays importable from here (tests).
82
83
  export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
83
84
  import type { DeferredOutputs } from './deferred-outputs.js'
@@ -336,6 +337,18 @@ async function restoreOrMiss(
336
337
  }
337
338
  }
338
339
 
340
+ /**
341
+ * Does a write to an input during the command count as unchanged when it
342
+ * left the bytes the key folded (`movedInput`'s `writesByBytes`)? Only when
343
+ * the task says `compare`, or says nothing and `VX_REWRITTEN_INPUTS` is
344
+ * `compare`: by default any such write withholds the save, on CI too,
345
+ * since a concurrent task can change an input and put it back (owner,
346
+ * 2026-10-12).
347
+ */
348
+ function rewritesByBytes(cache: CacheConfig | undefined): boolean {
349
+ return (cache?.rewrittenInputs ?? process.env['VX_REWRITTEN_INPUTS']) === 'compare'
350
+ }
351
+
339
352
  /**
340
353
  * The workspace fingerprint every key here folded has moved (a task
341
354
  * rewrote the lockfile), said once per run.
@@ -1558,7 +1571,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1558
1571
  */
1559
1572
  async function movedSinceKey(): Promise<string | null | undefined> {
1560
1573
  if (described!.hash !== hash) return null
1561
- return (await movedInput(described!.facts, cache, described!.describedAt)) ?? described!.added()
1574
+ return (
1575
+ (await movedInput(
1576
+ described!.facts,
1577
+ cache,
1578
+ described!.describedAt,
1579
+ rewritesByBytes(args.node.config.cache),
1580
+ )) ?? described!.added()
1581
+ )
1562
1582
  }
1563
1583
 
1564
1584
  /**
@@ -1608,6 +1628,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1608
1628
  }
1609
1629
 
1610
1630
  const finalViolations = violations
1631
+ const fixOf = (fix: TaskOutcome['sandboxFix']) => (fix === undefined ? {} : { sandboxFix: fix })
1611
1632
 
1612
1633
  return {
1613
1634
  node,
@@ -1640,6 +1661,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1640
1661
  sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
1641
1662
  // A denied path is the task's own spelling, a secret in it too.
1642
1663
  sandboxViolationLines: finalViolations.map((v) => secrets?.mask(v.line) ?? v.line),
1664
+ ...fixOf(
1665
+ sandboxFix(
1666
+ finalViolations,
1667
+ node.projectDir,
1668
+ (t) => secrets?.mask(t) ?? t,
1669
+ cacheCfg?.inputs.files,
1670
+ ),
1671
+ ),
1643
1672
  }
1644
1673
  : {}),
1645
1674
  }
@@ -32,6 +32,7 @@ import { paint, type ColorSupport } from './colors.js'
32
32
  import { neverStarted } from './summary.js'
33
33
  import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
34
34
  import { fileMemo, linkPaths } from './path-links.js'
35
+ import { sandboxFixLine } from './sandbox-fix.js'
35
36
 
36
37
  const NO_COLOR: ColorSupport = { enabled: false }
37
38
 
@@ -529,7 +530,32 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
529
530
  // every record for telemetry.
530
531
  const body = [...new Set(vlines)]
531
532
  // Red, not amber: a violation fails the task, so it reads as a failure.
532
- return [sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors), '', ...body, '']
533
+ // vx's note, after the evidence: the grants that would have allowed it.
534
+ // A read the output depends on is a key input too; the sandbox never
535
+ // adds one (owner, 2026-10-09).
536
+ const fix = outcome.sandboxFix
537
+ const line = fix === undefined ? undefined : sandboxFixLine(fix)
538
+ const dim = (text: string): string => paint('', text, colors, { dim: true })
539
+ const note =
540
+ fix === undefined
541
+ ? []
542
+ : [
543
+ ...(line !== undefined ? [dim(`fix: ${line}`)] : []),
544
+ ...(fix.read !== undefined
545
+ ? [dim('a read the output depends on belongs in cache.inputs.files too')]
546
+ : []),
547
+ ...(fix.withheld !== undefined
548
+ ? [dim(`credential path, grant it only on purpose: ${fix.withheld.join(', ')}`)]
549
+ : []),
550
+ '',
551
+ ]
552
+ return [
553
+ sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors),
554
+ '',
555
+ ...body,
556
+ '',
557
+ ...note,
558
+ ]
533
559
  }
534
560
 
535
561
  export function formatFrameClose(