@vzn/vx 0.0.635 → 0.0.637

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 (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -21,12 +21,8 @@ import { isUserError, relPosix, secretMask, span, UserError, xxh3hex } from '../
21
21
  import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
22
22
 
23
23
  /**
24
- * Per-run memoization caches for the two derived hashes that don't
25
- * change across tasks within one run:
26
- * - `packageJson`: keyed by absolute projectDir. Every task in a
27
- * project re-reads the same `package.json`; without this, a
28
- * monorepo with N projects × M tasks per project does N×M reads
29
- * of the same bytes.
24
+ * Per-run memoization caches for what doesn't change across tasks within
25
+ * one run:
30
26
  * - `projectFiles`: keyed by the project and its input DECLARATION.
31
27
  * Tasks of one project that declare the same inputs and outputs
32
28
  * resolve the same file list; see `ProjectFilesCache`.
@@ -35,11 +31,10 @@ import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
35
31
  * JSON.stringify + xxh3 of it is deterministic. WeakMap so
36
32
  * entries free when the orchestrator is done.
37
33
  *
38
- * Both fields are optional — the helpers fall back to computing
39
- * fresh when the cache is missing.
34
+ * The whole memo is optional — the helpers fall back to computing
35
+ * fresh when it is missing.
40
36
  */
41
37
  export interface HashCache {
42
- packageJson: Map<string, Promise<string>>
43
38
  taskConfig: WeakMap<TaskConfig, string>
44
39
  runtime: Map<string, Promise<string>>
45
40
  workspaceRuntime: Map<string, Promise<string>>
@@ -49,7 +44,6 @@ export interface HashCache {
49
44
 
50
45
  export function createHashCache(): HashCache {
51
46
  return {
52
- packageJson: new Map(),
53
47
  taskConfig: new WeakMap(),
54
48
  runtime: new Map(),
55
49
  workspaceRuntime: new Map(),
@@ -76,7 +70,6 @@ export interface ComputeHashArgs {
76
70
  node: TaskNode
77
71
  upstream: TaskOutcome[]
78
72
  workspaceRoot: string
79
- workspaceFingerprint: string
80
73
  cache: CacheLayer
81
74
  forwardArgs?: readonly string[] | undefined
82
75
  nestedProjectDirs: string[]
@@ -100,8 +93,7 @@ export interface ComputeHashArgs {
100
93
 
101
94
  /**
102
95
  * Resolve every input the cache key depends on (file content, env
103
- * values, upstream hashes, config bytes, project package.json bytes,
104
- * forwardArgs) and ask the cache layer to combine them. Used by both
96
+ * values, upstream hashes, config bytes, forwardArgs) and ask the cache layer to combine them. Used by both
105
97
  * `executeTask` for the real run and `plan()` for `--dry-run` /
106
98
  * `--graph` previews.
107
99
  *
@@ -161,22 +153,12 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
161
153
  ? []
162
154
  : [{ path: relPosix(input.workspaceRoot, f), digest: digests[i]! }],
163
155
  )
164
- // The package.json digest is a per-run memo that may predate this
165
- // describe, so it is dated from the enumeration, the earliest a run
166
- // learns anything.
167
156
  const indexedAt = args.gitFilesCache?.enumeratedAtMs ?? 0
168
157
  const facts: InputFact[] = sorted.map((f, i) => ({
169
158
  path: f,
170
159
  digest: digests[i]!,
171
160
  since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
172
161
  }))
173
- if (input.projectPackageJsonHash !== '') {
174
- facts.push({
175
- path: path.join(args.node.projectDir, 'package.json'),
176
- digest: input.projectPackageJsonHash,
177
- since: indexedAt,
178
- })
179
- }
180
162
  // Groups expanded: this list describes the INPUT ROOT, not the key, and a
181
163
  // group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
182
164
  // Sorted by hash so the list — and any action digest derived from it — is
@@ -220,9 +202,7 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
220
202
  get upstream() {
221
203
  return (upstream ??= listUpstream())
222
204
  },
223
- packageJsonDigest: input.projectPackageJsonHash,
224
205
  configDigest: input.taskConfigHash,
225
- workspaceFingerprint: input.workspaceFingerprint,
226
206
  },
227
207
  }
228
208
  }
@@ -238,15 +218,17 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
238
218
  * input changed and changed BACK while the command ran matched its digest
239
219
  * again, and the entry saved the other content's output under the key (item
240
220
  * 1015). A write before it — an upstream's — is judged by content, as before.
241
- * `writesByBytes` (`cache.rewrittenInputs: 'compare'`) judges a write
242
- * during the command by content too: the task says the same bytes are its
243
- * own identical rewrite (tsdown's package.json), not an edit undone.
221
+ * `byBytes` (`inputStability.strategy: 'mutable'`) judges a write during
222
+ * the command by content too, when it answers true for that file and its
223
+ * ctime: the task says the same bytes are its own identical rewrite
224
+ * (tsdown's package.json), not an edit undone, unless another command that
225
+ * may write the file ran until then (command-windows.ts).
244
226
  */
245
227
  export async function movedInput(
246
228
  facts: readonly InputFact[],
247
229
  cache: CacheLayer,
248
230
  commandFrom?: number,
249
- writesByBytes = false,
231
+ byBytes?: (file: string, ctimeMs: number) => boolean,
250
232
  ): Promise<string | undefined> {
251
233
  const suspects: InputFact[] = []
252
234
  for (const f of facts) {
@@ -264,7 +246,7 @@ export async function movedInput(
264
246
  // an even one, FAT's tick), one during the command included
265
247
  // (`racyWindowMs`).
266
248
  if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
267
- if (!writesByBytes) return f.path
249
+ if (byBytes?.(f.path, ctimeMs) !== true) return f.path
268
250
  suspects.push(f)
269
251
  continue
270
252
  }
@@ -344,12 +326,6 @@ async function resolveKeyInput(
344
326
  }
345
327
  }
346
328
  const taskConfigHash = hashTaskConfig(cfg, args.hashCache)
347
- const projectPackageJsonHash = await hashProjectPackageJson(
348
- args.node.projectDir,
349
- args.cache,
350
- args.hashCache,
351
- fileHashes,
352
- )
353
329
 
354
330
  // Args after `--` reach a requested command; the default build (a keyed
355
331
  // group) runs none, and folded, they moved every key that folds its own.
@@ -359,7 +335,6 @@ async function resolveKeyInput(
359
335
  const input: CacheKeyInput = {
360
336
  taskId: args.node.id,
361
337
  taskConfigHash,
362
- projectPackageJsonHash,
363
338
  envValues: resolved.envValues,
364
339
  runtimeValues: resolved.runtimeValues,
365
340
  workspaceRuntimeValues: resolved.workspaceRuntimeValues,
@@ -368,7 +343,6 @@ async function resolveKeyInput(
368
343
  upstreamHashes,
369
344
  upstreamIds,
370
345
  upstreamGraft,
371
- workspaceFingerprint: args.workspaceFingerprint,
372
346
  forwardArgs: effectiveForwardArgs,
373
347
  ...(fileHashes !== undefined ? { fileHashes } : {}),
374
348
  ...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
@@ -462,48 +436,3 @@ export function computeGroupHash(upstream: TaskOutcome[]): string {
462
436
  .join('|')
463
437
  return xxh3hex(`group|${ids}`)
464
438
  }
465
-
466
- /**
467
- * Hash the project's `package.json` bytes. Folded into every task's
468
- * cache key in that project so dep changes (devDependencies, scripts,
469
- * version bumps) invalidate the project's tasks even when
470
- * `cache.inputs.files` is narrow and doesn't cover the file.
471
- *
472
- * Matches Turbo and Nx's "implicit dependencies" behavior. Returns
473
- * '' for the edge case of a project without a package.json (impossible
474
- * in practice — workspace discovery requires one).
475
- *
476
- * Two optimizations: routed through `Cache.hashFile` so the mtime+size
477
- * fast path applies (an unchanged pkg.json takes a stat + SQLite SELECT
478
- * instead of a full file read). And memoized within-run per projectDir
479
- * so a monorepo with 100 projects × 3 tasks each does 100 lookups, not
480
- * 300.
481
- */
482
- async function hashProjectPackageJson(
483
- projectDir: string,
484
- cache: CacheLayer,
485
- hashCache?: HashCache,
486
- fileHashes?: ReadonlyMap<string, string>,
487
- ): Promise<string> {
488
- const cached = hashCache?.packageJson.get(projectDir)
489
- if (cached !== undefined) return cached
490
- const promise = doHashProjectPackageJson(projectDir, cache, fileHashes)
491
- hashCache?.packageJson.set(projectDir, promise)
492
- return promise
493
- }
494
-
495
- async function doHashProjectPackageJson(
496
- projectDir: string,
497
- cache: CacheLayer,
498
- fileHashes?: ReadonlyMap<string, string>,
499
- ): Promise<string> {
500
- const filePath = path.join(projectDir, 'package.json')
501
- // Clean tracked package.json: its index OID is already in hand —
502
- // no exists probe, no stat, no SQLite.
503
- const oid = fileHashes?.get(filePath)
504
- if (oid !== undefined) return oid
505
- if (!(await Bun.file(filePath).exists())) return ''
506
- // Route through the cache layer's mtime+size fast path so the
507
- // typical re-run sees a stat + SQLite lookup instead of a file read.
508
- return cache.hashFile(filePath)
509
- }
@@ -191,11 +191,11 @@ export interface TaskTelemetry {
191
191
  /** Executor-reported placement (a worker id) — absent for local runs.
192
192
  * Additive-optional, no schema bump (the `attempts` precedent). */
193
193
  where?: string
194
- /** `'deferred'` when the outputs stayed remote (`--download=none`).
194
+ /** `'deferred'` when the outputs stayed remote (`--cache-download=none`).
195
195
  * Additive-optional, same no-bump precedent as `where`. */
196
196
  outputs?: 'deferred'
197
197
  /** Total attempts when the task RETRIED (>1) — set only when `exec.retries`
198
- * / `--retry` produced more than one attempt. A retried-then-passed task is
198
+ * / `--retries` produced more than one attempt. A retried-then-passed task is
199
199
  * flaky by definition; this is the telemetry-side flaky signal. */
200
200
  attempts?: number
201
201
  /**
@@ -340,7 +340,7 @@ export interface RunSummaryRecord {
340
340
  exitOk: boolean
341
341
  tasks: readonly TaskTelemetry[]
342
342
  /**
343
- * The run's stages as `VX_TIMING` marks them (startup, load configs,
343
+ * The run's stages as `VX_DEBUG_TIMING` marks them (startup, load configs,
344
344
  * classify + probe, run graph, …), each a wall window in epoch ms. The
345
345
  * stages ahead of the run lock end before `startedAt`. Additive.
346
346
  */
@@ -0,0 +1,417 @@
1
+ // Every vx setting, once: its path is its `.vx/workspace` key, and its
2
+ // environment variable and flag are derived from that path. Parsing,
3
+ // validation, `vx info`'s settings view and the docs' settings tables all
4
+ // read this list (`tests/settings.test.ts` holds src and docs to it).
5
+
6
+ import {
7
+ machineParallelism,
8
+ MAX_TIMEOUT_MS,
9
+ parseDecimalInt,
10
+ parseDuration,
11
+ parseSize,
12
+ UserError,
13
+ } from './util/index.js'
14
+
15
+ export type SettingKind =
16
+ | { readonly type: 'int'; readonly min: number }
17
+ | { readonly type: 'concurrency' }
18
+ // `zero`: 0 is a value ("wait for nothing not already done"), not a typo.
19
+ | { readonly type: 'duration'; readonly timer?: true; readonly zero?: true }
20
+ | { readonly type: 'size' }
21
+ | { readonly type: 'bool' }
22
+ | { readonly type: 'enum'; readonly values: readonly string[] }
23
+ | { readonly type: 'string' }
24
+
25
+ export interface Setting {
26
+ /** The `.vx/workspace` path (`run.timeout`); env and flag derive from it. */
27
+ readonly path: string
28
+ readonly kind: SettingKind
29
+ /** The built-in, as the docs print it. */
30
+ readonly default: string
31
+ /** One line for the docs table. */
32
+ readonly doc: string
33
+ /** The verb whose flag sets it (`vx run --timeout`); none → env and config only. */
34
+ readonly flag?: { readonly verb: 'run' | 'watch'; readonly name: string }
35
+ /** The task field that overrides it, and beats every run-level source. */
36
+ readonly task?: string
37
+ /** False: read before `.vx/workspace` loads, so flag and env only. */
38
+ readonly config?: false
39
+ }
40
+
41
+ const SCHEDULES = ['most-work', 'critical-path', 'direct-dependents', 'ready-order'] as const
42
+
43
+ export const SETTINGS: readonly Setting[] = [
44
+ {
45
+ path: 'run.concurrency',
46
+ kind: { type: 'concurrency' },
47
+ default: 'the cores this process may use',
48
+ doc: "Tasks at once: a positive integer, or a share of the cores (`'50%'`).",
49
+ flag: { verb: 'run', name: '--concurrency' },
50
+ },
51
+ {
52
+ path: 'run.schedule',
53
+ kind: { type: 'enum', values: SCHEDULES },
54
+ default: 'most-work',
55
+ doc: 'Which ready task starts first when there are more than workers.',
56
+ flag: { verb: 'run', name: '--schedule' },
57
+ },
58
+ {
59
+ path: 'run.affectedBase',
60
+ kind: { type: 'string' },
61
+ default: 'origin/HEAD, then the trunk, else HEAD~1',
62
+ doc: 'The git ref a bare `--affected` compares with.',
63
+ },
64
+ {
65
+ path: 'run.branch',
66
+ kind: { type: 'string' },
67
+ default: "git's branch, then the CI provider's",
68
+ doc: '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.',
69
+ flag: { verb: 'run', name: '--branch' },
70
+ config: false,
71
+ },
72
+ {
73
+ path: 'run.timeout',
74
+ kind: { type: 'duration', timer: true },
75
+ default: 'none',
76
+ doc: "A task's time limit, for tasks without their own `exec.timeout` (`'30m'`).",
77
+ flag: { verb: 'run', name: '--timeout' },
78
+ task: 'exec.timeout',
79
+ },
80
+ {
81
+ path: 'run.retries',
82
+ kind: { type: 'int', min: 0 },
83
+ default: '0',
84
+ doc: 'Re-runs of a failed task, for tasks without their own `exec.retries`.',
85
+ flag: { verb: 'run', name: '--retries' },
86
+ task: 'exec.retries',
87
+ },
88
+ {
89
+ path: 'run.continue',
90
+ kind: { type: 'enum', values: ['never', 'deps-ok', 'always'] },
91
+ default: 'deps-ok',
92
+ doc: 'What a failed task takes down: `never` stops the run, `deps-ok` skips its dependents, `always` runs them too.',
93
+ flag: { verb: 'run', name: '--continue' },
94
+ },
95
+ {
96
+ path: 'run.outputLogs',
97
+ kind: { type: 'enum', values: ['full', 'errors-only', 'hash-only', 'none'] },
98
+ default: '`full` on CI, else by what the run selects',
99
+ doc: 'Which task output the run prints. Chosen before the workspace loads, so flag and env only.',
100
+ flag: { verb: 'run', name: '--output-logs' },
101
+ config: false,
102
+ },
103
+ {
104
+ path: 'run.killGrace',
105
+ kind: { type: 'duration', timer: true },
106
+ default: '2s',
107
+ doc: 'How long a task that ignores SIGTERM gets before SIGKILL.',
108
+ flag: { verb: 'run', name: '--kill-grace' },
109
+ },
110
+ {
111
+ path: 'run.teardownTimeout',
112
+ kind: { type: 'duration', timer: true, zero: true },
113
+ default: '3s',
114
+ doc: "The bound on each plugin's end-of-run flush and teardown.",
115
+ flag: { verb: 'run', name: '--teardown-timeout' },
116
+ },
117
+ {
118
+ path: 'run.readyNotice',
119
+ kind: { type: 'duration', timer: true },
120
+ default: '10s',
121
+ doc: 'How long a persistent task may take to match `readyWhen` before vx says it waits.',
122
+ flag: { verb: 'run', name: '--ready-notice' },
123
+ },
124
+ {
125
+ path: 'run.rules.exclusiveOutputs',
126
+ kind: { type: 'bool' },
127
+ default: 'true',
128
+ doc: 'Refuse two tasks whose outputs overlap even when an edge orders them.',
129
+ },
130
+ {
131
+ path: 'run.rules.upfrontKeys',
132
+ kind: { type: 'bool' },
133
+ default: 'true',
134
+ doc: "Refuse a task whose inputs can match another task's outputs.",
135
+ },
136
+ {
137
+ path: 'cache.dir',
138
+ kind: { type: 'string' },
139
+ default: '.vx/cache, entries in ~/.vx/<repo>/cache',
140
+ doc: 'Where the cache lives, relative to the workspace root; named, it holds the whole cache.',
141
+ flag: { verb: 'run', name: '--cache-dir' },
142
+ },
143
+ {
144
+ path: 'cache.retention.maxSize',
145
+ kind: { type: 'size' },
146
+ default: 'none',
147
+ doc: "Evict least-recently-used entries at each run's end until the cache is under this (`'10GB'`).",
148
+ },
149
+ {
150
+ path: 'cache.retention.olderThan',
151
+ kind: { type: 'duration' },
152
+ default: 'none',
153
+ doc: "Evict entries unused for this long at each run's end (`'30d'`).",
154
+ },
155
+ {
156
+ path: 'cache.policy',
157
+ kind: { type: 'string' },
158
+ default: 'every layer reads and writes',
159
+ doc: "Which layers a run reads and writes (`'local:rw,remote:r'`); `--no-cache` and `--force` apply on top.",
160
+ flag: { verb: 'run', name: '--cache-policy' },
161
+ },
162
+ {
163
+ path: 'cache.scope',
164
+ kind: { type: 'string' },
165
+ default: 'none (the default keys)',
166
+ doc: "Where cache writes land (`'pr-123'`): reads try the default key, then the scope's.",
167
+ flag: { verb: 'run', name: '--cache-scope' },
168
+ },
169
+ {
170
+ path: 'cache.download',
171
+ kind: { type: 'enum', values: ['all', 'toplevel', 'none'] },
172
+ default: 'all',
173
+ doc: 'Which remote hits land on disk: every one, only the requested tasks, or none.',
174
+ flag: { verb: 'run', name: '--cache-download' },
175
+ },
176
+ {
177
+ path: 'cache.inputStability.strategy',
178
+ kind: { type: 'enum', values: ['immutable', 'mutable', 'unchecked'] },
179
+ default: 'immutable',
180
+ doc: 'What a write to an input during the command means for the save, for tasks without their own.',
181
+ task: 'cache.inputStability.strategy',
182
+ },
183
+ {
184
+ path: 'cache.inputStability.severity',
185
+ kind: { type: 'enum', values: ['warn', 'error'] },
186
+ default: 'warn',
187
+ doc: 'Whether a skipped save warns or fails the task, for tasks without their own.',
188
+ task: 'cache.inputStability.severity',
189
+ },
190
+ {
191
+ path: 'cache.outputs.clean',
192
+ kind: { type: 'bool' },
193
+ default: 'true',
194
+ doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
195
+ task: 'cache.outputs.clean',
196
+ },
197
+ {
198
+ path: 'lock.reviewFiles',
199
+ kind: { type: 'bool' },
200
+ default: 'true',
201
+ doc: "`vx lock` writes each project's resolved config, pretty, to `vx.lock.json` beside its `vx.config`, for review; vx never reads it.",
202
+ },
203
+ {
204
+ path: 'watch.poll',
205
+ kind: { type: 'bool' },
206
+ default: 'false',
207
+ doc: 'Poll for changes from the start instead of the OS watcher.',
208
+ flag: { verb: 'watch', name: '--poll' },
209
+ },
210
+ {
211
+ path: 'config.workerTimeout',
212
+ kind: { type: 'duration', timer: true },
213
+ default: '30s',
214
+ doc: 'How long one config evaluation may take.',
215
+ config: false,
216
+ },
217
+ {
218
+ path: 'debug.timing',
219
+ kind: { type: 'bool' },
220
+ default: 'false',
221
+ doc: "Print the run's stage timings to stderr.",
222
+ config: false,
223
+ },
224
+ {
225
+ path: 'debug.dumpable',
226
+ kind: { type: 'bool' },
227
+ default: 'false',
228
+ doc: 'Linux: keep vx dumpable, so strace or a debugger can read its memory and `/proc/<pid>/fd`, and an unsandboxed task its env.',
229
+ config: false,
230
+ },
231
+ ]
232
+
233
+ /** The `.vx/workspace` groups that hold settings; `plugins` is the other key. */
234
+ export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
235
+
236
+ const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
237
+
238
+ export function setting(path: string): Setting {
239
+ return BY_PATH.get(path)!
240
+ }
241
+
242
+ /** `run.killGrace` → `VX_RUN_KILL_GRACE`. */
243
+ export function envName(path: string): string {
244
+ return `VX_${path
245
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
246
+ .replace(/\./g, '_')
247
+ .toUpperCase()}`
248
+ }
249
+
250
+ /** What a value of this kind looks like, for an error. */
251
+ export function kindHint(kind: SettingKind): string {
252
+ switch (kind.type) {
253
+ case 'int':
254
+ return kind.min === 0 ? 'a non-negative integer' : `an integer ≥ ${kind.min}`
255
+ case 'concurrency':
256
+ return "a positive integer, or a share of the cores like '50%'"
257
+ case 'duration':
258
+ // A timer past 2^31-1 ms fires at once, so "no limit" is no value.
259
+ return kind.timer === true
260
+ ? "a duration like '500ms', '30s' or '5m', at most 24 days"
261
+ : "a duration like '45s', '12h' or '30d'"
262
+ case 'size':
263
+ return "a size like '500MB' or '10GB'"
264
+ case 'bool':
265
+ return 'true or false'
266
+ case 'enum':
267
+ return `one of ${kind.values.map((v) => `'${v}'`).join(', ')}`
268
+ case 'string':
269
+ return 'a non-blank string'
270
+ }
271
+ }
272
+
273
+ /**
274
+ * A positive decimal integer, or `<n>%` of the cores this process may use
275
+ * (`50%` on eight cores is 4, never below 1; over 100% is allowed for
276
+ * I/O-bound work; a container's CPU quota caps the count the way it caps
277
+ * the default), resolved here so the run sees one number and the summary
278
+ * says what it ran with.
279
+ */
280
+ export function parseConcurrency(v: unknown, cpus = machineParallelism()): number | null {
281
+ if (typeof v !== 'string') return null
282
+ const pct = /^(\d+)%$/.exec(v)
283
+ if (pct) {
284
+ const n = parseDecimalInt(pct[1]!)
285
+ if (n === null || n < 1) return null
286
+ return Math.max(1, Math.round((Math.max(1, cpus) * n) / 100))
287
+ }
288
+ const n = parseDecimalInt(v)
289
+ return n === null || n < 1 ? null : n
290
+ }
291
+
292
+ /**
293
+ * A setting's value, or null when `raw` is not one. Durations come back in
294
+ * ms, sizes in bytes and a share of the cores as a count; the rest as typed.
295
+ */
296
+ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
297
+ switch (kind.type) {
298
+ case 'int': {
299
+ const n =
300
+ typeof raw === 'number' ? raw : typeof raw === 'string' ? parseDecimalInt(raw) : null
301
+ return n !== null && Number.isSafeInteger(n) && n >= kind.min ? n : null
302
+ }
303
+ case 'concurrency':
304
+ return parseConcurrency(typeof raw === 'number' ? String(raw) : raw)
305
+ case 'duration': {
306
+ const n = typeof raw === 'string' ? parseDuration(raw) : null
307
+ if (n === null || (n === 0 && kind.zero !== true)) return null
308
+ return kind.timer === true && n > MAX_TIMEOUT_MS ? null : n
309
+ }
310
+ case 'size':
311
+ return typeof raw === 'string' ? parseSize(raw) : null
312
+ case 'bool':
313
+ if (typeof raw === 'boolean') return raw
314
+ return raw === 'true' || raw === '1' ? true : raw === 'false' || raw === '0' ? false : null
315
+ case 'enum':
316
+ return typeof raw === 'string' && kind.values.includes(raw) ? raw : null
317
+ case 'string':
318
+ return typeof raw === 'string' && raw.trim() !== '' ? raw : null
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Why `raw` from `where` (a variable, a flag, a config field) is refused
324
+ * for the setting at `path`; `got` is `raw` as the message quotes it. A
325
+ * bare number for a duration names the unit it lacks, and a timer past the
326
+ * platform's range says it would fire at once rather than never.
327
+ */
328
+ export function settingRefusal(
329
+ path: string,
330
+ where: string,
331
+ raw: unknown,
332
+ got = String(raw),
333
+ ): string {
334
+ const { kind } = setting(path)
335
+ if (kind.type === 'duration') {
336
+ const n =
337
+ typeof raw === 'number'
338
+ ? raw
339
+ : typeof raw === 'string' && /^\d+$/.test(raw)
340
+ ? Number(raw)
341
+ : null
342
+ if (n !== null && Number.isSafeInteger(n) && n > 0) {
343
+ return `${where} takes a duration with its unit, like '${n}ms' or '${Math.max(1, Math.round(n / 1000))}s' (got ${got})`
344
+ }
345
+ const ms = typeof raw === 'string' ? parseDuration(raw) : null
346
+ if (kind.timer === true && ms !== null && ms > MAX_TIMEOUT_MS) {
347
+ return (
348
+ `${where} of ${got} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days): ` +
349
+ 'a longer timer fires at once, it does not mean "no limit"' +
350
+ (path === 'run.timeout' ? ' — omit it for no limit' : '')
351
+ )
352
+ }
353
+ }
354
+ return `${where} must be ${kindHint(kind)} (got ${got})`
355
+ }
356
+
357
+ /** A setting's environment value, parsed; undefined when unset or empty. */
358
+ export function settingEnv(path: string, env: NodeJS.ProcessEnv = process.env): unknown {
359
+ const { kind } = setting(path)
360
+ const name = envName(path)
361
+ const raw = env[name]
362
+ if (raw === undefined || raw === '') return undefined
363
+ const v = parseSettingValue(kind, raw)
364
+ if (v === null) throw new UserError(settingRefusal(path, name, raw), 'VX_E_USAGE')
365
+ return v
366
+ }
367
+
368
+ /** Validate every set setting variable once, so a deep reader can trust what it reads. */
369
+ export function checkSettingsEnv(env: NodeJS.ProcessEnv = process.env): void {
370
+ for (const s of SETTINGS) settingEnv(s.path, env)
371
+ }
372
+
373
+ /** Where a resolved setting came from. */
374
+ export type SettingSource = 'flag' | 'env' | 'workspace' | 'default'
375
+
376
+ /** `config` at `path` (`run.timeout` → `config.run?.timeout`). */
377
+ export function atPath(config: unknown, path: string): unknown {
378
+ let v: unknown = config
379
+ for (const k of path.split('.')) {
380
+ if (v === null || typeof v !== 'object') return undefined
381
+ v = (v as Record<string, unknown>)[k]
382
+ }
383
+ return v
384
+ }
385
+
386
+ /**
387
+ * A run-level setting: the flag's value, else the environment's, else the
388
+ * workspace's, parsed; undefined with its default. A task override is
389
+ * applied where the task runs, above all three.
390
+ */
391
+ export function resolveSetting(
392
+ path: string,
393
+ workspace: unknown,
394
+ flag?: unknown,
395
+ env: NodeJS.ProcessEnv = process.env,
396
+ ): { value: unknown; source: SettingSource } {
397
+ if (flag !== undefined) return { value: flag, source: 'flag' }
398
+ const e = settingEnv(path, env)
399
+ if (e !== undefined) return { value: e, source: 'env' }
400
+ const w = setting(path).config === false ? undefined : atPath(workspace, path)
401
+ if (w !== undefined)
402
+ return { value: parseSettingValue(setting(path).kind, w), source: 'workspace' }
403
+ return { value: undefined, source: 'default' }
404
+ }
405
+
406
+ /** `run.rules`, each from its variable, else the workspace; unset ones stay on. */
407
+ export function runRules(workspace: unknown): {
408
+ exclusiveOutputs?: boolean
409
+ upfrontKeys?: boolean
410
+ } {
411
+ const out: { exclusiveOutputs?: boolean; upfrontKeys?: boolean } = {}
412
+ for (const rule of ['exclusiveOutputs', 'upfrontKeys'] as const) {
413
+ const { value } = resolveSetting(`run.rules.${rule}`, workspace)
414
+ if (value !== undefined) out[rule] = value as boolean
415
+ }
416
+ return out
417
+ }
@@ -26,7 +26,7 @@ export class UserError extends Error {
26
26
 
27
27
  // A refusal that opens with the config that caused it: the schema's
28
28
  // `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
29
- const CONFIG_REFUSAL = /^\/\S*\/vx\.(?:config|workspace)\.[cm]?[jt]s\b/
29
+ const CONFIG_REFUSAL = /^\/\S*\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s\b/
30
30
 
31
31
  /**
32
32
  * The stable code of anything `bin.ts` catches. A UserError's own, but
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Every task gets these (`exec/env.ts`), so `hideEnvFromTasks` leaves
3
+ * them. Exported so the schema doc's enumeration of it can be PINNED against the
4
+ * real list: a hand-copied allowlist is how the two drift, and a reader
5
+ * asking "what does my build script actually see?" is asking a
6
+ * security-shaped question that a stale list answers wrongly.
7
+ */
8
+ export const ESSENTIAL_ENV: readonly string[] = [
9
+ 'PATH',
10
+ 'HOME',
11
+ 'SHELL',
12
+ 'USER',
13
+ 'LOGNAME',
14
+ 'TMPDIR',
15
+ 'TEMP',
16
+ 'TMP',
17
+ 'LANG',
18
+ 'LC_ALL',
19
+ 'LC_CTYPE',
20
+ 'TERM',
21
+ 'COLORTERM',
22
+ 'FORCE_COLOR',
23
+ 'NO_COLOR',
24
+ 'CI',
25
+ 'NODE_OPTIONS',
26
+ // Where corepack and pnpm keep their own binaries, as HOME is: a machine
27
+ // path, never task output. Without them a corepack shim re-downloads its
28
+ // manager (offline or sandboxed: fails) and pnpm loses its global dir.
29
+ 'COREPACK_HOME',
30
+ 'PNPM_HOME',
31
+ ]