@vzn/vx 0.0.631 → 0.0.633

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 (86) hide show
  1. package/README.md +21 -18
  2. package/docs/caching.md +2247 -0
  3. package/docs/cli.md +3214 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +172 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2137 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +14 -5
  10. package/schemas/cache.json +24 -0
  11. package/schemas/docs.json +28 -0
  12. package/schemas/error.json +23 -0
  13. package/schemas/info.json +147 -0
  14. package/schemas/init.json +57 -0
  15. package/schemas/last.json +188 -0
  16. package/schemas/lock.json +21 -0
  17. package/schemas/plan.json +88 -0
  18. package/schemas/show.json +124 -0
  19. package/schemas/summary.json +133 -0
  20. package/schemas/why.json +168 -0
  21. package/skills/vx/SKILL.md +89 -0
  22. package/src/bin.ts +9 -0
  23. package/src/cache/archive.ts +153 -51
  24. package/src/cache/cache.ts +542 -137
  25. package/src/cache/chained-cache.ts +9 -0
  26. package/src/cache/inputs.ts +5 -0
  27. package/src/cache/layer.ts +10 -5
  28. package/src/cache/layered-cache.ts +4 -0
  29. package/src/cache/schema.ts +44 -17
  30. package/src/cache/tar-stream.ts +204 -66
  31. package/src/cache/zstd.ts +1 -1
  32. package/src/cli/cache.ts +14 -14
  33. package/src/cli/docs-corpus.ts +21 -0
  34. package/src/cli/docs-text.d.ts +5 -0
  35. package/src/cli/docs.ts +167 -0
  36. package/src/cli/foreign-flags.ts +1 -1
  37. package/src/cli/help.ts +42 -7
  38. package/src/cli/index.ts +25 -11
  39. package/src/cli/info.ts +38 -9
  40. package/src/cli/init.ts +131 -24
  41. package/src/cli/last.ts +54 -3
  42. package/src/cli/lock.ts +43 -15
  43. package/src/cli/plan-format.ts +23 -1
  44. package/src/cli/plugin-commands.ts +3 -0
  45. package/src/cli/plugin-templates.ts +1 -1
  46. package/src/cli/run.ts +40 -21
  47. package/src/cli/show.ts +10 -7
  48. package/src/cli/upgrade.ts +19 -7
  49. package/src/cli/watch.ts +7 -2
  50. package/src/cli/why.ts +14 -40
  51. package/src/exec/runner.ts +27 -10
  52. package/src/graph/scheduler.ts +36 -0
  53. package/src/graph/task-graph.ts +4 -4
  54. package/src/index.ts +22 -5
  55. package/src/orchestrator/admission.ts +29 -1
  56. package/src/orchestrator/affected-tasks.ts +87 -18
  57. package/src/orchestrator/doctor.ts +14 -1
  58. package/src/orchestrator/execute-task.ts +149 -64
  59. package/src/orchestrator/failure-mode.ts +5 -3
  60. package/src/orchestrator/framed-output.ts +17 -3
  61. package/src/orchestrator/index.ts +11 -0
  62. package/src/orchestrator/logger.ts +57 -6
  63. package/src/orchestrator/metrics.ts +76 -4
  64. package/src/orchestrator/miss-reason.ts +3 -5
  65. package/src/orchestrator/miss-save.ts +6 -5
  66. package/src/orchestrator/options.ts +12 -0
  67. package/src/orchestrator/output-log.ts +14 -1
  68. package/src/orchestrator/path-links.ts +79 -0
  69. package/src/orchestrator/plan.ts +20 -3
  70. package/src/orchestrator/plugin.ts +7 -0
  71. package/src/orchestrator/prepare.ts +14 -4
  72. package/src/orchestrator/projects.ts +2 -2
  73. package/src/orchestrator/run-artifacts.ts +18 -2
  74. package/src/orchestrator/run-failures.ts +192 -0
  75. package/src/orchestrator/run-records.ts +10 -8
  76. package/src/orchestrator/run.ts +87 -22
  77. package/src/orchestrator/task-hash.ts +4 -4
  78. package/src/orchestrator/terminal-signals.ts +16 -0
  79. package/src/orchestrator/upstream.ts +65 -10
  80. package/src/util/errors.ts +55 -1
  81. package/src/util/index.ts +3 -0
  82. package/src/util/secret-mask.ts +13 -1
  83. package/src/util/verbs.ts +1 -0
  84. package/src/workspace/config-schema.ts +10 -8
  85. package/src/workspace/migration.ts +30 -3
  86. package/src/workspace/workspace.ts +1 -0
package/src/cli/run.ts CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path'
3
3
  import { isatty } from 'node:tty'
4
4
  import { findWorkspaceRoot } from '../workspace/index.js'
5
5
  import { translateForeign } from './foreign-flags.js'
6
- import { flagHint, seeHelp } from './help.js'
6
+ import { flagHint, formatValue, refuse, seeHelp } from './help.js'
7
7
  import {
8
8
  planRun,
9
9
  formatRunReportMarkdown,
@@ -69,6 +69,8 @@ export interface RunArgs {
69
69
  forwardArgs: string[]
70
70
  verbosity: number
71
71
  dry: 'text' | 'json' | undefined
72
+ /** `--format json`: the run's result document on stdout, every other line on stderr. */
73
+ format: 'pretty' | 'json'
72
74
  graph: string | undefined
73
75
  summarize: string | undefined
74
76
  profile: string | undefined
@@ -132,6 +134,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
132
134
  forwardArgs: [],
133
135
  verbosity: 0,
134
136
  dry: undefined,
137
+ format: 'pretty',
135
138
  graph: undefined,
136
139
  summarize: undefined,
137
140
  profile: undefined,
@@ -342,6 +345,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
342
345
  return { ...out, error: `--dry must be text or json (got ${fmt})` }
343
346
  }
344
347
  out.dry = fmt
348
+ } else if (a === '--format' || a?.startsWith('--format=')) {
349
+ const f = formatValue(a === '--format' ? before[++i] : a.slice('--format='.length), verb)
350
+ if (typeof f !== 'string') return { ...out, error: f.error }
351
+ out.format = f
345
352
  } else if (a === '--graph') {
346
353
  out.graph = ''
347
354
  } else if (a?.startsWith('--graph=')) {
@@ -429,6 +436,21 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
429
436
  if (out.graph !== undefined && needsRun) {
430
437
  return { ...out, error: `--graph skips execution; ${runOnly}` }
431
438
  }
439
+ if (out.format === 'json') {
440
+ // Each of these prints something else on stdout, which the document owns.
441
+ if (out.dry !== undefined)
442
+ return { ...out, error: '--format json runs the tasks; --dry=json prints the plan' }
443
+ if (out.graph === '')
444
+ return {
445
+ ...out,
446
+ error: '--format json and --graph both print on stdout; give --graph a file',
447
+ }
448
+ if (out.report === 'markdown')
449
+ return {
450
+ ...out,
451
+ error: '--format json and --report=markdown both print on stdout; use --report-file',
452
+ }
453
+ }
432
454
  return out
433
455
  }
434
456
 
@@ -614,6 +636,7 @@ export async function resolveRunOptions(
614
636
  if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
615
637
  if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
616
638
  if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
639
+ if (parsed.format === 'json') opts.json = true
617
640
  if (parsed.profile !== undefined) opts.profile = parsed.profile
618
641
  if (Object.keys(parsed.tags).length > 0) opts.tags = parsed.tags
619
642
 
@@ -626,8 +649,7 @@ const TASKS_SHOWN = 12
626
649
  export async function runCmd(args: readonly string[]): Promise<number> {
627
650
  const parsed = parseRunArgs(args)
628
651
  if (parsed.error) {
629
- process.stderr.write(`vx run: ${parsed.error}${seeHelp('run')}\n`)
630
- return 1
652
+ return refuse('run', args, `${parsed.error}${seeHelp('run')}`, 'VX_E_USAGE')
631
653
  }
632
654
 
633
655
  const cwd = process.cwd()
@@ -638,7 +660,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
638
660
  // anchored pkg#task; the rest of the pipeline treats it like any
639
661
  // explicit anchored positional.
640
662
  if (tasks.length === 0) {
641
- if (!process.stdin.isTTY) {
663
+ // A JSON answer is for a program, which cannot pick from a menu: an
664
+ // agent on a pty waited at the picker forever.
665
+ const forProgram = parsed.format === 'json' || parsed.dry === 'json'
666
+ if (!process.stdin.isTTY || forProgram) {
642
667
  const names = await taskNamesHere(cwd)
643
668
  const shown = (names ?? []).slice(0, TASKS_SHOWN)
644
669
  const more = (names?.length ?? 0) - shown.length
@@ -646,10 +671,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
646
671
  shown.length === 0
647
672
  ? 'vx run <task>, e.g. vx run build'
648
673
  : `tasks here: ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
649
- process.stderr.write(
650
- `vx run: missing task name (stdin is not a TTY, so no picker; ${tasksHere})${seeHelp('run')}\n`,
674
+ return refuse(
675
+ 'run',
676
+ args,
677
+ `missing task name (${forProgram ? 'a JSON answer opens' : 'stdin is not a TTY, so'} no picker; ${tasksHere})${seeHelp('run')}`,
678
+ 'VX_E_USAGE',
651
679
  )
652
- return 1
653
680
  }
654
681
  // Every run needs git: refused here, not after the user has chosen.
655
682
  const refusal = gitRefusal(await findWorkspaceRoot(cwd))
@@ -664,17 +691,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
664
691
  // `--filter` / `--affected` scope the menu, as they scope a bare task:
665
692
  // the menu listed every project and the anchored pick ran outside it.
666
693
  const scope = await scopeFilters(parsed, cwd)
667
- if ('error' in scope) {
668
- process.stderr.write(`vx run: ${scope.error}\n`)
669
- return 1
670
- }
694
+ if ('error' in scope) return refuse('run', args, scope.error, 'VX_E_USAGE')
671
695
  let only: ReadonlySet<string> | undefined
672
696
  if (scope.filterStrings.length > 0) {
673
697
  const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
674
- if ('error' in selected) {
675
- process.stderr.write(`vx run: ${selected.error}\n`)
676
- return 1
677
- }
698
+ if ('error' in selected) return refuse('run', args, selected.error, 'VX_E_USAGE')
678
699
  if ('empty' in selected) {
679
700
  process.stderr.write(`vx run: ${selected.empty}\n`)
680
701
  return 0
@@ -689,10 +710,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
689
710
  }
690
711
 
691
712
  const resolved = await resolveRunOptions(parsed, cwd, tasks)
692
- if ('error' in resolved) {
693
- process.stderr.write(`vx run: ${resolved.error}\n`)
694
- return 1
695
- }
713
+ if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
696
714
  if ('nothingSelected' in resolved) {
697
715
  process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
698
716
  return 0
@@ -783,8 +801,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
783
801
  // is no whole-run delegation seam to consult.
784
802
  const summary = await runOrchestrator(opts)
785
803
  if (summary.refused !== undefined) {
786
- process.stderr.write(`vx run: ${summary.refused}\n`)
787
- return 1
804
+ const unknown = summary.refused.startsWith('no projects declare')
805
+ return refuse('run', args, summary.refused, unknown ? 'VX_E_UNKNOWN_TASK' : 'VX_E_REFUSED')
788
806
  }
789
807
  if (wantsReport) {
790
808
  const md =
@@ -816,5 +834,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
816
834
  }
817
835
  }
818
836
  }
837
+ if (summary.json !== undefined) process.stdout.write(`${JSON.stringify(summary.json, null, 2)}\n`)
819
838
  return summary.ok ? 0 : 1
820
839
  }
package/src/cli/show.ts CHANGED
@@ -10,7 +10,7 @@ import path from 'node:path'
10
10
  import type { ProjectConfig, TaskConfig } from '../config.js'
11
11
  import { declaredTask } from '../graph/index.js'
12
12
  import { affectedTaskProjects, isDefaultBuild } from '../orchestrator/index.js'
13
- import { flagHint, formatValue, seeHelp } from './help.js'
13
+ import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
14
  import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
15
15
  import { affectedFilterFor, resolveFilters } from './select.js'
16
16
  import {
@@ -82,10 +82,7 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
82
82
 
83
83
  export async function showCmd(args: readonly string[]): Promise<number> {
84
84
  const parsed = parseShowArgs(args)
85
- if (parsed.error) {
86
- process.stderr.write(`vx show: ${parsed.error}\n`)
87
- return 1
88
- }
85
+ if (parsed.error) return refuse('show', args, parsed.error, 'VX_E_USAGE')
89
86
  const reads: LoadReads = new Map()
90
87
  const root = await findWorkspaceRoot(process.cwd(), reads)
91
88
  const metas = await discoverCliProjects(await loadWorkspace(root, reads))
@@ -98,6 +95,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
98
95
  if (rootMeta === undefined) {
99
96
  throw new UserError(
100
97
  `vx show: "${parsed.target}" names the workspace root's project, and the root is no project here`,
98
+ 'VX_E_USAGE',
101
99
  )
102
100
  }
103
101
  parsed.target = `${rootMeta.name}${parsed.target.slice(2)}`
@@ -114,12 +112,14 @@ export async function showCmd(args: readonly string[]): Promise<number> {
114
112
  const list = projectName === '' ? 'vx show' : `vx show ${projectName}`
115
113
  throw new UserError(
116
114
  `vx show: missing task name after '#' in "${parsed.target}" (${list} lists the tasks)`,
115
+ 'VX_E_USAGE',
117
116
  )
118
117
  }
119
118
  if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
120
119
  if (!byName.has(projectName)) {
121
120
  throw new UserError(
122
121
  `vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()], '', 'projects')}`,
122
+ 'VX_E_UNKNOWN_PROJECT',
123
123
  )
124
124
  }
125
125
  }
@@ -138,18 +138,19 @@ export async function showCmd(args: readonly string[]): Promise<number> {
138
138
  if (scope !== 'all') {
139
139
  throw new UserError(
140
140
  'vx show: --filter and --affected narrow a list: `vx show` or `vx show <task>`',
141
+ 'VX_E_USAGE',
141
142
  )
142
143
  }
143
144
  const raw = [...parsed.filters]
144
145
  let affected: string | undefined
145
146
  if (parsed.affected !== undefined) {
146
147
  const f = await affectedFilterFor(process.cwd(), parsed.affected)
147
- if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`)
148
+ if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, 'VX_E_USAGE')
148
149
  affected = f
149
150
  raw.unshift(f)
150
151
  }
151
152
  const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
152
- if ('error' in r) throw new UserError(`vx show: ${r.error}`)
153
+ if ('error' in r) throw new UserError(`vx show: ${r.error}`, 'VX_E_USAGE')
153
154
  if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
154
155
  selected = new Set('empty' in r ? [] : r.names)
155
156
  if ('names' in r && r.affected !== undefined)
@@ -181,6 +182,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
181
182
  const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
182
183
  throw new UserError(
183
184
  `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
185
+ 'VX_E_UNKNOWN_TASK',
184
186
  )
185
187
  }
186
188
  let shown = declaring.filter((p) => inSelection(p.name))
@@ -224,6 +226,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
224
226
  if (task === undefined) {
225
227
  throw new UserError(
226
228
  `vx show: unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`, 'its tasks')}`,
229
+ 'VX_E_UNKNOWN_TASK',
227
230
  )
228
231
  }
229
232
  process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
@@ -13,7 +13,7 @@
13
13
  // deno upgrade — "update" is what package managers do to indexes.
14
14
 
15
15
  import { constants } from 'node:fs'
16
- import { access, chmod, chown, link, rename, rm, stat } from 'node:fs/promises'
16
+ import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
17
17
  import path from 'node:path'
18
18
  import { flagHint, seeHelp } from './help.js'
19
19
  import { UserError } from '../util/index.js'
@@ -38,7 +38,7 @@ export function isBunfsPath(p: string): boolean {
38
38
  * upgrade` refused with "running from source". `Bun.main` stays the
39
39
  * bunfs path under every compile-flag combination.
40
40
  */
41
- function isCompiledBinary(): boolean {
41
+ export function isCompiledBinary(): boolean {
42
42
  return (
43
43
  isBunfsPath(Bun.main) || isBunfsPath(process.argv[1] ?? '') || isBunfsPath(import.meta.path)
44
44
  )
@@ -247,8 +247,20 @@ export async function replaceBinary(
247
247
  `vx upgrade: the download did not match the release's SHA-256 (expected ${sha256}, got ${got}) — nothing replaced; try again, and if it repeats the asset is not the one the release published`,
248
248
  )
249
249
  }
250
- const tmp = `${dest}.upgrade-${process.pid}`
251
- const old = `${dest}.previous-${process.pid}`
250
+ // A fresh directory beside the binary, not a name built from the pid:
251
+ // anyone who can write the install directory could plant a link at a
252
+ // predictable name, and a `sudo vx upgrade` wrote through it as root.
253
+ let stage: string
254
+ try {
255
+ stage = await mkdtemp(path.join(dir, '.vx-upgrade-'))
256
+ } catch (err) {
257
+ const msg = err instanceof Error ? err.message : String(err)
258
+ throw new UserError(
259
+ `vx upgrade: could not stage the new binary in ${dir} (${msg}) — nothing replaced; check the permissions of ${dir}`,
260
+ )
261
+ }
262
+ const tmp = path.join(stage, 'new')
263
+ const old = path.join(stage, 'previous')
252
264
  let kept = false
253
265
  try {
254
266
  const was = await stat(dest).catch(() => null)
@@ -265,18 +277,18 @@ export async function replaceBinary(
265
277
  }
266
278
  await rename(tmp, dest)
267
279
  } catch (err) {
268
- await rm(tmp, { force: true })
269
- if (kept) await rm(old, { force: true })
280
+ await rm(stage, { recursive: true, force: true })
270
281
  const msg = err instanceof Error ? err.message : String(err)
271
282
  throw new UserError(
272
283
  `vx upgrade: could not replace ${dest} (${msg}) — nothing replaced; check the permissions of ${dest} and its directory`,
273
284
  )
274
285
  }
275
286
  if (starts === undefined || starts(dest)) {
276
- if (kept) await rm(old, { force: true })
287
+ await rm(stage, { recursive: true, force: true })
277
288
  return
278
289
  }
279
290
  if (kept) await rename(old, dest)
291
+ await rm(stage, { recursive: true, force: true })
280
292
  throw new UserError(
281
293
  `vx upgrade: the new binary did not start on this machine (\`${dest} --version\` failed)${kept ? ' — the previous vx is back in place' : ''}. Nothing else changed; report it with this os/arch`,
282
294
  )
package/src/cli/watch.ts CHANGED
@@ -78,8 +78,13 @@ export function watchRefusal(parsed: RunArgs): string | null {
78
78
  // All three format ONE run's result and are consumed by `runCmd` alone, so
79
79
  // a watch loop silently ignored them. `--verbosity 0` is not rejected: it
80
80
  // asks for the output watch already gives.
81
- if (parsed.report !== undefined || parsed.reportFile !== undefined || parsed.verbosity > 0) {
82
- return 'vx watch: --report / --report-file / --verbosity are not supported in watch mode (they report a single run)'
81
+ if (
82
+ parsed.report !== undefined ||
83
+ parsed.reportFile !== undefined ||
84
+ parsed.verbosity > 0 ||
85
+ parsed.format !== 'pretty'
86
+ ) {
87
+ return 'vx watch: --report / --report-file / --verbosity / --format are not supported in watch mode (they report a single run)'
83
88
  }
84
89
  return null
85
90
  }
package/src/cli/why.ts CHANGED
@@ -9,12 +9,11 @@ import path from 'node:path'
9
9
  import { Cache } from '../cache/index.js'
10
10
  import { flagHint, formatValue, seeHelp } from './help.js'
11
11
  import { splitTaskId } from '../graph/index.js'
12
- import type { Database } from 'bun:sqlite'
13
12
  import {
14
- type CacheKeyDiff,
15
13
  cacheKeyDiff,
16
14
  explainCacheKeyQuery as explainCacheKey,
17
15
  latestRunId,
16
+ rootCauses,
18
17
  whyDidThisRerunQuery as whyDidThisRerun,
19
18
  resolveRunId,
20
19
  } from '../orchestrator/index.js'
@@ -151,7 +150,10 @@ async function resolveTarget(
151
150
  const ids = pairs.map((p) => `${p.project}#${p.task}`)
152
151
  const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
153
152
  if (target.includes('#')) {
154
- throw new UserError(`vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`)
153
+ throw new UserError(
154
+ `vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
155
+ 'VX_E_NO_HISTORY',
156
+ )
155
157
  }
156
158
  const matches = ids.filter((id) => id.endsWith(`#${target}`))
157
159
  if (matches.length === 1) return matches[0]!
@@ -161,10 +163,12 @@ async function resolveTarget(
161
163
  throw new UserError(
162
164
  `vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
163
165
  matches.map((m) => ` ${m}`).join('\n'),
166
+ 'VX_E_AMBIGUOUS',
164
167
  )
165
168
  }
166
169
  throw new UserError(
167
170
  `vx why: no recorded runs for task "${target}"${suggest(target, ids, invoked)}`,
171
+ 'VX_E_NO_HISTORY',
168
172
  )
169
173
  }
170
174
 
@@ -187,9 +191,12 @@ function restoredWord(r: { restored: boolean | null }): string {
187
191
 
188
192
  export async function whyCmd(args: readonly string[]): Promise<number> {
189
193
  const parsed = parseWhyArgs(args)
190
- if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`)
194
+ if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`, 'VX_E_USAGE')
191
195
  if (parsed.target === undefined) {
192
- throw new UserError('vx why: <task> required (e.g. vx why app#build, or vx why build)')
196
+ throw new UserError(
197
+ 'vx why: <task> required (e.g. vx why app#build, or vx why build)',
198
+ 'VX_E_USAGE',
199
+ )
193
200
  }
194
201
 
195
202
  const root = await findWorkspaceRoot(process.cwd())
@@ -206,6 +213,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
206
213
  if (runId === null) {
207
214
  throw new UserError(
208
215
  `vx why: no recorded run ${parsed.runId} (vx last --list shows recent runs)`,
216
+ 'VX_E_NO_HISTORY',
209
217
  )
210
218
  }
211
219
  } else runId = latestRunId(db, taskId)
@@ -231,6 +239,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
231
239
  if (!why.found) {
232
240
  throw new UserError(
233
241
  `vx why: run ${runId} has no row for ${taskId}; \`vx last --list\` shows the recorded runs, \`vx last RUNID\` what one ran`,
242
+ 'VX_E_NO_HISTORY',
234
243
  )
235
244
  }
236
245
  const diff = cacheKeyDiff(db, runId, taskId)
@@ -344,38 +353,3 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
344
353
  }
345
354
 
346
355
  const ROOTS_SHOWN = 5
347
-
348
- interface RootCause {
349
- /** From the asked task down to the one whose own inputs moved. */
350
- chain: string[]
351
- entries: CacheKeyDiff['entries']
352
- }
353
-
354
- /**
355
- * The tasks under `taskId`'s moved upstreams whose OWN key components
356
- * moved in the same run, each with the chain that carried it up. A task
357
- * visited once is not walked again (a diamond names its root once).
358
- */
359
- function rootCauses(
360
- db: Database,
361
- runId: string,
362
- taskId: string,
363
- entries: CacheKeyDiff['entries'],
364
- ): RootCause[] {
365
- const roots: RootCause[] = []
366
- const seen = new Set([taskId])
367
- const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
368
- for (const e of moved) {
369
- if (e.kind !== 'upstream' || seen.has(e.name)) continue
370
- seen.add(e.name)
371
- const next = [...chain, e.name]
372
- const d = cacheKeyDiff(db, runId, e.name)
373
- if (!d.found) continue
374
- const own = d.entries.filter((x) => x.kind !== 'upstream')
375
- if (own.length > 0) roots.push({ chain: next, entries: own })
376
- walk(next, d.entries)
377
- }
378
- }
379
- walk([taskId], entries)
380
- return roots
381
- }
@@ -849,7 +849,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
849
849
  }
850
850
  }
851
851
  const reader = stream.getReader()
852
- const decoder = new TextDecoder()
852
+ const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
853
853
  try {
854
854
  while (true) {
855
855
  const { value, done } = await reader.read()
@@ -1097,12 +1097,17 @@ export const CAPTURE_HEAD_CHARS = 8 * 1024 * 1024
1097
1097
  export const CAPTURE_TAIL_CHARS = 8 * 1024 * 1024
1098
1098
 
1099
1099
  const mib = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MiB`
1100
+ const size = (n: number): string => (n < 1024 * 1024 ? `${n / 1024} KiB` : mib(n))
1100
1101
 
1101
1102
  /** The line that stands where the dropped middle was. */
1102
- export function droppedOutputLine(dropped: number): string {
1103
+ export function droppedOutputLine(
1104
+ dropped: number,
1105
+ headChars = CAPTURE_HEAD_CHARS,
1106
+ tailChars = CAPTURE_TAIL_CHARS,
1107
+ ): string {
1103
1108
  return (
1104
- `\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${mib(CAPTURE_HEAD_CHARS)} ` +
1105
- `and the last ${mib(CAPTURE_TAIL_CHARS)} of a task's output for its cache entry and replay\n`
1109
+ `\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${size(headChars)} ` +
1110
+ `and the last ${size(tailChars)} of a task's output\n`
1106
1111
  )
1107
1112
  }
1108
1113
 
@@ -1145,12 +1150,17 @@ export class BoundedCapture {
1145
1150
  private tailLen = 0
1146
1151
  private dropped = 0
1147
1152
 
1153
+ constructor(
1154
+ private readonly headChars = CAPTURE_HEAD_CHARS,
1155
+ private readonly tailChars = CAPTURE_TAIL_CHARS,
1156
+ ) {}
1157
+
1148
1158
  push(text: string, err = false): void {
1149
1159
  // Once the tail has begun the head is closed, even a unit short of its
1150
1160
  // bound (the surrogate case below): a later short chunk fit there and
1151
1161
  // was retained ahead of the chunk before it.
1152
- if (this.tailLen === 0 && this.headLen < CAPTURE_HEAD_CHARS) {
1153
- let room = CAPTURE_HEAD_CHARS - this.headLen
1162
+ if (this.tailLen === 0 && this.headLen < this.headChars) {
1163
+ let room = this.headChars - this.headLen
1154
1164
  if (text.length <= room) {
1155
1165
  this.head.push({ text, err })
1156
1166
  this.headLen += text.length
@@ -1165,9 +1175,9 @@ export class BoundedCapture {
1165
1175
  }
1166
1176
  this.tail.push({ text, err })
1167
1177
  this.tailLen += text.length
1168
- while (this.tailLen > CAPTURE_TAIL_CHARS) {
1178
+ while (this.tailLen > this.tailChars) {
1169
1179
  const first = this.tail[0]!
1170
- const excess = this.tailLen - CAPTURE_TAIL_CHARS
1180
+ const excess = this.tailLen - this.tailChars
1171
1181
  if (first.text.length <= excess) {
1172
1182
  this.tail.shift()
1173
1183
  this.tailLen -= first.text.length
@@ -1185,7 +1195,14 @@ export class BoundedCapture {
1185
1195
  chunks(): CapturedChunk[] {
1186
1196
  return this.dropped === 0
1187
1197
  ? [...this.head, ...this.tail]
1188
- : [...this.head, { text: droppedOutputLine(this.dropped), err: false }, ...this.tail]
1198
+ : [
1199
+ ...this.head,
1200
+ {
1201
+ text: droppedOutputLine(this.dropped, this.headChars, this.tailChars),
1202
+ err: false,
1203
+ },
1204
+ ...this.tail,
1205
+ ]
1189
1206
  }
1190
1207
 
1191
1208
  text(): string {
@@ -1213,7 +1230,7 @@ export async function streamToString(
1213
1230
  if (!stream || typeof stream === 'number') return ''
1214
1231
  const full = new BoundedCapture()
1215
1232
  const reader = stream.getReader()
1216
- const decoder = new TextDecoder()
1233
+ const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
1217
1234
  // On abort, cancel the read so a pending `reader.read()` resolves
1218
1235
  // `done` and we return whatever we captured. Needed for the timeout
1219
1236
  // path: SIGTERMing `sh` doesn't close the pipe if an orphaned
@@ -140,6 +140,12 @@ export interface TaskOutcome {
140
140
  * of reading it as a signal (item 268).
141
141
  */
142
142
  timedOut?: true
143
+ /**
144
+ * On a `failed` outcome of a command that ran: the final attempt's
145
+ * output, both streams, as `encodeOutputLog` stores it, secrets masked,
146
+ * bounded to its first 8 KiB and last 56 KiB. The run's history keeps it.
147
+ */
148
+ failedOutput?: string
143
149
  /**
144
150
  * On a failed persistent task: why it never became ready — the readiness
145
151
  * deadline fired, the child exited first (`exitCode` is then its own), or
@@ -255,6 +261,15 @@ export interface ScheduleOptions {
255
261
  * slot admits other work at once. Undefined: nothing owed.
256
262
  */
257
263
  settledOf?: (outcome: TaskOutcome) => Promise<Partial<TaskOutcome> | void> | undefined
264
+ /**
265
+ * The outcome of a task the caller settles in place, or undefined to
266
+ * dispatch it through `execute`. Asked of an exec-tier task the lane has
267
+ * room for, after the skip check; its outcome lands in the same tick, it
268
+ * holds no slot, and `onStart` / `onFinish` hear it as any other. An
269
+ * unkeyed group runs nothing, and the dispatch, the slot and the promise
270
+ * around it were ~10 µs of each of a warm run's 1,090 groups (X-192).
271
+ */
272
+ settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => TaskOutcome | undefined
258
273
  /**
259
274
  * Optional priority override: callers pass their own per-node weight
260
275
  * (e.g. `computePredictedPriorities` from the orchestrator's history
@@ -749,6 +764,16 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
749
764
  })
750
765
  }
751
766
 
767
+ // A throw falls back to the dispatch, whose `execute` reports it.
768
+ const settleInPlace = (node: TaskNode, upstream: TaskOutcome[]): TaskOutcome | undefined => {
769
+ if (options.settleNow === undefined) return undefined
770
+ try {
771
+ return options.settleNow(node, upstream)
772
+ } catch {
773
+ return undefined
774
+ }
775
+ }
776
+
752
777
  const tick = (): void => {
753
778
  if (resolved) return
754
779
  // Exec-tier tasks parked THIS tick on a refused admission. Within
@@ -844,6 +869,17 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
844
869
 
845
870
  const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
846
871
  readyAt.delete(id)
872
+ const now = inRestoreTier(id) ? undefined : settleInPlace(node, upstream)
873
+ if (now !== undefined) {
874
+ try {
875
+ onStart?.(node)
876
+ } catch (err) {
877
+ const m = err instanceof Error ? err.message : String(err)
878
+ process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
879
+ }
880
+ finishOne(id, queuedMs === 0 ? now : { ...now, queuedMs })
881
+ continue
882
+ }
847
883
  const leave = admit(id)
848
884
  // Listed as running on dispatch, so the policy's next ask in this
849
885
  // tick sees it; the completion callbacks unlist it.
@@ -109,7 +109,7 @@ export function isGroupTask(node: TaskNode): boolean {
109
109
  return node.config.exec === undefined
110
110
  }
111
111
 
112
- /** The default `build` (orchestrator/projects.ts): a config cannot key a group, so it is the one keyed group. */
112
+ /** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `cache`. */
113
113
  function isKeyedGroup(task: TaskConfig): boolean {
114
114
  return task.exec === undefined && task.cache !== undefined
115
115
  }
@@ -1505,8 +1505,8 @@ interface Side {
1505
1505
  * `inputs.files` rebased to the root against another task's
1506
1506
  * `outputs.workspaceFiles`, which may land in any project (X-135). A task's own outputs are
1507
1507
  * already subtracted from its inputs, and an output the reader's `!` entries
1508
- * take back whole is no overlap. A keyed group is exempt: the default
1509
- * `build` reads `**` of a config-less dependency and runs nothing, and its
1508
+ * take back whole is no overlap. A keyed group is exempt: it runs nothing
1509
+ * (the default `build` reads `**` of a config-less dependency), and its
1510
1510
  * key waits as before.
1511
1511
  */
1512
1512
  function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
@@ -1670,7 +1670,7 @@ function detectCycle(nodes: Map<string, TaskNode>): void {
1670
1670
  for (let i = 0; i < stack.length; i += 2) path.push(idsArr[stack[i]!]!)
1671
1671
  const startInPath = path.indexOf(idsArr[depIdx]!)
1672
1672
  const cycle = [...path.slice(startInPath), idsArr[depIdx]!].join(' -> ')
1673
- throw new UserError(`Cycle detected in task graph: ${cycle}`)
1673
+ throw new UserError(`Cycle detected in task graph: ${cycle}`, 'VX_E_CYCLE')
1674
1674
  }
1675
1675
  color[depIdx] = GRAY
1676
1676
  stack.push(depIdx, 0)
package/src/index.ts CHANGED
@@ -239,8 +239,25 @@ export type {
239
239
  // The serializable run result and its per-task views (what `run()` returns).
240
240
  export type { RunResult, TaskView, OutcomeView } from './orchestrator/index.js'
241
241
 
242
- // The one run-history query a reader outside the CLI takes today: `vx why`'s
243
- // answer, which `@vzn/vx-mcp` serves. The event bus / wire form and the
244
- // other history readers (`listRuns`, `getRun`, …) left the façade 2026-09-10
245
- // with no consumer; the telemetry seam above is the canonical export path.
246
- export { latestRunId, resolveRunId, whyDidThisRerunQuery } from './orchestrator/index.js'
242
+ // The run-history queries a reader outside the CLI takes today: `vx why`'s
243
+ // answer, a run's failures and a task's log, which `@vzn/vx-mcp` serves. The event bus /
244
+ // wire form and the other history readers (`listRuns`, `getRun`, …) left the
245
+ // façade 2026-09-10 with no consumer; the telemetry seam above is the
246
+ // canonical export path.
247
+ export {
248
+ cacheKeyDiff,
249
+ latestRunId,
250
+ resolveRunId,
251
+ rootCauses,
252
+ runFailures,
253
+ taskLog,
254
+ whyDidThisRerunQuery,
255
+ } from './orchestrator/index.js'
256
+ export type {
257
+ CacheKeyDiff,
258
+ OutputLocation,
259
+ RootCause,
260
+ RunFailures,
261
+ TaskFailure,
262
+ TaskLog,
263
+ } from './orchestrator/index.js'