@vzn/vx 0.0.646 → 0.0.648

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
package/src/cli/why.ts CHANGED
@@ -2,24 +2,46 @@
2
2
  // terminal. The runs table names the run-over-run hash change, and
3
3
  // `entry_inputs` (the input-fingerprint moat) names the exact cache-key
4
4
  // components that differ. Read-only over cache.db plus a stat of each
5
- // moved input file — no config evaluation, no re-hash.
5
+ // moved input file, no re-hash. Configs are evaluated only when a moved
6
+ // upstream is a group: it records no run, so the graph names what it gathers.
6
7
 
7
8
  import { Cache } from '../cache/index.js'
8
- import { flagHint, formatValue, seeHelp } from './help.js'
9
- import { splitTaskId } from '../graph/index.js'
9
+ import { anchoredSpecError, flagHint, formatValue, seeHelp } from './help.js'
10
+ import { buildTaskGraph, isGroupTask, splitTaskId } from '../graph/index.js'
10
11
  import {
11
12
  cacheKeyDiff,
12
13
  explainCacheKey,
13
14
  inputWriters,
14
15
  latestRunId,
15
16
  rootCauses,
17
+ upstreamTask,
16
18
  whyDidThisRerun,
17
19
  } from '../history/index.js'
18
20
  import { resolveRunId } from '../orchestrator/index.js'
19
- import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
20
- import { findWorkspaceRoot } from '../workspace/index.js'
21
+ import {
22
+ detectColors,
23
+ ERROR,
24
+ MASKED,
25
+ nearMatches,
26
+ paint,
27
+ printable,
28
+ refusal,
29
+ SUCCESS,
30
+ ui,
31
+ UserError,
32
+ WARN,
33
+ } from '../util/index.js'
34
+ import { runRules } from '../settings.js'
35
+ import { buildPackageGraph, findWorkspaceRoot } from '../workspace/index.js'
21
36
  import { findCwdProject } from './select.js'
22
- import { cliCacheDir, discoverCliProjects, parseCacheDirFlag } from './workspace-config.js'
37
+ import {
38
+ cliCacheDir,
39
+ discoverCliProjects,
40
+ loadCliProjects,
41
+ loadCliWorkspace,
42
+ parseCacheDirFlag,
43
+ rootSpelled,
44
+ } from './workspace-config.js'
23
45
 
24
46
  interface WhyArgs {
25
47
  target?: string
@@ -47,7 +69,8 @@ export const WHAT_TO_DO: Readonly<Record<string, string>> = {
47
69
  config:
48
70
  "the task's evaluated config changed (its vx.config or a file it imports); `vx show <that task>` prints it as it is now",
49
71
  upstream: "a dependency's key moved; `vx why <that task>` says why",
50
- plugin: "a key plugin's material changed; that plugin decides what it folds",
72
+ plugin: "a key plugin's material changed; that plugin decides what goes into the key",
73
+ platform: 'it ran on another platform; each of its `exec.platforms` keeps its own entry',
51
74
  format:
52
75
  'vx keys tasks differently since an upgrade; every cached task misses once, nothing to fix',
53
76
  }
@@ -65,8 +88,13 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
65
88
  // got two different diagnoses depending on how it was typed.
66
89
  if (a === '--run' || a.startsWith('--run=')) {
67
90
  const rv = a === '--run' ? args[++i] : a.slice(6)
68
- if (rv === undefined || rv === '')
69
- return { ...out, error: '--run requires a run id (`vx last --list` shows them)' }
91
+ // A run id is hex: one opening with `-` is the next flag, swallowed.
92
+ const flag = a === '--run' && rv?.startsWith('-') === true ? `got flag ${rv}; ` : ''
93
+ if (rv === undefined || rv === '' || flag !== '')
94
+ return {
95
+ ...out,
96
+ error: `--run requires a run id (${flag}\`vx last --list\` shows them)${seeHelp('why')}`,
97
+ }
70
98
  out.runId = rv
71
99
  continue
72
100
  }
@@ -78,7 +106,7 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
78
106
  }
79
107
  const cd = parseCacheDirFlag(args, i)
80
108
  if (cd !== null) {
81
- if ('error' in cd) return { ...out, error: cd.error }
109
+ if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('why')}` }
82
110
  out.cacheDir = cd.cacheDir
83
111
  i = cd.next
84
112
  continue
@@ -90,6 +118,13 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
90
118
  // Empty is no task: read as a query it matched every recorded one.
91
119
  if (a !== '') out.target = a
92
120
  }
121
+ if (out.target !== undefined) {
122
+ const error = anchoredSpecError(
123
+ out.target,
124
+ (task) => `vx why ${task} finds the project that ran it`,
125
+ )
126
+ if (error !== undefined) return { ...out, error }
127
+ }
93
128
  return out
94
129
  }
95
130
 
@@ -141,6 +176,25 @@ async function resolveTarget(
141
176
  .query('SELECT 1 FROM runs WHERE project = ? AND task = ? ORDER BY id DESC LIMIT 1')
142
177
  .get(project, task)
143
178
  if (hit !== null) return target
179
+ } else {
180
+ // A bare name reads only its own task's rows: the DISTINCT over every
181
+ // pair sorted the whole history (24 of 26 ms at 58k rows), and its list
182
+ // serves only the miss's suggestions below.
183
+ const matches = (
184
+ db.query('SELECT DISTINCT project FROM runs WHERE task = ? ORDER BY project').all(target) as {
185
+ project: string
186
+ }[]
187
+ ).map((r) => `${r.project}#${target}`)
188
+ if (matches.length === 1) return matches[0]!
189
+ if (matches.length > 1) {
190
+ const here = await cwdProject()
191
+ if (here !== null && matches.includes(`${here}#${target}`)) return `${here}#${target}`
192
+ throw new UserError(
193
+ `vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
194
+ matches.map((m) => ` ${m}`).join('\n'),
195
+ 'VX_E_AMBIGUOUS',
196
+ )
197
+ }
144
198
  }
145
199
  const pairs = db
146
200
  .query('SELECT DISTINCT project, task FROM runs ORDER BY project, task')
@@ -162,17 +216,6 @@ async function resolveTarget(
162
216
  'VX_E_NO_HISTORY',
163
217
  )
164
218
  }
165
- const matches = ids.filter((id) => id.endsWith(`#${target}`))
166
- if (matches.length === 1) return matches[0]!
167
- if (matches.length > 1) {
168
- const here = await cwdProject()
169
- if (here !== null && matches.includes(`${here}#${target}`)) return `${here}#${target}`
170
- throw new UserError(
171
- `vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
172
- matches.map((m) => ` ${m}`).join('\n'),
173
- 'VX_E_AMBIGUOUS',
174
- )
175
- }
176
219
  throw new UserError(
177
220
  `vx why: no recorded runs for task "${target}"${suggest(target, ids, invoked)}`,
178
221
  'VX_E_NO_HISTORY',
@@ -181,27 +224,38 @@ async function resolveTarget(
181
224
 
182
225
  const fmtWhen = (ms: number): string => new Date(ms).toISOString()
183
226
 
184
- /** `//#task`, Turbo's root package, as the root project's id: the name discovery gives it (D-39). */
185
- async function rootSpelled(root: string, target: string): Promise<string> {
186
- if (!target.startsWith('//#')) return target
187
- const name = await discoverCliProjects(root).then(
188
- (metas) => metas.find((m) => m.dir === root)?.name,
189
- () => undefined,
190
- )
191
- return name === undefined ? target : `${name}#${target.slice(3)}`
192
- }
193
-
194
227
  /** ` · up-to-date` or ` · restored` on a hit, as the run's summary said it; nothing otherwise. */
195
228
  function restoredWord(r: { restored: boolean | null }): string {
196
229
  return r.restored === null ? '' : r.restored ? ' · restored' : ' · up-to-date'
197
230
  }
198
231
 
232
+ /**
233
+ * One run of the task, the same for `this run` and `previous`. A hit's
234
+ * status (`cache-hit`, `cache-hit-remote`) already names it and its tier,
235
+ * and `up-to-date` / `restored` says what it did to the disk; only an
236
+ * executed run needs the word. A skipped task never ran and has no key: it
237
+ * read `skipped · executed · key ` (item 898).
238
+ */
239
+ function runFacts(r: {
240
+ startedAt: number
241
+ status: string
242
+ cacheHit: boolean | null
243
+ restored: boolean | null
244
+ hash: string
245
+ }): string {
246
+ const ran = r.cacheHit === false && r.status !== 'skipped'
247
+ return (
248
+ `${fmtWhen(r.startedAt)} · ${r.status}${restoredWord(r)}${ran ? ' · executed' : ''}` +
249
+ (r.hash === '' ? ' · no key' : ` · key ${r.hash}`)
250
+ )
251
+ }
252
+
199
253
  export async function whyCmd(args: readonly string[]): Promise<number> {
200
254
  const parsed = parseWhyArgs(args)
201
255
  if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`, 'VX_E_USAGE')
202
256
  if (parsed.target === undefined) {
203
257
  throw new UserError(
204
- 'vx why: <task> required (e.g. vx why app#build, or vx why build)',
258
+ `vx why: <task> required (e.g. vx why app#build, or vx why build)${seeHelp('why')}`,
205
259
  'VX_E_USAGE',
206
260
  )
207
261
  }
@@ -215,7 +269,13 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
215
269
  )
216
270
  let runId: string | null
217
271
  if (parsed.runId !== undefined) {
218
- runId = resolveRunId(db, parsed.runId, 'vx why')
272
+ // A stopped run keeps rows but no header (item 854), and `vx last
273
+ // --log` prints its id, so the whole id is taken from its rows too.
274
+ runId =
275
+ resolveRunId(db, parsed.runId, 'vx why') ??
276
+ (db.query('SELECT 1 FROM runs WHERE run_id = ? LIMIT 1').get(parsed.runId) !== null
277
+ ? parsed.runId
278
+ : null)
219
279
  // An unknown id read as a known run missing the task (X-18).
220
280
  if (runId === null) {
221
281
  throw new UserError(
@@ -232,8 +292,9 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
232
292
  if (parsed.format === 'json') {
233
293
  process.stdout.write(`${JSON.stringify({ taskId, why: null, diff: null, explanation })}\n`)
234
294
  } else {
295
+ const c = detectColors(process.stdout)
235
296
  process.stdout.write(
236
- `${taskId}: recorded runs carry no run id — showing the latest cache entry instead\n` +
297
+ `${ui.inline(ui.id(taskId), c)}: recorded runs carry no run id — showing the latest cache entry instead\n` +
237
298
  (explanation.latestEntry !== null
238
299
  ? ` hash ${explanation.latestEntry.hash} · $ ${explanation.latestEntry.command}\n`
239
300
  : ' (no cache entry either)\n'),
@@ -253,7 +314,15 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
253
314
  // An upstream change is never the cause, only its carrier: follow
254
315
  // each moved dependency in this run down to the tasks whose own
255
316
  // inputs moved, so one `vx why` names the file, not the next task.
256
- const roots = rootCauses(db, runId, taskId, diff.entries)
317
+ // A group records no run: the graph names what it gathers.
318
+ const unrecorded = diff.entries.filter(
319
+ (e) =>
320
+ e.kind === 'upstream' &&
321
+ e.change === 'changed' &&
322
+ !cacheKeyDiff(db, runId, upstreamTask(e.name)).found,
323
+ )
324
+ const groups = unrecorded.length > 0 ? await groupMembers(root, taskId) : new Map()
325
+ const roots = rootCauses(db, runId, taskId, diff.entries, groups)
257
326
  const writers = inputWriters(db, root, taskId, diff.entries, roots)
258
327
 
259
328
  if (parsed.format === 'json') {
@@ -262,24 +331,22 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
262
331
  }
263
332
 
264
333
  const lines: string[] = []
334
+ const c = detectColors(process.stdout)
335
+ const dim = (text: string): string => paint('', text, c, { dim: true })
336
+ const head = (text: string): string => paint('', text, c, { bold: true })
265
337
  const t = why.thisRun!
266
- lines.push(`${taskId} — run ${runId}`)
267
- // A hit's status (`cache-hit`, `cache-hit-remote`) already names it and
268
- // its tier, and `up-to-date` / `restored` says what it did to the disk;
269
- // only an executed run needs the word. A skipped task never ran and has
270
- // no key: it read `skipped · executed · key ` (item 898).
271
- const ran = t.cacheHit === false && t.status !== 'skipped'
272
- lines.push(
273
- ` this run ${fmtWhen(t.startedAt)} · ${t.status}${restoredWord(t)}` +
274
- `${ran ? ' · executed' : ''}${t.hash === '' ? ' · no key' : ` · key ${t.hash}`}`,
275
- )
276
- if (why.previousRun == null) {
277
- lines.push(' previous (none — first recorded run of this task)')
338
+ lines.push(`${ui.inline(ui.id(taskId), c)}${dim(` — run ${runId}`)}`)
339
+ lines.push(` ${dim('this run')} ${runFacts(t)}`)
340
+ if (why.previousRun == null && t.hash === '') {
341
+ // Only keyed runs are compared: a persistent task's tenth run read
342
+ // as its first, and the note saying why went unprinted.
343
+ lines.push(` ${dim('previous')} (none with a cache key)`)
344
+ lines.push(` ${dim('verdict')} ${why.note}`)
345
+ } else if (why.previousRun == null) {
346
+ lines.push(` ${dim('previous')} (none — first recorded run of this task)`)
278
347
  } else {
279
348
  const p = why.previousRun
280
- lines.push(
281
- ` previous ${fmtWhen(p.startedAt)} · ${p.status}${restoredWord(p)} · key ${p.hash}`,
282
- )
349
+ lines.push(` ${dim('previous')} ${runFacts(p)}`)
283
350
  // The key moved and the diff says by what: the verdict names it, so
284
351
  // an env-only change reads as one line, not "inputs differ" above a
285
352
  // table to scan.
@@ -288,9 +355,13 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
288
355
  e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
289
356
  )
290
357
  lines.push(
291
- ` verdict ${
358
+ ` ${dim('verdict')} ${
292
359
  why.hashChanged === true && moved.length > 0
293
- ? `cache key changed: ${moved.slice(0, 3).join(', ')}${moved.length > 3 ? ` and ${moved.length - 3} more` : ''}`
360
+ ? paint(
361
+ WARN,
362
+ `cache key changed: ${moved.slice(0, 3).join(', ')}${moved.length > 3 ? ` and ${moved.length - 3} more` : ''}`,
363
+ c,
364
+ )
294
365
  : why.note
295
366
  }`,
296
367
  )
@@ -299,7 +370,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
299
370
  if (diff.entries.length > 0) {
300
371
  lines.push('')
301
372
  lines.push(
302
- ` what changed (${diff.entries.length} component${diff.entries.length === 1 ? '' : 's'}, ${diff.unchangedCount} unchanged):`,
373
+ ` ${head('what changed')}${dim(` (${diff.entries.length} component${diff.entries.length === 1 ? '' : 's'}, ${diff.unchangedCount} unchanged)`)}:`,
303
374
  )
304
375
  const kindW = Math.max(...diff.entries.map((e) => e.kind.length), 4)
305
376
  for (const e of diff.entries) {
@@ -313,11 +384,11 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
313
384
  ? `- ${e.before}`
314
385
  : `${e.before} → ${e.after}`
315
386
  lines.push(
316
- ` ${e.change.padEnd(7)} ${e.kind.padEnd(kindW)} ${printable(e.name)} ${beforeAfter}`.trimEnd(),
387
+ ` ${paint(CHANGE_HUE[e.change] ?? WARN, e.change.padEnd(7), c)} ${dim(e.kind.padEnd(kindW))} ${printable(e.name)} ${dim(beforeAfter)}`.trimEnd(),
317
388
  )
318
389
  }
319
390
  if (roots.length > 0) {
320
- lines.push('', ' root cause:')
391
+ lines.push('', ` ${head('root cause')}:`)
321
392
  for (const r of roots.slice(0, ROOTS_SHOWN)) {
322
393
  const what = r.entries.map((e) =>
323
394
  e.name === e.kind ? e.kind : `${e.kind} ${printable(e.name)}`,
@@ -329,40 +400,47 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
329
400
  if (roots.length > ROOTS_SHOWN) lines.push(` and ${roots.length - ROOTS_SHOWN} more`)
330
401
  }
331
402
  if (writers.length > 0) {
332
- lines.push('', ' changed during a run:')
403
+ lines.push('', ` ${head('changed during a run')}:`)
333
404
  for (const w of writers) {
334
- lines.push(` ${printable(w.file)} while ${w.writers.join(', ')} ran`)
405
+ lines.push(` ${printable(w.file)} ${dim(`while ${w.writers.join(', ')} ran`)}`)
335
406
  }
336
407
  }
337
408
  const rootKinds = roots.flatMap((r) => r.entries.map((e) => e.kind))
409
+ // An upstream row names the task to ask next: the command, not a
410
+ // placeholder to fill in from the row above. A group records no run,
411
+ // so `vx why` on one refuses: its members are asked instead.
412
+ const upstream = upstreamTodo(
413
+ diff.entries.filter((e) => e.kind === 'upstream'),
414
+ unrecorded,
415
+ )
338
416
  const kinds = [...new Set([...diff.entries.map((e) => e.kind), ...rootKinds])].filter(
339
- (k) => k in WHAT_TO_DO && !(k === 'upstream' && roots.length > 0),
417
+ (k) =>
418
+ k in WHAT_TO_DO && !(k === 'upstream' && (roots.length > 0 || upstream.length === 0)),
340
419
  )
341
420
  if (kinds.length > 0) {
342
- lines.push('', ' what to do:')
343
- // An upstream row names the task to ask next: the command, not a
344
- // placeholder to fill in from the row above.
345
- const upstream = diff.entries.filter((e) => e.kind === 'upstream').map((e) => e.name)
421
+ lines.push('', ` ${head('what to do')}:`)
422
+ // Its own column: a root cause's kind and the `written` row are
423
+ // not in the table above, and overflowed its width.
424
+ const todoW = Math.max(kindW, ...kinds.map((k) => k.length), writers.length > 0 ? 7 : 0)
346
425
  for (const k of kinds) {
426
+ if (k === 'upstream') {
427
+ for (const todo of upstream) lines.push(` ${dim(k.padEnd(todoW))} ${todo}`)
428
+ continue
429
+ }
347
430
  const todo =
348
- k === 'upstream'
349
- ? WHAT_TO_DO[k]!.replace(
350
- '`vx why <that task>`',
351
- upstream.map((n) => `\`vx why ${n}\``).join(', '),
352
- )
353
- : k === 'config'
354
- ? WHAT_TO_DO[k]!.replace('`vx show <that task>`', `\`vx show ${taskId}\``)
355
- : WHAT_TO_DO[k]
356
- lines.push(` ${k.padEnd(kindW)} ${todo}`)
431
+ k === 'config'
432
+ ? WHAT_TO_DO[k]!.replace('`vx show <that task>`', `\`vx show ${taskId}\``)
433
+ : WHAT_TO_DO[k]
434
+ lines.push(` ${dim(k.padEnd(todoW))} ${todo}`)
357
435
  }
358
436
  if (writers.length > 0) {
359
437
  // A task re-writing a file a key reads moves that key every run:
360
438
  // the miss is right, and only moving the write ends it.
361
- lines.push(` ${'written'.padEnd(kindW)} ${WRITTEN_TODO}`)
439
+ lines.push(` ${dim('written'.padEnd(todoW))} ${WRITTEN_TODO}`)
362
440
  }
363
441
  }
364
442
  } else if (why.hashChanged === true) {
365
- lines.push(` detail ${diff.note}`)
443
+ lines.push(` ${dim('detail')} ${diff.note}`)
366
444
  }
367
445
  process.stdout.write(`${lines.join('\n')}\n`)
368
446
  return 0
@@ -373,5 +451,65 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
373
451
 
374
452
  const ROOTS_SHOWN = 5
375
453
 
454
+ const CHANGE_HUE: Record<string, string> = { added: SUCCESS, removed: ERROR, changed: WARN }
455
+
456
+ type DiffEntry = ReturnType<typeof cacheKeyDiff>['entries'][number]
457
+
458
+ /**
459
+ * The upstream advice: a moved key names the task to ask next, a group
460
+ * (no run of its own) the verb that names what it gathers, and an edge
461
+ * added or removed says the graph changed, since no key moved.
462
+ */
463
+ function upstreamTodo(upstream: readonly DiffEntry[], unrecorded: readonly DiffEntry[]): string[] {
464
+ const out: string[] = []
465
+ const groups = new Set(unrecorded.map((e) => e.name))
466
+ const moved = [
467
+ ...new Set(
468
+ upstream
469
+ .filter((e) => e.change === 'changed' && !groups.has(e.name))
470
+ .map((e) => upstreamTask(e.name)),
471
+ ),
472
+ ]
473
+ if (moved.length > 0)
474
+ out.push(
475
+ WHAT_TO_DO['upstream']!.replace(
476
+ '`vx why <that task>`',
477
+ moved.map((n) => `\`vx why ${n}\``).join(', '),
478
+ ),
479
+ )
480
+ for (const g of groups)
481
+ out.push(
482
+ `${g} is a group and records no run; \`vx show ${g}\` names what it gathers, \`vx why\` on the one whose key moved says why`,
483
+ )
484
+ const edges = upstream.filter((e) => e.change !== 'changed')
485
+ if (edges.length > 0)
486
+ out.push(
487
+ `a dependency joined or left the graph (${edges.map((e) => `${e.name} ${e.change}`).join(', ')}): its \`dependsOn\` or a project edge changed, which re-keys the task by design`,
488
+ )
489
+ return out
490
+ }
491
+
492
+ /**
493
+ * Each group in `taskId`'s graph with the tasks it gathers. Empty when a
494
+ * config no longer loads: the advice then names the group to `vx show`.
495
+ */
496
+ async function groupMembers(root: string, taskId: string): Promise<Map<string, string[]>> {
497
+ try {
498
+ const metas = await discoverCliProjects(root)
499
+ const projects = await loadCliProjects(root, metas, 'all', { closure: true })
500
+ const [project, task] = splitTaskId(taskId)
501
+ const nodes = buildTaskGraph({
502
+ projects,
503
+ packageGraph: buildPackageGraph(metas),
504
+ requested: [{ project, task }],
505
+ workspaceRoot: root,
506
+ rules: runRules((await loadCliWorkspace(root)).workspaceConfig),
507
+ })
508
+ return new Map([...nodes.values()].filter(isGroupTask).map((n) => [n.id, n.deps]))
509
+ } catch {
510
+ return new Map()
511
+ }
512
+ }
513
+
376
514
  const WRITTEN_TODO =
377
515
  'a task that writes a file a key reads re-keys it every run; give that write its own task with the file as an output, or keep the file out of inputs.files'
@@ -30,25 +30,29 @@ interface CliWorkspace {
30
30
  /** What a plugin's `config` stage said of the branch: its cache scope and base commit. */
31
31
  branch: BranchFacts
32
32
  cacheDir: string
33
- /** The shared store a run opens `cacheDir` with (`resolveStoreRoot`), asked on load. */
34
- storeRoot: Promise<string | null>
33
+ /**
34
+ * The shared store a run opens `cacheDir` with (`resolveStoreRoot`), asked
35
+ * on first call: with no remote, the answer is a git spawn, and a verb that
36
+ * never opens the store (help, show, last) paid it.
37
+ */
38
+ storeRoot: () => Promise<string | null>
35
39
  }
36
40
 
37
41
  export const warnToStderr = (message: string): void => {
38
42
  sayLine('warn', message)
39
43
  }
40
44
 
41
- export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
42
- const { workspaceConfig, plugins, branch } = await loadWorkspacePlugins(
43
- workspaceRoot,
44
- warnToStderr,
45
- )
45
+ export async function loadCliWorkspace(
46
+ workspaceRoot: string,
47
+ warn: (message: string) => void = warnToStderr,
48
+ ): Promise<CliWorkspace> {
49
+ const { workspaceConfig, plugins, branch } = await loadWorkspacePlugins(workspaceRoot, warn)
46
50
  return {
47
51
  workspaceConfig,
48
52
  plugins,
49
53
  branch,
50
54
  cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig),
51
- storeRoot: resolveStoreRoot(workspaceRoot, workspaceConfig),
55
+ storeRoot: () => resolveStoreRoot(workspaceRoot, workspaceConfig),
52
56
  }
53
57
  }
54
58
 
@@ -57,9 +61,35 @@ export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorksp
57
61
  * plugin `discover` stage, with the cache dir the run uses. No lock is
58
62
  * `NO_LOCK`.
59
63
  */
60
- export async function discoverCliProjects(root: string): Promise<ProjectMeta[]> {
61
- const { plugins, cacheDir } = await loadCliWorkspace(root)
62
- return await discoverProjects(root, lockReader(root), plugins, cacheDir, warnToStderr)
64
+ export async function discoverCliProjects(
65
+ root: string,
66
+ load: CliLoadOptions = {},
67
+ ): Promise<ProjectMeta[]> {
68
+ const warn = load.warn ?? warnToStderr
69
+ const ws = await loadCliWorkspace(root, warn)
70
+ return await discoverProjects(root, lockReader(root), ws.plugins, stageCacheDir(ws, load), warn)
71
+ }
72
+
73
+ /**
74
+ * The cache dir a plugin stage is told: `--cache-dir` resolved against the
75
+ * working directory as the run resolves it (prepare.ts), else the
76
+ * workspace's. The run reuses what these stages answered.
77
+ */
78
+ export function stageCacheDir(ws: { cacheDir: string }, load: CliLoadOptions): string {
79
+ return load.cacheDir === undefined ? ws.cacheDir : path.resolve(process.cwd(), load.cacheDir)
80
+ }
81
+
82
+ /**
83
+ * `//#task`, Turbo's root package, as the root project's id: the name
84
+ * discovery gives it (D-39), for a verb that reads history by task id.
85
+ */
86
+ export async function rootSpelled(root: string, target: string): Promise<string> {
87
+ if (!target.startsWith('//#')) return target
88
+ const name = await discoverCliProjects(root).then(
89
+ (metas) => metas.find((m) => m.dir === root)?.name,
90
+ () => undefined,
91
+ )
92
+ return name === undefined ? target : `${name}#${target.slice(3)}`
63
93
  }
64
94
 
65
95
  /**
@@ -93,7 +123,9 @@ export function parseCacheDirFlag(
93
123
  */
94
124
  export function namedCacheDir(override: string): string {
95
125
  const dir = path.resolve(process.cwd(), override)
96
- if (!existsSync(dir)) throw new UserError(`--cache-dir ${override}: no such directory (${dir})`)
126
+ if (!existsSync(dir)) {
127
+ throw new UserError(`--cache-dir ${override}: no such directory (${dir})`, 'VX_E_USAGE')
128
+ }
97
129
  return dir
98
130
  }
99
131
 
@@ -120,6 +152,12 @@ export interface CliLoadOptions {
120
152
  * `build` there, and a reader that skipped it showed less than a run.
121
153
  */
122
154
  closure?: boolean
155
+ /**
156
+ * Where a plugin's warning goes, stderr by default. `vx watch` re-reads
157
+ * the configs around every cycle, and the cycle's run says each warning
158
+ * itself: printed per read, one cycle said it seven times.
159
+ */
160
+ warn?: (message: string) => void
123
161
  }
124
162
 
125
163
  /**
@@ -134,9 +172,10 @@ export async function loadCliProjects(
134
172
  scope: 'all' | readonly string[] = 'all',
135
173
  opts: CliLoadOptions = {},
136
174
  ): Promise<Map<string, ProjectEntry>> {
137
- const ws = await loadCliWorkspace(workspaceRoot)
175
+ const warn = opts.warn ?? warnToStderr
176
+ const ws = await loadCliWorkspace(workspaceRoot, warn)
138
177
  const { plugins } = ws
139
- const cacheDir = opts.cacheDir ?? ws.cacheDir
178
+ const cacheDir = stageCacheDir(ws, opts)
140
179
  const loaded = await loadProjects({
141
180
  workspaceRoot,
142
181
  cacheDir,
@@ -147,7 +186,7 @@ export async function loadCliProjects(
147
186
  ? { closure: true as const, packageGraph: buildPackageGraph([...metas]) }
148
187
  : { closure: false as const }),
149
188
  configs: lockOfDiscovery(metas) ?? lockReader(workspaceRoot),
150
- warn: warnToStderr,
189
+ warn,
151
190
  })
152
191
  return loaded.projects
153
192
  }
package/src/config.ts CHANGED
@@ -6,6 +6,11 @@
6
6
  */
7
7
  export type Duration = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
8
8
 
9
+ /** The machines a task can name in `exec.platforms`: `os-arch`, as Bun's compile targets name them. */
10
+ export const PLATFORMS = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64'] as const
11
+
12
+ export type Platform = (typeof PLATFORMS)[number]
13
+
9
14
  /**
10
15
  * The workspace: settings in groups (`run`, `cache`, `lock`, `watch`) and the
11
16
  * plugins. Each setting's environment variable and flag derive from its
@@ -329,7 +334,7 @@ export interface TaskConfig {
329
334
  * INVALIDATES a task, `allow` says what it may TOUCH, and deriving one
330
335
  * from the other coupled them both ways — a declaration added for
331
336
  * caching silently widened the sandbox, and a path the task needed had
332
- * to be laundered through the cache key to get it (owner, 2026-09-05).
337
+ * to be laundered through the cache key to get it (2026-09-05).
333
338
  * A declared `cache.outputs` is NOT a write grant; the request
334
339
  * `sandbox-request.ts` builds carries no write of its own and binds
335
340
  * `allow.write` alone. This comment claimed the opposite — here and on
@@ -464,6 +469,13 @@ export interface ExecConfig {
464
469
  * state the machine has, exactly as they did before the field existed.
465
470
  */
466
471
  remote?: boolean | 'only'
472
+ /**
473
+ * The machines this task can run on, any of them. On one that is not
474
+ * listed the task is skipped, never failed, and so are its dependants;
475
+ * an executor that routes by platform (`TaskExecutor.platforms`) runs
476
+ * it on a worker that is. Omitted → anywhere.
477
+ */
478
+ platforms?: readonly Platform[]
467
479
  /** Environment exposed to the child process. */
468
480
  env?: ExecEnv
469
481
  /**
package/src/exec/env.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { ESSENTIAL_ENV } from '../util/index.js'
3
+ import { ESSENTIAL_ENV, withoutWorkspaceBins } from '../util/index.js'
4
4
 
5
5
  // Build the environment exposed to a task.
6
6
  //
@@ -34,6 +34,8 @@ export interface BuildEnvOptions {
34
34
  * One holding `path.delimiter` is left out: PATH cannot name it.
35
35
  */
36
36
  binPaths?: readonly string[]
37
+ /** When set, the PATH taken from `source` drops each `node_modules/.bin` inside it. */
38
+ workspaceRoot?: string
37
39
  }
38
40
 
39
41
  export function buildIsolatedEnv(opts: BuildEnvOptions): NodeJS.ProcessEnv {
@@ -47,6 +49,9 @@ export function buildIsolatedEnv(opts: BuildEnvOptions): NodeJS.ProcessEnv {
47
49
  const value = opts.source[name]
48
50
  if (value !== undefined) out[name] = value
49
51
  }
52
+ if (opts.workspaceRoot !== undefined && out['PATH'] !== undefined) {
53
+ out['PATH'] = withoutWorkspaceBins(out['PATH'], opts.workspaceRoot)
54
+ }
50
55
  for (const [name, value] of Object.entries(opts.define)) {
51
56
  out[name] = value
52
57
  }
@@ -73,7 +78,7 @@ export function buildIsolatedEnv(opts: BuildEnvOptions): NodeJS.ProcessEnv {
73
78
  * A tool that runs other scripts reads it to call the same manager back
74
79
  * (npm-run-all's `run-s`, `run-p`) and falls back to `npm` without it:
75
80
  * solidjs/solid's `npm-run-all -nl build:*` needed a global npm under vx,
76
- * where `pnpm run build` passed (owner, 2026-10-06). It names a binary on
81
+ * where `pnpm run build` passed (2026-10-06). It names a binary on
77
82
  * this machine, so it is not in the key, as PATH is not.
78
83
  */
79
84
  export const PM_EXEC_ENV = 'npm_execpath'