@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/run.ts CHANGED
@@ -1,34 +1,27 @@
1
- import readline from 'node:readline/promises'
2
- import { appendFile } from 'node:fs/promises'
1
+ import { appendFile, mkdir } from 'node:fs/promises'
3
2
  import path from 'node:path'
4
- import { documentedFlags } from './help.js'
5
- import {
6
- affectedProjects,
7
- applyFilters,
8
- buildPackageGraph,
9
- defaultAffectedBase,
10
- findWorkspaceRoot,
11
- listProjects,
12
- loadProjectConfig,
13
- loadWorkspace,
14
- parseFilter,
15
- readLockfile,
16
- workspaceGlobsMatch,
17
- type ProjectMeta,
18
- } from '../workspace/index.js'
3
+ import { documentedFlags, seeHelp } from './help.js'
4
+ import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
19
5
  import {
20
6
  planRun,
21
7
  formatRunReportMarkdown,
8
+ outcomeLabel,
22
9
  projectOutcome,
23
10
  run as runOrchestrator,
24
11
  type OutcomeView,
25
12
  type RunOptions,
26
13
  type RunResult,
27
14
  } from '../orchestrator/index.js'
28
- import type { ProjectConfig } from '../config.js'
29
15
  import type { ContinueMode } from '../graph/index.js'
30
16
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
31
- import { MAX_TIMEOUT_MS, parseDecimalInt, parseSize, editDistance } from '../util/index.js'
17
+ import { findCwdProject, pickTask, resolveFilters } from './select.js'
18
+ import {
19
+ MAX_TIMEOUT_MS,
20
+ isUserError,
21
+ parseDecimalInt,
22
+ nearest,
23
+ machineParallelism,
24
+ } from '../util/index.js'
32
25
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
33
26
 
34
27
  export interface RunArgs {
@@ -55,6 +48,8 @@ export interface RunArgs {
55
48
  * / `--force` in precedence order. Defaults to all-on.
56
49
  */
57
50
  cache: CachePolicy
51
+ /** `--cache` named a remote axis (`remote:r`, `:w`, `:rw`): the run says so when no plugin serves one. */
52
+ remoteRequested?: boolean
58
53
  /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
59
54
  cacheDir: string | undefined
60
55
  frozen: boolean
@@ -69,23 +64,6 @@ export interface RunArgs {
69
64
  * env and workspace `timeout` defaults. Undefined when not passed.
70
65
  */
71
66
  timeout: number | undefined
72
- /**
73
- * `--memory <size>` / `--memory=<size>`: memory budget (resolved to
74
- * bytes) for `exec.resources.memory` reservations. Defaults to
75
- * os.totalmem() when not passed — pass it in cgroup-limited containers.
76
- */
77
- memory: number | undefined
78
- /**
79
- * `--verify[=determinism|inputs|fingerprint|all]`: cache-correctness
80
- * verification. Undefined when not passed. `determinism` re-runs and
81
- * content-compares outputs; `inputs` sandboxes with the declared-input
82
- * baseline and flags undeclared reads; `fingerprint` ships output-tree
83
- * fingerprints for the cross-machine diff (no re-run; the determinism
84
- * modes set it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`)
85
- * exempts tasks from failing the run on a divergence.
86
- */
87
- verify: { determinism: boolean; inputs: boolean; fingerprint: boolean } | undefined
88
- verifyAllow: string[]
89
67
  outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
90
68
  download?: 'all' | 'toplevel' | 'none'
91
69
  forwardArgs: string[]
@@ -113,6 +91,30 @@ export interface RunArgs {
113
91
  error?: string
114
92
  }
115
93
 
94
+ // The camelCase spelling the flag shipped with (retired 2026-09-10): two
95
+ // edits past the suggester's reach, so it is named outright. A regex, not a
96
+ // quoted literal, so the doc-drift test does not count it as an accepted flag.
97
+ const RETIRED_EXCLUDE_DEPENDENCIES = /^--excludeDependencies(=|$)/
98
+
99
+ /**
100
+ * `--concurrency <n>` is a positive decimal integer, or `<n>%` of the
101
+ * cores this process may use (`50%` on eight cores is 4, never below 1;
102
+ * over 100% is allowed for I/O-bound work; a container's CPU quota caps
103
+ * the count the way it caps the default). The percent form resolves
104
+ * here, at parse time, so the run sees one number and the summary says
105
+ * what it ran with.
106
+ */
107
+ export function parseConcurrency(v: string, cpus = machineParallelism()): number | null {
108
+ const pct = /^(\d+)%$/.exec(v)
109
+ if (pct) {
110
+ const n = parseDecimalInt(pct[1]!)
111
+ if (n === null || n < 1) return null
112
+ return Math.max(1, Math.round((Math.max(1, cpus) * n) / 100))
113
+ }
114
+ const n = parseDecimalInt(v)
115
+ return n === null || n < 1 ? null : n
116
+ }
117
+
116
118
  export function parseRunArgs(args: readonly string[]): RunArgs {
117
119
  const out: RunArgs = {
118
120
  tasks: [],
@@ -125,9 +127,6 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
125
127
  frozen: false,
126
128
  retries: undefined,
127
129
  timeout: undefined,
128
- memory: undefined,
129
- verify: undefined,
130
- verifyAllow: [],
131
130
  forwardArgs: [],
132
131
  verbosity: 0,
133
132
  dry: undefined,
@@ -162,25 +161,30 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
162
161
  } else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
163
162
  const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
164
163
  if (v === undefined) return { ...out, error: `--concurrency requires a value` }
165
- const n = parseDecimalInt(v)
166
- if (n === null || n < 1) return { ...out, error: `invalid concurrency: ${v}` }
164
+ const n = parseConcurrency(v)
165
+ if (n === null) return { ...out, error: `invalid concurrency: ${v}` }
167
166
  out.concurrency = n
168
167
  } else if (a === '--all') {
169
168
  out.all = true
170
- } else if (a === '--excludeDependencies') {
169
+ } else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
170
+ return {
171
+ ...out,
172
+ error: `unknown flag: ${a} (the flag is --exclude-dependencies)${seeHelp('run')}`,
173
+ }
174
+ } else if (a === '--exclude-dependencies') {
171
175
  out.excludeDependencies = 'all'
172
- } else if (a?.startsWith('--excludeDependencies=')) {
173
- const raw = a.slice('--excludeDependencies='.length)
176
+ } else if (a?.startsWith('--exclude-dependencies=')) {
177
+ const raw = a.slice('--exclude-dependencies='.length)
174
178
  // An empty value is genuinely ambiguous — "drop every edge" (the
175
179
  // bare flag) and "drop none" (an empty list) are equally defensible
176
180
  // readings, and silently picking either does the opposite of what
177
181
  // half the callers mean. Reject and name both explicit forms; the
178
- // sibling value flags (--retry=, --timeout=, --memory=, --cache-dir=)
182
+ // sibling value flags (--retry=, --timeout=, --cache-dir=)
179
183
  // reject an empty `=` value the same way.
180
184
  if (raw === '') {
181
185
  return {
182
186
  ...out,
183
- error: `--excludeDependencies= needs a value — pass bare --excludeDependencies to drop every dependsOn edge, or omit the flag to keep them`,
187
+ error: `--exclude-dependencies= needs a value — pass bare --exclude-dependencies to drop every dependsOn edge, or omit the flag to keep them`,
184
188
  }
185
189
  }
186
190
  out.excludeDependencies = raw
@@ -215,42 +219,6 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
215
219
  return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
216
220
  }
217
221
  out.timeout = n
218
- } else if (a === '--memory' || a?.startsWith('--memory=')) {
219
- const v = a === '--memory' ? before[++i] : a.slice('--memory='.length)
220
- if (v === undefined || v === '') return { ...out, error: `--memory requires a value` }
221
- const bytes = parseSize(v)
222
- if (bytes === null || bytes <= 0) {
223
- return { ...out, error: `--memory must be a size like 8GB or 512MB, got: ${v}` }
224
- }
225
- out.memory = bytes
226
- } else if (a === '--verify' || a?.startsWith('--verify=')) {
227
- const what = a === '--verify' ? 'determinism' : a.slice('--verify='.length)
228
- if (what === 'determinism') {
229
- out.verify = { determinism: true, inputs: false, fingerprint: true }
230
- } else if (what === 'inputs') {
231
- out.verify = { determinism: false, inputs: true, fingerprint: false }
232
- } else if (what === 'fingerprint') {
233
- out.verify = { determinism: false, inputs: false, fingerprint: true }
234
- } else if (what === 'all') {
235
- out.verify = { determinism: true, inputs: true, fingerprint: true }
236
- } else {
237
- return {
238
- ...out,
239
- error: `--verify must be determinism | inputs | fingerprint | all (or bare --verify), got: ${what}`,
240
- }
241
- }
242
- } else if (a === '--verify-allow' || a?.startsWith('--verify-allow=')) {
243
- const v = a === '--verify-allow' ? before[++i] : a.slice('--verify-allow='.length)
244
- if (v === undefined) return { ...out, error: `--verify-allow requires a value` }
245
- // A flag-shaped space-form value is always a lost flag (task ids
246
- // never start with `-`), never a value the user meant.
247
- if (a === '--verify-allow' && v.startsWith('-')) {
248
- return { ...out, error: `--verify-allow requires a value, got flag: ${v}` }
249
- }
250
- out.verifyAllow = v
251
- .split(',')
252
- .map((s) => s.trim())
253
- .filter((s) => s.length > 0)
254
222
  } else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
255
223
  const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
256
224
  if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
@@ -291,6 +259,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
291
259
  }
292
260
  try {
293
261
  cachePolicy = parseCachePolicy(v, cachePolicy)
262
+ if (/remote:[rw]/.test(v)) out.remoteRequested = true
294
263
  } catch (err) {
295
264
  cacheSpecError = err instanceof Error ? err.message : String(err)
296
265
  }
@@ -299,7 +268,14 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
299
268
  } else if (a === '--force') {
300
269
  forceFlag = true
301
270
  } else if (a === '--continue') {
302
- // Bare --continue = 'always' (the Turbo convention).
271
+ // Bare --continue = 'always' (the Turbo convention). Its mode comes
272
+ // with '=' only: `--continue never` would read `never` as a TASK, and
273
+ // the run would then say "No projects declare task(s): never" — true,
274
+ // and no help at all.
275
+ const next = before[i + 1]
276
+ if (next === 'never' || next === 'deps-ok' || next === 'always') {
277
+ return { ...out, error: `--continue takes its mode with '=': --continue=${next}` }
278
+ }
303
279
  out.continueMode = 'always'
304
280
  } else if (a?.startsWith('--continue=')) {
305
281
  const v = a.slice('--continue='.length)
@@ -367,7 +343,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
367
343
  }
368
344
  out.report = fmt
369
345
  } else if (a !== undefined && a.startsWith('-')) {
370
- return { ...out, error: `unknown flag: ${a}${didYouMeanFlag(a)}` }
346
+ return { ...out, error: `unknown flag: ${a}${didYouMeanFlag(a)}${seeHelp('run')}` }
371
347
  } else if (a !== undefined) {
372
348
  out.tasks.push(a)
373
349
  }
@@ -389,11 +365,21 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
389
365
  if (out.dry !== undefined && out.graph !== undefined) {
390
366
  return { ...out, error: '--dry and --graph are mutually exclusive' }
391
367
  }
392
- if (out.dry !== undefined && (out.summarize !== undefined || out.profile !== undefined)) {
393
- return { ...out, error: '--dry skips execution; --summarize / --profile need a real run' }
368
+ // Every flag that promises a file or a section about the run: the
369
+ // planning branch returns before any of them is written, and
370
+ // `--report-file="$GITHUB_STEP_SUMMARY" --dry` exited 0 having written
371
+ // nothing (item 992).
372
+ const needsRun =
373
+ out.summarize !== undefined ||
374
+ out.profile !== undefined ||
375
+ out.report !== undefined ||
376
+ out.reportFile !== undefined
377
+ const runOnly = '--summarize / --profile / --report / --report-file need a real run'
378
+ if (out.dry !== undefined && needsRun) {
379
+ return { ...out, error: `--dry skips execution; ${runOnly}` }
394
380
  }
395
- if (out.graph !== undefined && (out.summarize !== undefined || out.profile !== undefined)) {
396
- return { ...out, error: '--graph skips execution; --summarize / --profile need a real run' }
381
+ if (out.graph !== undefined && needsRun) {
382
+ return { ...out, error: `--graph skips execution; ${runOnly}` }
397
383
  }
398
384
  return out
399
385
  }
@@ -445,14 +431,28 @@ export async function resolveRunOptions(
445
431
  }
446
432
  }
447
433
 
448
- // `--affected[=<base>]` is sugar for `--filter '[<base>]'`. Merging
449
- // it into the filter list means the same code path handles plain
450
- // filter use, --affected alone, and the combo.
434
+ // `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
435
+ // changed projects AND their dependents — what a CI gate must run, what
436
+ // the flag's name says, what the guides promised while the sugar was the
437
+ // changed-only `[<base>]` (item 287). `--filter '[<base>]'` stays the
438
+ // "only what I touched" form. Merging it into the filter list means the
439
+ // same code path handles plain filter use, --affected alone, and the combo.
440
+ // It goes FIRST: filters apply in argv order, and appended last it came
441
+ // after every `!` exclude, so `--affected --filter '!app'` still ran app
442
+ // (item 955). An include's place does not change a union.
451
443
  const filterStrings = [...parsed.filters]
452
444
  if (parsed.affected !== undefined) {
453
445
  const root = await findWorkspaceRoot(cwd)
454
- const base = parsed.affected === '' ? await defaultAffectedBase(root) : parsed.affected
455
- filterStrings.push(`[${base}]`)
446
+ let base = parsed.affected
447
+ if (base === '') {
448
+ try {
449
+ base = await defaultAffectedBase(root)
450
+ } catch (err) {
451
+ if (!isUserError(err)) throw err
452
+ return { error: err.message }
453
+ }
454
+ }
455
+ filterStrings.unshift(`...[${base}]`)
456
456
  }
457
457
 
458
458
  // Project scope applies to bare task names only. Anchored entries
@@ -460,10 +460,14 @@ export async function resolveRunOptions(
460
460
  const bareTasks = tasks.filter((t) => !t.includes('#'))
461
461
  const anchoredTasks = tasks.filter((t) => t.includes('#'))
462
462
  let projects: string[] | undefined
463
+ let staged: RunOptions['staged']
463
464
  if (bareTasks.length === 0) {
464
465
  projects = undefined
465
466
  } else if (filterStrings.length > 0) {
466
- const resolved = await resolveFilters(cwd, filterStrings)
467
+ const resolved = await resolveFilters(cwd, filterStrings, {
468
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
469
+ ...(parsed.frozen ? { frozen: true } : {}),
470
+ })
467
471
  if ('error' in resolved) return { error: resolved.error }
468
472
  if ('empty' in resolved) {
469
473
  // "Nothing changed" is a clean exit for the BARE tasks the filter
@@ -476,6 +480,9 @@ export async function resolveRunOptions(
476
480
  projects = []
477
481
  } else {
478
482
  projects = resolved.names
483
+ // The graph walk staged every config; the run reuses those entries
484
+ // instead of evaluating and staging them a second time.
485
+ staged = resolved.staged
479
486
  }
480
487
  } else if (parsed.all) {
481
488
  projects = undefined
@@ -494,6 +501,7 @@ export async function resolveRunOptions(
494
501
  cwd,
495
502
  tasks: [...tasks],
496
503
  cache: parsed.cache,
504
+ ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
497
505
  flow: detectFlow(parsed),
498
506
  ...(parsed.frozen ? { frozen: true } : {}),
499
507
  ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
@@ -507,18 +515,10 @@ export async function resolveRunOptions(
507
515
  opts.excludeDependencies = parsed.excludeDependencies
508
516
  }
509
517
  if (projects !== undefined) opts.projects = projects
518
+ if (staged !== undefined) opts.staged = staged
510
519
  if (parsed.retries !== undefined) opts.retries = parsed.retries
511
520
  if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
512
- if (parsed.memory !== undefined) opts.memory = parsed.memory
513
521
  if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
514
- if (parsed.verify !== undefined) {
515
- opts.verify = {
516
- determinism: parsed.verify.determinism,
517
- inputs: parsed.verify.inputs,
518
- fingerprint: parsed.verify.fingerprint,
519
- allow: new Set(parsed.verifyAllow),
520
- }
521
- }
522
522
  if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
523
523
  if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
524
524
  if (parsed.profile !== undefined) opts.profile = parsed.profile
@@ -545,7 +545,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
545
545
  process.stderr.write(`vx run: missing task name (stdin is not a TTY)\n`)
546
546
  return 1
547
547
  }
548
- const picked = await pickTask(cwd)
548
+ const picked = await pickTask(
549
+ cwd,
550
+ {},
551
+ {
552
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
553
+ ...(parsed.frozen ? { frozen: true } : {}),
554
+ },
555
+ )
549
556
  if (!picked) return 1
550
557
  tasks = [`${picked.project}#${picked.task}`]
551
558
  }
@@ -583,7 +590,17 @@ export async function runCmd(args: readonly string[]): Promise<number> {
583
590
  if (parsed.graph === '') {
584
591
  process.stdout.write(out)
585
592
  } else {
586
- await Bun.write(parsed.graph, out)
593
+ // The graph is the command's one product, so a path it cannot write
594
+ // is exit 1, said in one line as the other output paths say theirs:
595
+ // a directory or an unreachable path printed a raw stack (item 993).
596
+ const target = path.resolve(cwd, parsed.graph)
597
+ try {
598
+ await Bun.write(target, out)
599
+ } catch (err) {
600
+ const message = err instanceof Error ? err.message : String(err)
601
+ process.stderr.write(`vx run: failed to write graph to ${target}: ${message}\n`)
602
+ return 1
603
+ }
587
604
  }
588
605
  } else if (parsed.dry === 'json') {
589
606
  process.stdout.write(formatPlanJson(plan))
@@ -618,6 +635,9 @@ export async function runCmd(args: readonly string[]): Promise<number> {
618
635
  // same job also write to. Truncating it would silently destroy their
619
636
  // content, and would make replacing the documented `>>` recipe a
620
637
  // behaviour change rather than a drop-in.
638
+ // Its directory made first, as `--summarize`, `--profile` and
639
+ // `--graph` make theirs: `nr/r.md` failed alone of the four (item 993).
640
+ await mkdir(path.dirname(target), { recursive: true })
621
641
  await appendFile(target, md)
622
642
  } catch (err) {
623
643
  // Same contract as --summarize / --profile: the run already
@@ -630,182 +650,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
630
650
  return result.ok ? 0 : 1
631
651
  }
632
652
 
633
- /**
634
- * Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
635
- * `orphans` (workspace-relative paths belonging to no project).
636
- *
637
- * Reached ONLY when a changed path belongs to no project, so a run whose every
638
- * change is inside a project never gets here — the scoped-config-loading win
639
- * is untouched for the common case.
640
- *
641
- * `vx-lock.json` FIRST when present: it holds the resolved configs, so CI —
642
- * where `--affected` matters most and the lock is committed — answers with
643
- * zero evaluation. Without a lock this evaluates configs, which is the honest
644
- * cost of the question: nothing cheaper can know which globs a program
645
- * declares. A config that fails to load is skipped rather than failing the
646
- * run, matching the deliberate Turbo-like rule that a broken out-of-scope
647
- * config does not fail a scoped run.
648
- */
649
- async function workspaceGlobOwners(
650
- root: string,
651
- projects: readonly ProjectMeta[],
652
- orphans: readonly string[],
653
- ): Promise<string[]> {
654
- const lock = await readLockfile(root)
655
- const owners: string[] = []
656
- const declaresMatch = (config: ProjectConfig): boolean => {
657
- for (const task of Object.values(config.tasks ?? {})) {
658
- const globs = task.cache?.inputs?.workspaceFiles
659
- if (globs === undefined) continue
660
- if (orphans.some((rel) => workspaceGlobsMatch(globs, rel))) return true
661
- }
662
- return false
663
- }
664
- await Promise.all(
665
- projects.map(async (meta) => {
666
- const locked = lock?.projects[meta.name]
667
- if (locked !== undefined) {
668
- if (declaresMatch(locked.config)) owners.push(meta.name)
669
- return
670
- }
671
- if (meta.configPath === null || meta.configPath === '') return
672
- try {
673
- if (declaresMatch(await loadProjectConfig(meta.configPath))) owners.push(meta.name)
674
- } catch {
675
- // A config that will not load cannot be shown to declare a matching
676
- // glob; surfacing it here would fail a run the loader itself tolerates.
677
- }
678
- }),
679
- )
680
- return owners
681
- }
682
-
683
- async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
684
- const root = await findWorkspaceRoot(cwd)
685
- const ws = await loadWorkspace(root)
686
- return await listProjects(ws)
687
- }
688
-
689
- async function findCwdProject(cwd: string): Promise<string | null> {
690
- const projects = await loadWorkspaceProjects(cwd)
691
- const abs = path.resolve(cwd)
692
- let best: ProjectMeta | null = null
693
- for (const p of projects) {
694
- if (abs === p.dir || abs.startsWith(p.dir + path.sep)) {
695
- if (!best || p.dir.length > best.dir.length) best = p
696
- }
697
- }
698
- return best?.name ?? null
699
- }
700
-
701
- type FilterResolution = { names: string[] } | { error: string } | { empty: string }
702
-
703
- async function resolveFilters(cwd: string, raw: string[]): Promise<FilterResolution> {
704
- const root = await findWorkspaceRoot(cwd)
705
- const projects = await loadWorkspaceProjects(cwd)
706
- const graph = buildPackageGraph(projects)
707
- const parsed = raw.map((r) => parseFilter(r, root))
708
-
709
- // Resolve every `[<since>]` filter against git before the pure
710
- // applyFilters pass runs. One spawn per distinct ref — usually
711
- // there's only one anyway.
712
- const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
713
- for (const f of parsed) {
714
- if (f.gitSince === undefined) continue
715
- try {
716
- const names = await affectedProjects({
717
- workspaceRoot: root,
718
- since: f.gitSince,
719
- projects,
720
- workspaceGlobOwners: (orphans) => workspaceGlobOwners(root, projects, orphans),
721
- })
722
- affectedByFilter.set(f, names)
723
- } catch (err) {
724
- const msg = err instanceof Error ? err.message : String(err)
725
- return { error: msg }
726
- }
727
- }
728
-
729
- const selected = applyFilters({
730
- filters: parsed,
731
- projects,
732
- graph,
733
- affectedByFilter,
734
- // A `[<since>]` selector matching nothing is the ordinary "nothing
735
- // changed" outcome, reported below; only a name/path pattern that
736
- // matched nothing is worth flagging as a probable typo.
737
- onNoMatch: (f) => {
738
- if (f.gitSince === undefined) {
739
- process.stderr.write(`vx: filter "${f.raw}" matched no projects\n`)
740
- }
741
- },
742
- })
743
- if (selected.size === 0) {
744
- // "Nothing changed" is a legitimate outcome, not a typo — a docs-only
745
- // commit must not red `vx run … --affected=origin/main`. Only report an
746
- // error when the user named something concrete that failed to resolve.
747
- const includes = parsed.filter((f) => !f.negate)
748
- if (includes.length > 0 && includes.every((f) => f.gitSince !== undefined)) {
749
- const refs = includes.map((f) => f.gitSince).join(', ')
750
- return { empty: `nothing affected since ${refs}` }
751
- }
752
- return { error: `no projects matched filter(s): ${raw.join(', ')}` }
753
- }
754
- return { names: [...selected].sort() }
755
- }
756
-
757
- interface PickedTask {
758
- project: string
759
- task: string
760
- description?: string
761
- }
762
-
763
- export async function pickTask(
764
- cwd: string,
765
- io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
766
- ): Promise<PickedTask | null> {
767
- const projects = await loadWorkspaceProjects(cwd)
768
- const entries: PickedTask[] = []
769
- for (const meta of projects) {
770
- if (!meta.configPath) continue
771
- const config = await loadProjectConfig(meta.configPath)
772
- const taskNames = Object.keys(config.tasks ?? {}).sort()
773
- for (const t of taskNames) {
774
- const desc = config.tasks?.[t]?.description
775
- entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
776
- }
777
- }
778
- if (entries.length === 0) {
779
- process.stderr.write(`vx run: no tasks declared in any project\n`)
780
- return null
781
- }
782
- const out = io.output ?? process.stdout
783
- const numW = String(entries.length).length
784
- const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
785
- out.write('Tasks:\n')
786
- entries.forEach((e, i) => {
787
- const n = String(i + 1).padStart(numW, ' ')
788
- const id = `${e.project}#${e.task}`.padEnd(idW)
789
- const desc = e.description ? ` ${e.description}` : ''
790
- out.write(` ${n}. ${id}${desc}\n`)
791
- })
792
- const rl = readline.createInterface({
793
- input: io.input ?? process.stdin,
794
- output: io.output ?? process.stdout,
795
- })
796
- try {
797
- const answer = (await rl.question(`Pick a task [1-${entries.length}]: `)).trim()
798
- const n = Number(answer)
799
- if (!Number.isInteger(n) || n < 1 || n > entries.length) {
800
- process.stderr.write(`vx run: invalid selection: ${answer}\n`)
801
- return null
802
- }
803
- return entries[n - 1] ?? null
804
- } finally {
805
- rl.close()
806
- }
807
- }
808
-
809
653
  function printSummary(summary: RunResult): void {
810
654
  const rows = summary.outcomes.map((o) => formatRow(o))
811
655
  if (rows.length === 0) return
@@ -835,41 +679,22 @@ function printSummary(summary: RunResult): void {
835
679
  }
836
680
 
837
681
  function formatRow(o: OutcomeView): { task: string; status: string; duration: string } {
838
- // Same outcome vocabulary as the framed blocks + summary:
839
- // executed / restored-local / restored-remote / up-to-date /
840
- // failed / skipped.
841
- const status =
842
- o.status === 'cache-hit'
843
- ? o.restored === false
844
- ? 'up-to-date'
845
- : 'restored-local'
846
- : o.status === 'cache-hit-remote'
847
- ? o.restored === false
848
- ? 'up-to-date'
849
- : 'restored-remote'
850
- : o.status === 'success'
851
- ? 'executed'
852
- : o.status === 'failed'
853
- ? `failed (exit ${o.exitCode})`
854
- : o.status
855
- return {
856
- task: o.taskId,
857
- status,
858
- duration: `${o.durationMs}ms`,
859
- }
682
+ return { task: o.taskId, status: outcomeLabel(o), duration: `${o.durationMs}ms` }
860
683
  }
861
684
 
862
685
  /** `(did you mean --concurrency?)` for a flag within two edits of a documented one. */
863
686
  function didYouMeanFlag(flag: string): string {
864
687
  const name = flag.replace(/=.*$/, '')
865
- let best: string | undefined
866
- let bestD = 3
867
- for (const f of documentedFlags('run')) {
868
- const d = editDistance(name, f)
869
- if (d < bestD) {
870
- bestD = d
871
- best = f
872
- }
873
- }
874
- return best === undefined || best === name ? '' : ` (did you mean ${best}?)`
688
+ const flags = documentedFlags('run')
689
+ // The usual two edits; a third only between flags that share their
690
+ // first five characters, so `--retries` reaches `--retry` (the i/y)
691
+ // while `--zzz` does not reach `--all` — a plain three-edit budget did.
692
+ const best =
693
+ nearest(name, flags) ??
694
+ nearest(
695
+ name,
696
+ flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
697
+ 3,
698
+ )
699
+ return best === undefined ? '' : ` (did you mean ${best}?)`
875
700
  }