@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
package/src/cli/run.ts CHANGED
@@ -1,34 +1,22 @@
1
- import readline from 'node:readline/promises'
1
+ import { appendFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
- import {
4
- affectedProjects,
5
- applyFilters,
6
- buildPackageGraph,
7
- defaultAffectedBase,
8
- findWorkspaceRoot,
9
- listProjects,
10
- loadProjectConfig,
11
- loadWorkspace,
12
- loadWorkspaceConfig,
13
- parseFilter,
14
- resolveCacheDir,
15
- type ProjectMeta,
16
- } from '../workspace/index.js'
3
+ import { documentedFlags, seeHelp } from './help.js'
4
+ import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
17
5
  import {
18
6
  planRun,
19
7
  formatRunReportMarkdown,
20
- optionsToRequest,
21
- resolveBackend as resolvePluginBackend,
8
+ outcomeLabel,
9
+ projectOutcome,
10
+ run as runOrchestrator,
22
11
  type OutcomeView,
23
12
  type RunOptions,
24
13
  type RunResult,
25
- type VxPlugin,
26
14
  } from '../orchestrator/index.js'
27
15
  import type { ContinueMode } from '../graph/index.js'
28
16
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
29
- import { parseSize } from '../util/index.js'
17
+ import { findCwdProject, pickTask, resolveFilters } from './select.js'
18
+ import { MAX_TIMEOUT_MS, parseDecimalInt, nearest, machineParallelism } from '../util/index.js'
30
19
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
31
- import { localBackend } from './backend.js'
32
20
 
33
21
  export interface RunArgs {
34
22
  /** Failure propagation (`--continue[=never|deps-ok|always]`). */
@@ -54,6 +42,8 @@ export interface RunArgs {
54
42
  * / `--force` in precedence order. Defaults to all-on.
55
43
  */
56
44
  cache: CachePolicy
45
+ /** `--cache` named a remote axis (`remote:r`, `:w`, `:rw`): the run says so when no plugin serves one. */
46
+ remoteRequested?: boolean
57
47
  /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
58
48
  cacheDir: string | undefined
59
49
  frozen: boolean
@@ -68,24 +58,8 @@ export interface RunArgs {
68
58
  * env and workspace `timeout` defaults. Undefined when not passed.
69
59
  */
70
60
  timeout: number | undefined
71
- /**
72
- * `--memory <size>` / `--memory=<size>`: memory budget (resolved to
73
- * bytes) for `exec.resources.memory` reservations. Defaults to
74
- * os.totalmem() when not passed — pass it in cgroup-limited containers.
75
- */
76
- memory: number | undefined
77
- /**
78
- * `--verify[=determinism|inputs|fingerprint|all]`: cache-correctness
79
- * verification. Undefined when not passed. `determinism` re-runs and
80
- * content-compares outputs; `inputs` sandboxes with the declared-input
81
- * baseline and flags undeclared reads; `fingerprint` ships output-tree
82
- * fingerprints for the cross-machine diff (no re-run; the determinism
83
- * modes set it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`)
84
- * exempts tasks from failing the run on a divergence.
85
- */
86
- verify: { determinism: boolean; inputs: boolean; fingerprint: boolean } | undefined
87
- verifyAllow: string[]
88
- outputLogs?: 'full' | 'errors-only' | 'none'
61
+ outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
62
+ download?: 'all' | 'toplevel' | 'none'
89
63
  forwardArgs: string[]
90
64
  verbosity: number
91
65
  dry: 'text' | 'json' | undefined
@@ -102,9 +76,39 @@ export interface RunArgs {
102
76
  tags: Record<string, string>
103
77
  /** `--report[=markdown]`. Undefined when the flag wasn't passed. */
104
78
  report: 'markdown' | undefined
79
+ /**
80
+ * `--report-file=<path>`. Undefined when the flag wasn't passed. Writes
81
+ * the same markdown as `--report`, but to a file — because redirecting
82
+ * vx's stdout captures the whole run log, not just the report.
83
+ */
84
+ reportFile: string | undefined
105
85
  error?: string
106
86
  }
107
87
 
88
+ // The camelCase spelling the flag shipped with (retired 2026-09-10): two
89
+ // edits past the suggester's reach, so it is named outright. A regex, not a
90
+ // quoted literal, so the doc-drift test does not count it as an accepted flag.
91
+ const RETIRED_EXCLUDE_DEPENDENCIES = /^--excludeDependencies(=|$)/
92
+
93
+ /**
94
+ * `--concurrency <n>` is a positive decimal integer, or `<n>%` of the
95
+ * cores this process may use (`50%` on eight cores is 4, never below 1;
96
+ * over 100% is allowed for I/O-bound work; a container's CPU quota caps
97
+ * the count the way it caps the default). The percent form resolves
98
+ * here, at parse time, so the run sees one number and the summary says
99
+ * what it ran with.
100
+ */
101
+ export function parseConcurrency(v: string, cpus = machineParallelism()): number | null {
102
+ const pct = /^(\d+)%$/.exec(v)
103
+ if (pct) {
104
+ const n = parseDecimalInt(pct[1]!)
105
+ if (n === null || n < 1) return null
106
+ return Math.max(1, Math.round((Math.max(1, cpus) * n) / 100))
107
+ }
108
+ const n = parseDecimalInt(v)
109
+ return n === null || n < 1 ? null : n
110
+ }
111
+
108
112
  export function parseRunArgs(args: readonly string[]): RunArgs {
109
113
  const out: RunArgs = {
110
114
  tasks: [],
@@ -117,9 +121,6 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
117
121
  frozen: false,
118
122
  retries: undefined,
119
123
  timeout: undefined,
120
- memory: undefined,
121
- verify: undefined,
122
- verifyAllow: [],
123
124
  forwardArgs: [],
124
125
  verbosity: 0,
125
126
  dry: undefined,
@@ -129,6 +130,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
129
130
  affected: undefined,
130
131
  tags: {},
131
132
  report: undefined,
133
+ reportFile: undefined,
132
134
  }
133
135
 
134
136
  const sepIdx = args.indexOf('--')
@@ -146,22 +148,39 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
146
148
 
147
149
  for (let i = 0; i < before.length; i++) {
148
150
  const a = before[i]
149
- if (a === '--filter') {
150
- const v = before[++i]
151
- if (v === undefined) return { ...out, error: `${a} requires a value` }
151
+ if (a === '--filter' || a?.startsWith('--filter=')) {
152
+ const v = a === '--filter' ? before[++i] : a.slice('--filter='.length)
153
+ if (v === undefined || v === '') return { ...out, error: `--filter requires a value` }
152
154
  out.filters.push(v)
153
- } else if (a === '--concurrency') {
154
- const v = before[++i]
155
- if (v === undefined) return { ...out, error: `${a} requires a value` }
156
- const n = Number(v)
157
- if (!Number.isFinite(n) || n < 1) return { ...out, error: `invalid concurrency: ${v}` }
158
- out.concurrency = Math.floor(n)
155
+ } else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
156
+ const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
157
+ if (v === undefined) return { ...out, error: `--concurrency requires a value` }
158
+ const n = parseConcurrency(v)
159
+ if (n === null) return { ...out, error: `invalid concurrency: ${v}` }
160
+ out.concurrency = n
159
161
  } else if (a === '--all') {
160
162
  out.all = true
161
- } else if (a === '--excludeDependencies') {
163
+ } else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
164
+ return {
165
+ ...out,
166
+ error: `unknown flag: ${a} (the flag is --exclude-dependencies)${seeHelp('run')}`,
167
+ }
168
+ } else if (a === '--exclude-dependencies') {
162
169
  out.excludeDependencies = 'all'
163
- } else if (a?.startsWith('--excludeDependencies=')) {
164
- const raw = a.slice('--excludeDependencies='.length)
170
+ } else if (a?.startsWith('--exclude-dependencies=')) {
171
+ const raw = a.slice('--exclude-dependencies='.length)
172
+ // An empty value is genuinely ambiguous — "drop every edge" (the
173
+ // bare flag) and "drop none" (an empty list) are equally defensible
174
+ // readings, and silently picking either does the opposite of what
175
+ // half the callers mean. Reject and name both explicit forms; the
176
+ // sibling value flags (--retry=, --timeout=, --cache-dir=)
177
+ // reject an empty `=` value the same way.
178
+ if (raw === '') {
179
+ return {
180
+ ...out,
181
+ error: `--exclude-dependencies= needs a value — pass bare --exclude-dependencies to drop every dependsOn edge, or omit the flag to keep them`,
182
+ }
183
+ }
165
184
  out.excludeDependencies = raw
166
185
  .split(',')
167
186
  .map((s) => s.trim())
@@ -171,71 +190,70 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
171
190
  } else if (a === '--retry' || a?.startsWith('--retry=')) {
172
191
  const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
173
192
  if (v === undefined) return { ...out, error: `--retry requires a value` }
174
- const n = Number(v)
175
- if (v === '' || !Number.isInteger(n) || n < 0) {
193
+ const n = parseDecimalInt(v)
194
+ if (n === null) {
176
195
  return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
177
196
  }
178
197
  out.retries = n
179
198
  } else if (a === '--timeout' || a?.startsWith('--timeout=')) {
180
199
  const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
181
200
  if (v === undefined) return { ...out, error: `--timeout requires a value` }
182
- const n = Number(v)
183
- if (v === '' || !Number.isInteger(n) || n <= 0) {
184
- return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
185
- }
186
- out.timeout = n
187
- } else if (a === '--memory' || a?.startsWith('--memory=')) {
188
- const v = a === '--memory' ? before[++i] : a.slice('--memory='.length)
189
- if (v === undefined || v === '') return { ...out, error: `--memory requires a value` }
190
- const bytes = parseSize(v)
191
- if (bytes === null || bytes <= 0) {
192
- return { ...out, error: `--memory must be a size like 8GB or 512MB, got: ${v}` }
193
- }
194
- out.memory = bytes
195
- } else if (a === '--verify' || a?.startsWith('--verify=')) {
196
- const what = a === '--verify' ? 'determinism' : a.slice('--verify='.length)
197
- if (what === 'determinism') {
198
- out.verify = { determinism: true, inputs: false, fingerprint: true }
199
- } else if (what === 'inputs') {
200
- out.verify = { determinism: false, inputs: true, fingerprint: false }
201
- } else if (what === 'fingerprint') {
202
- out.verify = { determinism: false, inputs: false, fingerprint: true }
203
- } else if (what === 'all') {
204
- out.verify = { determinism: true, inputs: true, fingerprint: true }
205
- } else {
201
+ const n = parseDecimalInt(v)
202
+ if (n !== null && n > MAX_TIMEOUT_MS) {
203
+ // Not "effectively no limit" — setTimeout reduces a delay past 2^31-1
204
+ // to 1 ms, so this would kill every task the moment it spawned.
206
205
  return {
207
206
  ...out,
208
- error: `--verify must be determinism | inputs | fingerprint | all (or bare --verify), got: ${what}`,
207
+ error:
208
+ `--timeout ${n} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days); ` +
209
+ `larger values are silently reduced to 1 ms. Omit --timeout for no limit.`,
209
210
  }
210
211
  }
211
- } else if (a?.startsWith('--verify-allow=')) {
212
- out.verifyAllow = a
213
- .slice('--verify-allow='.length)
214
- .split(',')
215
- .map((s) => s.trim())
216
- .filter((s) => s.length > 0)
217
- } else if (a === '--output-logs') {
218
- const v = before[++i]
219
- if (v !== 'full' && v !== 'errors-only' && v !== 'none') {
220
- return { ...out, error: `--output-logs must be full, errors-only, or none` }
212
+ if (n === null || n <= 0) {
213
+ return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
214
+ }
215
+ out.timeout = n
216
+ } else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
217
+ const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
218
+ if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
219
+ return { ...out, error: `--output-logs must be full, errors-only, hash-only, or none` }
221
220
  }
222
221
  out.outputLogs = v
222
+ } else if (a === '--download' || a?.startsWith('--download=')) {
223
+ const v = a === '--download' ? before[++i] : a.slice('--download='.length)
224
+ if (v !== 'all' && v !== 'toplevel' && v !== 'none') {
225
+ return { ...out, error: `--download must be all, toplevel, or none` }
226
+ }
227
+ out.download = v
223
228
  } else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
224
229
  const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
225
230
  if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a value` }
231
+ // Unlike every other value flag, a cache dir is an arbitrary
232
+ // string — nothing about its shape rejects a swallowed flag. An
233
+ // unquoted empty shell var (`--cache-dir $EMPTY --force`) would
234
+ // otherwise create a directory literally named `--force` and drop
235
+ // the flag. A path starting with `-` needs the `=` form.
236
+ if (a === '--cache-dir' && v.startsWith('-')) {
237
+ return { ...out, error: `--cache-dir requires a path, got flag: ${v}` }
238
+ }
226
239
  out.cacheDir = v
227
- } else if (a === '--cache') {
228
- const v = before[++i]
229
- if (v === undefined) return { ...out, error: `${a} requires a value` }
230
- try {
231
- cachePolicy = parseCachePolicy(v, cachePolicy)
232
- } catch (err) {
233
- cacheSpecError = err instanceof Error ? err.message : String(err)
240
+ } else if (a === '--cache' || a?.startsWith('--cache=')) {
241
+ const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
242
+ if (v === undefined) return { ...out, error: `--cache requires a value` }
243
+ // A spec with no segment at all applies NOTHING, so `--cache=`
244
+ // (an empty shell var, or someone reaching for "no cache") left
245
+ // all four axes ON — the opposite of the intent. Reject it; an
246
+ // empty flag list for a NAMED layer (`local:`) stays valid and
247
+ // means that layer off.
248
+ if (!v.split(',').some((seg) => seg.trim().length > 0)) {
249
+ return {
250
+ ...out,
251
+ error: `--cache needs a spec like local:r, local:rw, remote:, or local:,remote:rw — pass --no-cache to disable every axis`,
252
+ }
234
253
  }
235
- } else if (a?.startsWith('--cache=')) {
236
- const v = a.slice('--cache='.length)
237
254
  try {
238
255
  cachePolicy = parseCachePolicy(v, cachePolicy)
256
+ if (/remote:[rw]/.test(v)) out.remoteRequested = true
239
257
  } catch (err) {
240
258
  cacheSpecError = err instanceof Error ? err.message : String(err)
241
259
  }
@@ -244,7 +262,14 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
244
262
  } else if (a === '--force') {
245
263
  forceFlag = true
246
264
  } else if (a === '--continue') {
247
- // Bare --continue = 'always' (the Turbo convention).
265
+ // Bare --continue = 'always' (the Turbo convention). Its mode comes
266
+ // with '=' only: `--continue never` would read `never` as a TASK, and
267
+ // the run would then say "No projects declare task(s): never" — true,
268
+ // and no help at all.
269
+ const next = before[i + 1]
270
+ if (next === 'never' || next === 'deps-ok' || next === 'always') {
271
+ return { ...out, error: `--continue takes its mode with '=': --continue=${next}` }
272
+ }
248
273
  out.continueMode = 'always'
249
274
  } else if (a?.startsWith('--continue=')) {
250
275
  const v = a.slice('--continue='.length)
@@ -252,11 +277,11 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
252
277
  return { ...out, error: `--continue must be never, deps-ok, or always` }
253
278
  }
254
279
  out.continueMode = v
255
- } else if (a === '--verbosity') {
256
- const v = before[++i]
257
- if (v === undefined) return { ...out, error: `${a} requires a value` }
258
- const n = Number(v)
259
- if (!Number.isInteger(n) || n < 0) return { ...out, error: `invalid verbosity: ${v}` }
280
+ } else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
281
+ const v = a === '--verbosity' ? before[++i] : a.slice('--verbosity='.length)
282
+ if (v === undefined) return { ...out, error: `--verbosity requires a value` }
283
+ const n = parseDecimalInt(v)
284
+ if (n === null) return { ...out, error: `invalid verbosity: ${v}` }
260
285
  out.verbosity = n
261
286
  } else if (a === '--dry') {
262
287
  out.dry = 'text'
@@ -277,7 +302,11 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
277
302
  } else if (a === '--profile') {
278
303
  out.profile = 'profile.json'
279
304
  } else if (a?.startsWith('--profile=')) {
280
- out.profile = a.slice('--profile='.length)
305
+ // An optional-value flag: an empty `=` means "no value given", so it
306
+ // takes the same documented default as the bare form. Resolving ''
307
+ // against cwd would name the cwd DIRECTORY and die with EISDIR after
308
+ // the whole run — and `--summarize=` already degrades to its default.
309
+ out.profile = a.slice('--profile='.length) || 'profile.json'
281
310
  } else if (a === '--affected') {
282
311
  out.affected = ''
283
312
  } else if (a?.startsWith('--affected=')) {
@@ -289,6 +318,16 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
289
318
  const eq = raw.indexOf('=')
290
319
  if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
291
320
  out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
321
+ } else if (a === '--report-file' || a?.startsWith('--report-file=')) {
322
+ const v = a === '--report-file' ? before[++i] : a.slice('--report-file='.length)
323
+ if (v === undefined || v === '') return { ...out, error: `--report-file requires a path` }
324
+ // Same reasoning as --cache-dir: a path is an arbitrary string, so
325
+ // nothing about its shape rejects a swallowed flag. A path starting
326
+ // with `-` needs the `=` form.
327
+ if (a === '--report-file' && v.startsWith('-')) {
328
+ return { ...out, error: `--report-file requires a path, got flag: ${v}` }
329
+ }
330
+ out.reportFile = v
292
331
  } else if (a === '--report') {
293
332
  out.report = 'markdown'
294
333
  } else if (a?.startsWith('--report=')) {
@@ -298,7 +337,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
298
337
  }
299
338
  out.report = fmt
300
339
  } else if (a !== undefined && a.startsWith('-')) {
301
- return { ...out, error: `unknown flag: ${a}` }
340
+ return { ...out, error: `unknown flag: ${a}${didYouMeanFlag(a)}${seeHelp('run')}` }
302
341
  } else if (a !== undefined) {
303
342
  out.tasks.push(a)
304
343
  }
@@ -351,9 +390,11 @@ export function detectFlow(
351
390
  * honor the same selection semantics (cwd / `--all` / `--filter` /
352
391
  * `--affected` / anchored positionals).
353
392
  *
354
- * Returns either the resolved options or an error message string. The
355
- * caller is responsible for prefixing the message with its subcommand
356
- * name (`vx run: <msg>` / `vx watch: <msg>`) and exiting non-zero.
393
+ * Returns the resolved options, an error message, or `nothingSelected` —
394
+ * the selection legitimately resolved to zero projects (nothing changed
395
+ * since the `--affected` base), which is a clean exit, not a failure. The
396
+ * caller prefixes messages with its subcommand name (`vx run: <msg>` /
397
+ * `vx watch: <msg>`).
357
398
  *
358
399
  * Assumes the caller has already populated `parsed.tasks` (e.g. via
359
400
  * the interactive picker for `vx run`).
@@ -362,7 +403,7 @@ export async function resolveRunOptions(
362
403
  parsed: RunArgs,
363
404
  cwd: string,
364
405
  tasks: readonly string[],
365
- ): Promise<RunOptions | { error: string }> {
406
+ ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
366
407
  for (const t of tasks) {
367
408
  const idx = t.indexOf('#')
368
409
  if (idx >= 0) {
@@ -387,13 +428,32 @@ export async function resolveRunOptions(
387
428
  // Project scope applies to bare task names only. Anchored entries
388
429
  // (pkg#task) resolve directly to their own project regardless.
389
430
  const bareTasks = tasks.filter((t) => !t.includes('#'))
431
+ const anchoredTasks = tasks.filter((t) => t.includes('#'))
390
432
  let projects: string[] | undefined
433
+ let staged: RunOptions['staged']
391
434
  if (bareTasks.length === 0) {
392
435
  projects = undefined
393
436
  } else if (filterStrings.length > 0) {
394
- const resolved = await resolveFilters(cwd, filterStrings)
395
- if (resolved.error) return { error: resolved.error }
396
- projects = resolved.names
437
+ const resolved = await resolveFilters(cwd, filterStrings, {
438
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
439
+ ...(parsed.frozen ? { frozen: true } : {}),
440
+ })
441
+ if ('error' in resolved) return { error: resolved.error }
442
+ if ('empty' in resolved) {
443
+ // "Nothing changed" is a clean exit for the BARE tasks the filter
444
+ // scopes — but it must never cancel an explicitly anchored
445
+ // `pkg#task`, which resolves to its own project regardless of
446
+ // scope. An empty project list keeps the bare tasks unselected
447
+ // while the anchored ones still run.
448
+ if (anchoredTasks.length === 0) return { nothingSelected: resolved.empty }
449
+ process.stderr.write(`vx: ${resolved.empty} — running ${anchoredTasks.join(', ')} only\n`)
450
+ projects = []
451
+ } else {
452
+ projects = resolved.names
453
+ // The graph walk staged every config; the run reuses those entries
454
+ // instead of evaluating and staging them a second time.
455
+ staged = resolved.staged
456
+ }
397
457
  } else if (parsed.all) {
398
458
  projects = undefined
399
459
  } else {
@@ -411,9 +471,11 @@ export async function resolveRunOptions(
411
471
  cwd,
412
472
  tasks: [...tasks],
413
473
  cache: parsed.cache,
474
+ ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
414
475
  flow: detectFlow(parsed),
415
476
  ...(parsed.frozen ? { frozen: true } : {}),
416
477
  ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
478
+ ...(parsed.download !== undefined ? { download: parsed.download } : {}),
417
479
  ...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
418
480
  forwardArgs: parsed.forwardArgs,
419
481
  }
@@ -423,18 +485,10 @@ export async function resolveRunOptions(
423
485
  opts.excludeDependencies = parsed.excludeDependencies
424
486
  }
425
487
  if (projects !== undefined) opts.projects = projects
488
+ if (staged !== undefined) opts.staged = staged
426
489
  if (parsed.retries !== undefined) opts.retries = parsed.retries
427
490
  if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
428
- if (parsed.memory !== undefined) opts.memory = parsed.memory
429
491
  if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
430
- if (parsed.verify !== undefined) {
431
- opts.verify = {
432
- determinism: parsed.verify.determinism,
433
- inputs: parsed.verify.inputs,
434
- fingerprint: parsed.verify.fingerprint,
435
- allow: new Set(parsed.verifyAllow),
436
- }
437
- }
438
492
  if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
439
493
  if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
440
494
  if (parsed.profile !== undefined) opts.profile = parsed.profile
@@ -461,7 +515,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
461
515
  process.stderr.write(`vx run: missing task name (stdin is not a TTY)\n`)
462
516
  return 1
463
517
  }
464
- const picked = await pickTask(cwd)
518
+ const picked = await pickTask(
519
+ cwd,
520
+ {},
521
+ {
522
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
523
+ ...(parsed.frozen ? { frozen: true } : {}),
524
+ },
525
+ )
465
526
  if (!picked) return 1
466
527
  tasks = [`${picked.project}#${picked.task}`]
467
528
  }
@@ -471,6 +532,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
471
532
  process.stderr.write(`vx run: ${resolved.error}\n`)
472
533
  return 1
473
534
  }
535
+ if ('nothingSelected' in resolved) {
536
+ process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
537
+ return 0
538
+ }
474
539
  const opts = resolved
475
540
  // The raw invocation, recorded on the `invocations` row so dashboards
476
541
  // show what was actually run. `args` is everything after `run`.
@@ -480,6 +545,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
480
545
  // graph + probe the cache; the difference is just the formatter.
481
546
  if (parsed.dry !== undefined || parsed.graph !== undefined) {
482
547
  const plan = await planRun(opts)
548
+ if (plan.unresolvedTasks !== undefined && plan.unresolvedTasks.length > 0) {
549
+ process.stderr.write(
550
+ `vx run: no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.\n`,
551
+ )
552
+ return 1
553
+ }
483
554
  if (plan.tasks.length === 0) {
484
555
  process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
485
556
  return 1
@@ -499,134 +570,41 @@ export async function runCmd(args: readonly string[]): Promise<number> {
499
570
  return 0
500
571
  }
501
572
 
502
- // Resolve where this run executes. A plugin's `backend` capability wins
503
- // first (e.g. a backend plugin routes to a local-or-hosted service);
504
- // otherwise core's default is pure in-process. Core names no service —
505
- // delegation is entirely a plugin concern.
506
- const request = optionsToRequest(opts)
507
- const root = await findWorkspaceRoot(cwd)
508
- const workspaceConfig = await loadWorkspaceConfig(root)
509
- const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
510
- const backend = await resolvePluginBackend(
511
- plugins,
512
- {
513
- workspaceRoot: root,
514
- cacheDir: resolveCacheDir(root, workspaceConfig),
515
- warn: (m) => process.stderr.write(`${m}\n`),
516
- request,
517
- },
518
- () => Promise.resolve(localBackend()),
519
- )
520
- const result = await backend.run(request)
573
+ // A run executes in THIS process, always. Where an individual task's
574
+ // command runs is the `executor` capability's business (per task, with
575
+ // the scheduler, cache, retries and telemetry unchanged above it); there
576
+ // is no whole-run delegation seam to consult.
577
+ const summary = await runOrchestrator(opts)
578
+ const result: RunResult = { ok: summary.ok, outcomes: summary.outcomes.map(projectOutcome) }
521
579
  if (parsed.verbosity > 0) printSummary(result)
522
- // Report generation is post-run, gated on the flag — zero cost when
523
- // absent. Goes to stdout (NOT the status logger) so it stays
524
- // machine-clean for `vx run ci --report=markdown >> $GITHUB_STEP_SUMMARY`.
525
- if (parsed.report === 'markdown') {
526
- process.stdout.write(formatRunReportMarkdown(result))
527
- }
528
- return result.ok ? 0 : 1
529
- }
530
-
531
- async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
532
- const root = await findWorkspaceRoot(cwd)
533
- const ws = await loadWorkspace(root)
534
- return await listProjects(ws)
535
- }
536
-
537
- async function findCwdProject(cwd: string): Promise<string | null> {
538
- const projects = await loadWorkspaceProjects(cwd)
539
- const abs = path.resolve(cwd)
540
- let best: ProjectMeta | null = null
541
- for (const p of projects) {
542
- if (abs === p.dir || abs.startsWith(p.dir + path.sep)) {
543
- if (!best || p.dir.length > best.dir.length) best = p
544
- }
545
- }
546
- return best?.name ?? null
547
- }
548
-
549
- async function resolveFilters(
550
- cwd: string,
551
- raw: string[],
552
- ): Promise<{ names?: string[]; error?: string }> {
553
- const root = await findWorkspaceRoot(cwd)
554
- const projects = await loadWorkspaceProjects(cwd)
555
- const graph = buildPackageGraph(projects)
556
- const parsed = raw.map((r) => parseFilter(r, root))
557
-
558
- // Resolve every `[<since>]` filter against git before the pure
559
- // applyFilters pass runs. One spawn per distinct ref — usually
560
- // there's only one anyway.
561
- const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
562
- for (const f of parsed) {
563
- if (f.gitSince === undefined) continue
564
- try {
565
- const names = await affectedProjects({ workspaceRoot: root, since: f.gitSince, projects })
566
- affectedByFilter.set(f, names)
567
- } catch (err) {
568
- const msg = err instanceof Error ? err.message : String(err)
569
- return { error: msg }
570
- }
571
- }
572
-
573
- const selected = applyFilters({ filters: parsed, projects, graph, affectedByFilter })
574
- if (selected.size === 0) {
575
- return { error: `no projects matched filter(s): ${raw.join(', ')}` }
576
- }
577
- return { names: [...selected].sort() }
578
- }
579
-
580
- interface PickedTask {
581
- project: string
582
- task: string
583
- description?: string
584
- }
585
-
586
- export async function pickTask(
587
- cwd: string,
588
- io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
589
- ): Promise<PickedTask | null> {
590
- const projects = await loadWorkspaceProjects(cwd)
591
- const entries: PickedTask[] = []
592
- for (const meta of projects) {
593
- if (!meta.configPath) continue
594
- const config = await loadProjectConfig(meta.configPath)
595
- const taskNames = Object.keys(config.tasks ?? {}).sort()
596
- for (const t of taskNames) {
597
- const desc = config.tasks?.[t]?.description
598
- entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
599
- }
600
- }
601
- if (entries.length === 0) {
602
- process.stderr.write(`vx run: no tasks declared in any project\n`)
603
- return null
604
- }
605
- const out = io.output ?? process.stdout
606
- const numW = String(entries.length).length
607
- const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
608
- out.write('Tasks:\n')
609
- entries.forEach((e, i) => {
610
- const n = String(i + 1).padStart(numW, ' ')
611
- const id = `${e.project}#${e.task}`.padEnd(idW)
612
- const desc = e.description ? ` ${e.description}` : ''
613
- out.write(` ${n}. ${id}${desc}\n`)
614
- })
615
- const rl = readline.createInterface({
616
- input: io.input ?? process.stdin,
617
- output: io.output ?? process.stdout,
618
- })
619
- try {
620
- const answer = (await rl.question(`Pick a task [1-${entries.length}]: `)).trim()
621
- const n = Number(answer)
622
- if (!Number.isInteger(n) || n < 1 || n > entries.length) {
623
- process.stderr.write(`vx run: invalid selection: ${answer}\n`)
624
- return null
580
+ // Report generation is post-run, gated on the flags — zero cost when
581
+ // both are absent. Rendered once, however many sinks asked for it.
582
+ if (parsed.report === 'markdown' || parsed.reportFile !== undefined) {
583
+ const md = formatRunReportMarkdown(result)
584
+ // `--report` writes stdout. The report ITSELF is machine-clean, but
585
+ // stdout is not vx's alone — the status logger writes there too, so
586
+ // `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
587
+ // meter bar and `::group::` command above the table. `--report-file`
588
+ // is the redirect-free form.
589
+ if (parsed.report === 'markdown') process.stdout.write(md)
590
+ if (parsed.reportFile !== undefined) {
591
+ const target = path.resolve(cwd, parsed.reportFile)
592
+ try {
593
+ // APPEND, not overwrite: the flag exists for `$GITHUB_STEP_SUMMARY`,
594
+ // which GitHub documents as append-only and which other steps in the
595
+ // same job also write to. Truncating it would silently destroy their
596
+ // content, and would make replacing the documented `>>` recipe a
597
+ // behaviour change rather than a drop-in.
598
+ await appendFile(target, md)
599
+ } catch (err) {
600
+ // Same contract as --summarize / --profile: the run already
601
+ // happened, so a write failure is reported, not fatal.
602
+ const message = err instanceof Error ? err.message : String(err)
603
+ process.stderr.write(`vx: failed to write report to ${target}: ${message}\n`)
604
+ }
625
605
  }
626
- return entries[n - 1] ?? null
627
- } finally {
628
- rl.close()
629
606
  }
607
+ return result.ok ? 0 : 1
630
608
  }
631
609
 
632
610
  function printSummary(summary: RunResult): void {
@@ -658,26 +636,22 @@ function printSummary(summary: RunResult): void {
658
636
  }
659
637
 
660
638
  function formatRow(o: OutcomeView): { task: string; status: string; duration: string } {
661
- // Same outcome vocabulary as the framed blocks + summary:
662
- // executed / restored-local / restored-remote / up-to-date /
663
- // failed / skipped.
664
- const status =
665
- o.status === 'cache-hit'
666
- ? o.restored === false
667
- ? 'up-to-date'
668
- : 'restored-local'
669
- : o.status === 'cache-hit-remote'
670
- ? o.restored === false
671
- ? 'up-to-date'
672
- : 'restored-remote'
673
- : o.status === 'success'
674
- ? 'executed'
675
- : o.status === 'failed'
676
- ? `failed (exit ${o.exitCode})`
677
- : o.status
678
- return {
679
- task: o.taskId,
680
- status,
681
- duration: `${o.durationMs}ms`,
682
- }
639
+ return { task: o.taskId, status: outcomeLabel(o), duration: `${o.durationMs}ms` }
640
+ }
641
+
642
+ /** `(did you mean --concurrency?)` for a flag within two edits of a documented one. */
643
+ function didYouMeanFlag(flag: string): string {
644
+ const name = flag.replace(/=.*$/, '')
645
+ const flags = documentedFlags('run')
646
+ // The usual two edits; a third only between flags that share their
647
+ // first five characters, so `--retries` reaches `--retry` (the i/y)
648
+ // while `--zzz` does not reach `--all` — a plain three-edit budget did.
649
+ const best =
650
+ nearest(name, flags) ??
651
+ nearest(
652
+ name,
653
+ flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
654
+ 3,
655
+ )
656
+ return best === undefined ? '' : ` (did you mean ${best}?)`
683
657
  }