@vzn/vx 0.0.595 → 0.0.596

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 (50) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/cache/cache.ts +139 -84
  4. package/src/cache/chained-cache.ts +25 -7
  5. package/src/cache/index.ts +1 -0
  6. package/src/cache/inputs.ts +12 -12
  7. package/src/cache/layer.ts +7 -5
  8. package/src/cache/policy.ts +5 -0
  9. package/src/cache/zstd.ts +10 -4
  10. package/src/cli/cache.ts +10 -4
  11. package/src/cli/foreign-flags.ts +1 -1
  12. package/src/cli/last.ts +4 -1
  13. package/src/cli/run.ts +31 -15
  14. package/src/cli/watch-filter.ts +63 -12
  15. package/src/cli/watch-judge.ts +15 -8
  16. package/src/cli/watch.ts +4 -4
  17. package/src/config.ts +21 -21
  18. package/src/exec/proc-sample.ts +1 -1
  19. package/src/exec/sandbox-paths.ts +1 -1
  20. package/src/exec/sandbox-runtime.ts +116 -40
  21. package/src/exec/sandbox-violations.ts +58 -11
  22. package/src/graph/scheduler.ts +28 -12
  23. package/src/graph/task-graph.ts +134 -25
  24. package/src/orchestrator/admission.ts +12 -3
  25. package/src/orchestrator/events.ts +10 -1
  26. package/src/orchestrator/execute-task.ts +36 -13
  27. package/src/orchestrator/failure-mode.ts +8 -2
  28. package/src/orchestrator/framed-output.ts +3 -3
  29. package/src/orchestrator/logger.ts +11 -6
  30. package/src/orchestrator/metrics.ts +19 -8
  31. package/src/orchestrator/miss-save.ts +2 -2
  32. package/src/orchestrator/options.ts +7 -0
  33. package/src/orchestrator/plan.ts +3 -3
  34. package/src/orchestrator/plugin.ts +37 -16
  35. package/src/orchestrator/prepare.ts +13 -4
  36. package/src/orchestrator/run-artifacts.ts +2 -1
  37. package/src/orchestrator/run.ts +20 -4
  38. package/src/orchestrator/sandbox-request.ts +65 -9
  39. package/src/orchestrator/save-lane.ts +5 -5
  40. package/src/orchestrator/summary.ts +2 -4
  41. package/src/orchestrator/telemetry.ts +16 -7
  42. package/src/util/paths.ts +23 -3
  43. package/src/workspace/config-imports.ts +7 -5
  44. package/src/workspace/config-schema.ts +26 -3
  45. package/src/workspace/filter.ts +19 -0
  46. package/src/workspace/migrate-scripts.ts +4 -2
  47. package/src/workspace/migration.ts +1 -1
  48. package/src/workspace/project-loader.ts +4 -2
  49. package/src/workspace/repo-id.ts +42 -4
  50. package/src/workspace/workspace.ts +17 -10
package/src/cli/cache.ts CHANGED
@@ -85,10 +85,15 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
85
85
  if (a === '--older-than' || a?.startsWith('--older-than=')) {
86
86
  const v = a === '--older-than' ? args[++i] : a.slice('--older-than='.length)
87
87
  if (v === undefined || v === '') {
88
- return { error: `--older-than requires a value (e.g. 30d, 24h, 60m)` }
88
+ return {
89
+ error: `--older-than requires a value: a duration like 30d, 24h or 60m${seeHelp('cache')}`,
90
+ }
89
91
  }
90
92
  const ms = parseDuration(v)
91
- if (ms === null) return { error: `invalid duration: ${v} (e.g. 30d, 24h, 60m)` }
93
+ if (ms === null)
94
+ return {
95
+ error: `--older-than must be a duration like 30d, 24h or 60m (got ${v})${seeHelp('cache')}`,
96
+ }
92
97
  // `0d` evicts everything ever cached. That is far more often a
93
98
  // computed-to-zero retention than an intent, and no other flag
94
99
  // combination expresses "wipe the cache" — so refuse it and name
@@ -102,10 +107,11 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
102
107
  } else if (a === '--max-size' || a?.startsWith('--max-size=')) {
103
108
  const v = a === '--max-size' ? args[++i] : a.slice('--max-size='.length)
104
109
  if (v === undefined || v === '') {
105
- return { error: `--max-size requires a value (e.g. 500M, 1G)` }
110
+ return { error: `--max-size requires a value: a size like 500M or 1G${seeHelp('cache')}` }
106
111
  }
107
112
  const bytes = parseSize(v)
108
- if (bytes === null) return { error: `invalid size: ${v} (e.g. 500M, 1G)` }
113
+ if (bytes === null)
114
+ return { error: `--max-size must be a size like 500M or 1G (got ${v})${seeHelp('cache')}` }
109
115
  if (bytes === 0) {
110
116
  return {
111
117
  error: `--max-size 0 would evict every entry — to clear the cache, delete the cache store \`vx info\` names`,
@@ -375,7 +375,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
375
375
  names: ['--preflight'],
376
376
  value: false,
377
377
  outcome: 'refuse',
378
- vx: '`turboCache()` sends no CORS preflight: drop it',
378
+ vx: 'set `turboCache({ preflight: true })` or `TURBO_PREFLIGHT=1`',
379
379
  },
380
380
  {
381
381
  runner: 'turbo',
package/src/cli/last.ts CHANGED
@@ -60,7 +60,10 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
60
60
  if (spaced) i++
61
61
  const n = Number(lv)
62
62
  if (!Number.isInteger(n) || n < 1 || n > 500) {
63
- return { ...out, error: `invalid --list: ${lv} (expected 1..500)` }
63
+ return {
64
+ ...out,
65
+ error: `--list must be a whole number from 1 to 500 (got ${lv})${seeHelp('last')}`,
66
+ }
64
67
  }
65
68
  out.list = n
66
69
  continue
package/src/cli/run.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  type RunOptions,
13
13
  type RunResult,
14
14
  } from '../orchestrator/index.js'
15
- import type { ContinueMode } from '../graph/index.js'
15
+ import type { ContinueMode, TaskOutcome } from '../graph/index.js'
16
16
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
17
17
  import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
18
18
  import { nxTargetHint, taskNamesHere } from './task-verb.js'
@@ -486,6 +486,7 @@ export async function resolveRunOptions(
486
486
  parsed: RunArgs,
487
487
  cwd: string,
488
488
  tasks: readonly string[],
489
+ verb: 'run' | 'watch' = 'run',
489
490
  ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
490
491
  for (const t of tasks) {
491
492
  const idx = t.indexOf('#')
@@ -557,8 +558,7 @@ export async function resolveRunOptions(
557
558
  const nx = await nxTargetHint(tasks, cwd)
558
559
  if (nx !== null) return { error: nx }
559
560
  return {
560
- error:
561
- 'not inside a project. Pass --all for every project, --filter <pattern> to filter, or run from within a project directory.',
561
+ error: `not inside a project: run from a project directory, or pass --all or --filter <pattern>${seeHelp(verb)}`,
562
562
  }
563
563
  }
564
564
  projects = [cwdProject.name]
@@ -726,6 +726,25 @@ export async function runCmd(args: readonly string[]): Promise<number> {
726
726
  return 0
727
727
  }
728
728
 
729
+ // The report prints above the footer: nothing prints below it (owner).
730
+ // Rendered once, however many sinks asked for it; a kept server's crash
731
+ // after the footer re-renders the file's copy (C-53).
732
+ const wantsReport = parsed.report === 'markdown' || parsed.reportFile !== undefined
733
+ let reported: { outcomes: readonly TaskOutcome[]; md: string } | undefined
734
+ if (wantsReport) {
735
+ opts.beforeFooter = (outcomes, ok) => {
736
+ reported = {
737
+ outcomes,
738
+ md: formatRunReportMarkdown({ ok, outcomes: outcomes.map(projectOutcome) }),
739
+ }
740
+ // `--report` writes stdout. The report ITSELF is machine-clean, but
741
+ // stdout is not vx's alone — the status logger writes there too, so
742
+ // `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
743
+ // meter bar and `::group::` command above the table. `--report-file`
744
+ // is the redirect-free form.
745
+ return parsed.report === 'markdown' ? reported.md : ''
746
+ }
747
+ }
729
748
  // A run executes in THIS process, always. Where an individual task's
730
749
  // command runs is the `executor` capability's business (per task, with
731
750
  // the scheduler, cache, retries and telemetry unchanged above it); there
@@ -735,17 +754,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
735
754
  process.stderr.write(`vx run: ${summary.refused}\n`)
736
755
  return 1
737
756
  }
738
- const result: RunResult = { ok: summary.ok, outcomes: summary.outcomes.map(projectOutcome) }
739
- // Report generation is post-run, gated on the flags — zero cost when
740
- // both are absent. Rendered once, however many sinks asked for it.
741
- if (parsed.report === 'markdown' || parsed.reportFile !== undefined) {
742
- const md = formatRunReportMarkdown(result)
743
- // `--report` writes stdout. The report ITSELF is machine-clean, but
744
- // stdout is not vx's alone — the status logger writes there too, so
745
- // `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
746
- // meter bar and `::group::` command above the table. `--report-file`
747
- // is the redirect-free form.
748
- if (parsed.report === 'markdown') process.stdout.write(md)
757
+ if (wantsReport) {
758
+ const md =
759
+ reported?.outcomes === summary.outcomes
760
+ ? reported.md
761
+ : formatRunReportMarkdown({
762
+ ok: summary.ok,
763
+ outcomes: summary.outcomes.map(projectOutcome),
764
+ })
749
765
  if (parsed.reportFile !== undefined) {
750
766
  const target = path.resolve(cwd, parsed.reportFile)
751
767
  try {
@@ -768,5 +784,5 @@ export async function runCmd(args: readonly string[]): Promise<number> {
768
784
  }
769
785
  }
770
786
  }
771
- return result.ok ? 0 : 1
787
+ return summary.ok ? 0 : 1
772
788
  }
@@ -179,9 +179,14 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
179
179
  // ignored let a pid file in the same window start cycles again. The
180
180
  // refused path is skipped and the rest asked again; a refusal before
181
181
  // any record outside a work tree is git refusing them all.
182
- let rest = paths
182
+ // Git refuses a path beyond a symbolic link: its ignored pid file read
183
+ // as an edit and an uncached task re-ran forever. Asked at its real
184
+ // place, answered as given.
185
+ const real = paths.map(gitSpeller(workspaceRoot))
186
+ let from = 0
183
187
  let inWorkTree: boolean | undefined
184
- while (rest.length > 0) {
188
+ while (from < paths.length) {
189
+ const rest = real.slice(from)
185
190
  let proc: ReturnType<typeof Bun.spawnSync>
186
191
  try {
187
192
  proc = Bun.spawnSync({
@@ -201,8 +206,8 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
201
206
  const fields = new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')
202
207
  const records = Math.floor(fields.length / 4)
203
208
  for (let i = 0; i < records; i++) {
204
- const [source, , pattern, p] = fields.slice(i * 4, i * 4 + 4)
205
- if (source !== '' && !pattern!.startsWith('!')) ignored.add(p!)
209
+ const [source, , pattern] = fields.slice(i * 4, i * 4 + 4)
210
+ if (source !== '' && !pattern!.startsWith('!')) ignored.add(paths[from + i]!)
206
211
  }
207
212
  // 0: some ignored; 1: none. Anything else is git refusing.
208
213
  if (proc.exitCode === 0 || proc.exitCode === 1) return ignored
@@ -216,7 +221,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
216
221
  }).exitCode === 0
217
222
  if (!inWorkTree) return ignored
218
223
  }
219
- rest = rest.slice(records + 1)
224
+ from += records + 1
220
225
  }
221
226
  return ignored
222
227
  }
@@ -351,19 +356,59 @@ export function isWorkspaceConfigFile(name: string): boolean {
351
356
  */
352
357
  const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
353
358
 
359
+ /**
360
+ * Paths as git spells them under `root`: a project reached through a
361
+ * symlink below the root (`packages/x -> ../shared/x`) is watched at the
362
+ * link, and git names and answers for its files only at the real place.
363
+ * The root keeps its own spelling (macOS's `/var` is `/private/var`), the
364
+ * name its own (the file may be gone); a directory outside the root is
365
+ * kept as given. Directories are resolved once per speller.
366
+ */
367
+ export function gitSpeller(root: string): (p: string) => string {
368
+ const dirs = new Map<string, string>()
369
+ let realRoot: string | undefined
370
+ return (p) => {
371
+ const dir = path.dirname(p)
372
+ let at = dirs.get(dir)
373
+ if (at === undefined) {
374
+ at = dir
375
+ try {
376
+ realRoot ??= realPath(root)
377
+ const rel = path.relative(realRoot, realPath(dir))
378
+ if (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)) {
379
+ at = path.join(root, rel)
380
+ }
381
+ } catch {}
382
+ dirs.set(dir, at)
383
+ }
384
+ return path.join(at, path.basename(p))
385
+ }
386
+ }
387
+
354
388
  /**
355
389
  * The files under the workspace git lists, tracked and untracked, ignored
356
390
  * ones aside, and every directory above one (git lists no directory, and
357
391
  * a project moved away whole is one event on its directory): what existed
358
392
  * when watch armed, so a path born and gone since is told from a deletion
359
- * (watch-judge.ts). Undefined when git cannot answer: no inventory, every
360
- * gone path is a deletion as before.
393
+ * (watch-judge.ts). `tracked` is the files git tracks: a user edits those,
394
+ * so a server cannot be blamed for one. Undefined when git cannot answer:
395
+ * no inventory, every gone path is a deletion as before.
361
396
  */
362
- export function gitFiles(workspaceRoot: string): Set<string> | undefined {
397
+ export function gitFiles(
398
+ workspaceRoot: string,
399
+ ): { listed: Set<string>; tracked: Set<string> } | undefined {
363
400
  let proc: ReturnType<typeof Bun.spawnSync>
364
401
  try {
365
402
  proc = Bun.spawnSync({
366
- cmd: [executablePath('git'), 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
403
+ cmd: [
404
+ executablePath('git'),
405
+ 'ls-files',
406
+ '-z',
407
+ '-t',
408
+ '--cached',
409
+ '--others',
410
+ '--exclude-standard',
411
+ ],
367
412
  cwd: workspaceRoot,
368
413
  stdout: 'pipe',
369
414
  stderr: 'ignore',
@@ -373,13 +418,19 @@ export function gitFiles(workspaceRoot: string): Set<string> | undefined {
373
418
  }
374
419
  if (proc.exitCode !== 0) return undefined
375
420
  const files = new Set<string>()
376
- for (const p of new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')) {
377
- if (p.length === 0) continue
421
+ const tracked = new Set<string>()
422
+ for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
423
+ .decode(proc.stdout)
424
+ .split('\0')) {
425
+ if (entry.length === 0) continue
426
+ // `-t` prefixes a tag and a space; `?` is untracked.
427
+ const p = entry.slice(2)
378
428
  // An untracked nested repository is listed as `dir/`.
379
429
  let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
380
430
  files.add(abs)
431
+ if (entry[0] !== '?') tracked.add(abs)
381
432
  for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
382
433
  files.add(abs)
383
434
  }
384
- return files
435
+ return { listed: files, tracked }
385
436
  }
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
  import { xxh3 } from '../util/index.js'
8
- import { gitIgnored } from './watch-filter.js'
8
+ import { gitIgnored, gitSpeller } from './watch-filter.js'
9
9
  import { modifiedBefore } from './watch-fs.js'
10
10
 
11
11
  const ABSENT = -1n
@@ -63,6 +63,8 @@ export interface JudgeContext {
63
63
  fenced?(ownDir: string, abs: string): boolean
64
64
  /** The files git listed at the arm; absent when git could not answer. */
65
65
  existedAtArm?: ReadonlySet<string>
66
+ /** The files git tracked at the arm; absent when git could not answer. */
67
+ trackedAtArm?: ReadonlySet<string>
66
68
  }
67
69
 
68
70
  export class ChangeJudge {
@@ -124,14 +126,14 @@ export class ChangeJudge {
124
126
  // the moment between a judgement and that cycle's keys, and gone by the
125
127
  // next judgement, was read and its deletion re-runs nothing. Git lists
126
128
  // no ignored path, so one of those is a deletion as before.
127
- private sameState(abs: string, ignored = false): boolean {
129
+ private sameState(abs: string, ignored: boolean, spell: (p: string) => string): boolean {
128
130
  const state = settledState(abs)
129
131
  const prev = this.lastState.get(abs)
130
132
  this.lastState.set(abs, state)
131
133
  if (prev !== undefined) return prev === state
132
134
  if (state === ABSENT) {
133
135
  const listed = this.ctx.existedAtArm
134
- return !ignored && listed !== undefined && !listed.has(abs) && !this.inNestedRepo(abs)
136
+ return !ignored && listed !== undefined && !listed.has(spell(abs)) && !this.inNestedRepo(abs)
135
137
  }
136
138
  return modifiedBefore(abs, this.ctx.armedAt)
137
139
  }
@@ -146,13 +148,17 @@ export class ChangeJudge {
146
148
  return false
147
149
  }
148
150
 
149
- private writtenDuringLastCycle(abs: string, openWhileHeld = false): boolean {
151
+ /** `spell` (git's spelling, see `gitSpeller`) given: a held server's write counts outside a cycle too. */
152
+ private writtenDuringLastCycle(abs: string, spell?: (p: string) => string): boolean {
150
153
  // A server the last cycle left running is still that cycle's: its
151
154
  // writes land after the cycle ended, and with a closed window a dev
152
155
  // server that rewrites a log in its project restarted itself forever
153
156
  // with no word of it, 12 restarts in 8 s (item 948). The initial run's
154
- // server is one too, from the arm on.
155
- const open = openWhileHeld && this.ctx.held()
157
+ // server is one too, from the arm on. Not a tracked file: that is the
158
+ // user's edit, and three saves of one source file were told to
159
+ // .gitignore it as a server's write (WD-7).
160
+ const open =
161
+ spell !== undefined && this.ctx.held() && this.ctx.trackedAtArm?.has(spell(abs)) !== true
156
162
  if (this.lastCycle === undefined && !open) return false
157
163
  try {
158
164
  const m = fs.statSync(abs).mtimeMs
@@ -165,6 +171,7 @@ export class ChangeJudge {
165
171
 
166
172
  judge(): string | undefined {
167
173
  const ignored = gitIgnored(this.ctx.workspaceRoot, [...this.pending.keys()])
174
+ const spell = gitSpeller(this.ctx.workspaceRoot)
168
175
  let first: string | undefined
169
176
  let firstAbs: string | undefined
170
177
  // `sameState` before the `first` test, never after it: it is what
@@ -190,7 +197,7 @@ export class ChangeJudge {
190
197
  let gone: [label: string, abs: string] | undefined
191
198
  for (const [p, l] of this.pending) {
192
199
  if (ignored.has(p) && !editForUncached(p)) continue
193
- if (this.sameState(p, ignored.has(p)) || first !== undefined) continue
200
+ if (this.sameState(p, ignored.has(p), spell) || first !== undefined) continue
194
201
  if (!fs.existsSync(p)) {
195
202
  gone ??= [l, p]
196
203
  continue
@@ -201,7 +208,7 @@ export class ChangeJudge {
201
208
  if (first === undefined && gone !== undefined) [first, firstAbs] = gone
202
209
  this.pending.clear()
203
210
  const byServer = firstAbs !== undefined && this.ctx.held()
204
- if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, true)) {
211
+ if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, spell)) {
205
212
  this.streak.n = 0
206
213
  return first
207
214
  }
package/src/cli/watch.ts CHANGED
@@ -103,7 +103,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
103
103
  }
104
104
 
105
105
  const cwd = process.cwd()
106
- const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks)
106
+ const resolved = await resolveRunOptions(parsed, cwd, parsed.tasks, 'watch')
107
107
  if ('error' in resolved) {
108
108
  process.stderr.write(`vx watch: ${resolved.error}\n`)
109
109
  return 1
@@ -471,8 +471,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
471
471
 
472
472
  /** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
473
473
  const armedAt = fsClockNow(cacheDir)
474
- /** What existed at the arm, so a file born and gone since is no deletion (watch-judge.ts). */
475
- const existedAtArm = gitFiles(workspaceRoot)
474
+ /** What existed and was tracked at the arm, so a file born and gone since is no deletion and a tracked one is the user's edit (watch-judge.ts). */
475
+ const atArm = gitFiles(workspaceRoot)
476
476
  /** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
477
477
  const changes = new ChangeJudge({
478
478
  workspaceRoot,
@@ -480,7 +480,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
480
480
  held: () => held !== undefined,
481
481
  uncached: () => uncached,
482
482
  fenced: (ownDir, abs) => fenced(ownDir, abs),
483
- ...(existedAtArm !== undefined ? { existedAtArm } : {}),
483
+ ...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
484
484
  })
485
485
  /** Per-project arms by directory, so `rearm` can add and drop them. */
486
486
  const perProject = new Map<string, WatchHandle>()
package/src/config.ts CHANGED
@@ -292,10 +292,10 @@ export interface SandboxConfig {
292
292
 
293
293
  /** The denials `sandbox.ignore` can leave out of the report: patterns per class. */
294
294
  export interface SandboxIgnore {
295
- read?: string[]
296
- write?: string[]
297
- systemInfo?: string[]
298
- network?: string[]
295
+ read?: readonly string[]
296
+ write?: readonly string[]
297
+ systemInfo?: readonly string[]
298
+ network?: readonly string[]
299
299
  }
300
300
 
301
301
  export interface SandboxGrants {
@@ -304,29 +304,29 @@ export interface SandboxGrants {
304
304
  * `node_modules`, NOT `cache.inputs.files`. A task that reads its own
305
305
  * source in the sandbox says so here.
306
306
  */
307
- read?: string[]
307
+ read?: readonly string[]
308
308
  /**
309
309
  * Writable paths or globs. The task can write NOTHING without these:
310
310
  * declaring `cache.outputs.files` grants no write, and a task whose
311
311
  * only declaration is its outputs writes into a tmpfs that evaporates
312
312
  * (vx then warns that `cache.outputs` matched no files).
313
313
  */
314
- write?: string[]
314
+ write?: readonly string[]
315
315
  /**
316
316
  * Reachable domains, `['*.example.com']` a pattern. The proxy is the
317
317
  * run's: it filters every sandboxed task against the union of the lists
318
318
  * the run's tasks declare, so `true` adds no domain and reaches only
319
319
  * those (none in a run with no list). Omitted adds none either.
320
320
  */
321
- network?: true | string[]
321
+ network?: true | readonly string[]
322
322
  /**
323
323
  * Kernel information the task may query, by info type — the name that
324
324
  * appears in a denial, e.g. `vfs.disk-space` (every Bun and git process
325
325
  * probes free space at startup).
326
326
  */
327
- systemInfo?: string[]
327
+ systemInfo?: readonly string[]
328
328
  /** Unix socket paths the task may connect to. `true` allows any. */
329
- unixSockets?: true | string[]
329
+ unixSockets?: true | readonly string[]
330
330
  /**
331
331
  * Bind and reach localhost ports — a test that boots a server for
332
332
  * itself. A port LIST additionally makes those ports reachable from
@@ -335,9 +335,9 @@ export interface SandboxGrants {
335
335
  * namespace, each listed port is bridged out to the host's loopback;
336
336
  * on macOS the host already sees them and the list means `true`.
337
337
  */
338
- localBinding?: boolean | number[]
338
+ localBinding?: boolean | readonly number[]
339
339
  /** macOS: Mach service names the task may look up. */
340
- machLookup?: string[]
340
+ machLookup?: readonly string[]
341
341
  /** Acquire a TTY. Rare outside interactive tools. */
342
342
  pty?: boolean
343
343
  /** Write to `.git/config`. Most build tools should not. */
@@ -346,7 +346,7 @@ export interface SandboxGrants {
346
346
 
347
347
  export interface SandboxDenials {
348
348
  /** Domains blocked even if `allow.network` would admit them. */
349
- network?: string[]
349
+ network?: readonly string[]
350
350
  }
351
351
 
352
352
  export interface ExecConfig {
@@ -458,7 +458,7 @@ export interface ExecEnv {
458
458
  * folded into the cache key — for things like CI flags, regional
459
459
  * vars, secrets that change between machines without affecting output.
460
460
  */
461
- passThrough?: string[]
461
+ passThrough?: readonly string[]
462
462
  /**
463
463
  * Explicit `name: value` pairs. The values are set on the child AND
464
464
  * folded into the cache key (they are literal in your config, captured
@@ -471,7 +471,7 @@ export interface ExecEnv {
471
471
  * hold `TOKEN`, `SECRET`, `KEY`, `PASSWORD`, `PASSWD` or `CREDENTIAL`
472
472
  * are masked without it; this reaches `GH_PAT`, `NPM_AUTH`.
473
473
  */
474
- secret?: string[]
474
+ secret?: readonly string[]
475
475
  }
476
476
 
477
477
  export interface CacheConfig {
@@ -488,7 +488,7 @@ export interface CacheOutputs {
488
488
  * Outputs are not filtered through gitignore — typical artifact dirs
489
489
  * like `dist/` and `coverage/` are captured normally even when ignored.
490
490
  */
491
- files: string[]
491
+ files: readonly string[]
492
492
  /**
493
493
  * Files produced OUTSIDE the project dir, as workspace-root-relative
494
494
  * globs (the Turbo `$TURBO_ROOT$` / Nx `{workspaceRoot}` escape
@@ -500,7 +500,7 @@ export interface CacheOutputs {
500
500
  * like overlapping `files` (vx cleans declared outputs before a run
501
501
  * and before a restore, so the second would delete the first's).
502
502
  */
503
- workspaceFiles?: string[]
503
+ workspaceFiles?: readonly string[]
504
504
  }
505
505
 
506
506
  export interface CacheInputs {
@@ -514,7 +514,7 @@ export interface CacheInputs {
514
514
  * files are excluded automatically — a task cannot invalidate itself,
515
515
  * and cannot read across project boundaries.
516
516
  */
517
- files: string[]
517
+ files: readonly string[]
518
518
  /**
519
519
  * Workspace-root-relative globs (the Turbo `$TURBO_ROOT$` / Nx
520
520
  * `{workspaceRoot}` equivalent), for inputs that live outside the
@@ -528,7 +528,7 @@ export interface CacheInputs {
528
528
  * Declared outputs (both `outputs` fields) are excluded
529
529
  * automatically (a task cannot invalidate itself).
530
530
  */
531
- workspaceFiles?: string[]
531
+ workspaceFiles?: readonly string[]
532
532
  /**
533
533
  * Env var names whose runtime values from parent `process.env` are
534
534
  * folded into the cache key. **Independent of `exec.env`** — declaring
@@ -536,7 +536,7 @@ export interface CacheInputs {
536
536
  * invalidation. To both forward AND track, list the name in both
537
537
  * `exec.env.passThrough` and here.
538
538
  */
539
- env?: string[]
539
+ env?: readonly string[]
540
540
  /**
541
541
  * Which upstream tasks' cache keys participate in this task's key.
542
542
  * Uses the same Turbo/Nx micro-syntax as `dependsOn`, plus
@@ -565,7 +565,7 @@ export interface CacheInputs {
565
565
  * lock, the OUTPUT is resolved live every run — correct under
566
566
  * `--frozen`. Use for project-specific runtime probes.
567
567
  */
568
- runtime?: string[]
568
+ runtime?: readonly string[]
569
569
  /**
570
570
  * Like `runtime`, but commands run at the WORKSPACE ROOT and are
571
571
  * deduped GLOBALLY per command across the whole run — a `node -v`
@@ -573,7 +573,7 @@ export interface CacheInputs {
573
573
  * analog of `workspaceFiles`: per-task, root-anchored. Use for global
574
574
  * tool versions, OS info, etc.
575
575
  */
576
- workspaceRuntime?: string[]
576
+ workspaceRuntime?: readonly string[]
577
577
  }
578
578
 
579
579
  /**
@@ -26,7 +26,7 @@ interface Proc {
26
26
 
27
27
  /**
28
28
  * Linux's `/proc/<pid>/stat` counts CPU in USER_HZ ticks, fixed at 100 by
29
- * the ABI on every architecture; `tests/proc-sample.test.ts` pins it by
29
+ * the ABI on every architecture; `tests/proc-sample.unsafe.test.ts` pins it by
30
30
  * burning a known span and reading it back.
31
31
  */
32
32
  const TICK_MS = 10
@@ -82,7 +82,7 @@ export function localBindingOn(c: { localBinding?: boolean | readonly number[] }
82
82
  * rest of the host is mounted read-only), or under a read grant, a write
83
83
  * grant or a baseline read, by its canonical path. A path no grant holds
84
84
  * under a denial is not there inside the sandbox, which the shell reports
85
- * as "not found" with no denial a trace can see (an `execve`).
85
+ * as "not found": a PATH search is a stat, which the trace does not stop on.
86
86
  */
87
87
  export function sandboxReads(
88
88
  sandbox: {