@vzn/vx 0.0.650 → 0.0.651

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 (115) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +202 -25
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/git-inputs.ts +188 -39
  8. package/src/cache/index.ts +1 -0
  9. package/src/cache/inputs.ts +3 -0
  10. package/src/cache/layer.ts +11 -0
  11. package/src/cache/layered-cache.ts +4 -0
  12. package/src/cache/linked-inputs.ts +3 -0
  13. package/src/cache/lockfile-memo.ts +3 -2
  14. package/src/cache/output-index.ts +5 -2
  15. package/src/cache/remote-lane.ts +2 -1
  16. package/src/cache/run-history.ts +11 -1
  17. package/src/cache/schema.ts +11 -1
  18. package/src/cli/cache.ts +26 -16
  19. package/src/cli/completions.ts +4 -2
  20. package/src/cli/docs.ts +10 -11
  21. package/src/cli/help.ts +12 -23
  22. package/src/cli/index.ts +4 -9
  23. package/src/cli/info.ts +15 -7
  24. package/src/cli/init.ts +23 -15
  25. package/src/cli/last.ts +213 -203
  26. package/src/cli/lock.ts +55 -5
  27. package/src/cli/plan-format.ts +100 -118
  28. package/src/cli/plugin-commands.ts +8 -2
  29. package/src/cli/plugin-templates.ts +2 -2
  30. package/src/cli/run.ts +46 -27
  31. package/src/cli/select.ts +43 -5
  32. package/src/cli/show.ts +38 -42
  33. package/src/cli/task-verb.ts +22 -0
  34. package/src/cli/upgrade.ts +3 -1
  35. package/src/cli/watch-fs.ts +6 -1
  36. package/src/cli/watch-judge.ts +2 -1
  37. package/src/cli/watch.ts +83 -44
  38. package/src/cli/why.ts +123 -89
  39. package/src/cli/workspace-config.ts +2 -1
  40. package/src/exec/executor.ts +2 -1
  41. package/src/exec/kill-tree.ts +58 -5
  42. package/src/exec/local-executor.ts +1 -1
  43. package/src/exec/pipes.ts +152 -0
  44. package/src/exec/runner.ts +199 -182
  45. package/src/exec/sandbox-binds.ts +1 -1
  46. package/src/exec/sandbox-runtime.ts +11 -2
  47. package/src/exec/spawn-off.ts +154 -0
  48. package/src/exec/warm-shell.ts +146 -0
  49. package/src/graph/critical-path.ts +40 -28
  50. package/src/graph/priorities.ts +98 -44
  51. package/src/graph/scheduler.ts +14 -10
  52. package/src/graph/task-graph.ts +27 -1
  53. package/src/handoff.ts +10 -7
  54. package/src/history/history.ts +5 -0
  55. package/src/history/metrics.ts +9 -1
  56. package/src/index.ts +3 -1
  57. package/src/orchestrator/doctor.ts +1 -1
  58. package/src/orchestrator/execute-task.ts +16 -2
  59. package/src/orchestrator/graph-memo.ts +50 -1
  60. package/src/orchestrator/graph-stage.ts +6 -0
  61. package/src/orchestrator/index.ts +3 -0
  62. package/src/orchestrator/live-usage.ts +16 -11
  63. package/src/orchestrator/locked-digests.ts +63 -0
  64. package/src/orchestrator/lockfile-claim.ts +108 -29
  65. package/src/orchestrator/miss-save.ts +31 -3
  66. package/src/orchestrator/persistent-task.ts +3 -1
  67. package/src/orchestrator/plan.ts +5 -1
  68. package/src/orchestrator/plugin-host.ts +61 -0
  69. package/src/orchestrator/plugin.ts +47 -2
  70. package/src/orchestrator/prepare.ts +16 -1
  71. package/src/orchestrator/projects.ts +4 -1
  72. package/src/orchestrator/run-context.ts +29 -10
  73. package/src/orchestrator/run-failures.ts +1 -1
  74. package/src/orchestrator/run-records.ts +10 -0
  75. package/src/orchestrator/run.ts +53 -22
  76. package/src/orchestrator/sandbox-request.ts +2 -2
  77. package/src/orchestrator/signals.ts +8 -5
  78. package/src/orchestrator/task-hash.ts +17 -3
  79. package/src/report/events.ts +3 -11
  80. package/src/report/forecast.ts +10 -32
  81. package/src/report/framed-output.ts +272 -173
  82. package/src/report/index.ts +8 -3
  83. package/src/report/logger.ts +73 -38
  84. package/src/report/plain-output.ts +1 -1
  85. package/src/report/status-line.ts +49 -357
  86. package/src/report/summary.ts +100 -131
  87. package/src/ui/ansi.ts +48 -0
  88. package/src/ui/components.ts +506 -0
  89. package/src/ui/index.ts +79 -0
  90. package/src/ui/kit.ts +37 -0
  91. package/src/ui/live-region.ts +363 -0
  92. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  93. package/src/ui/terminal.ts +51 -0
  94. package/src/ui/theme.ts +224 -0
  95. package/src/ui/version-line.ts +28 -0
  96. package/src/util/config-frame.ts +65 -10
  97. package/src/util/edit-distance.ts +6 -1
  98. package/src/util/index.ts +2 -15
  99. package/src/util/paths.ts +20 -2
  100. package/src/util/secret-mask.ts +39 -3
  101. package/src/util/settle.ts +31 -0
  102. package/src/util/timing.ts +4 -16
  103. package/src/workspace/affected.ts +89 -30
  104. package/src/workspace/config-schema.ts +4 -2
  105. package/src/workspace/dependencies-lock.ts +191 -0
  106. package/src/workspace/filter.ts +28 -0
  107. package/src/workspace/index.ts +9 -0
  108. package/src/workspace/lockfile.ts +159 -6
  109. package/src/workspace/migration.ts +4 -3
  110. package/src/workspace/workspace.ts +3 -0
  111. package/src/util/colors.ts +0 -133
  112. package/src/util/report.ts +0 -44
  113. package/src/util/ui.ts +0 -258
  114. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  115. /package/src/{report → ui}/terminal-signals.ts +0 -0
package/src/cli/last.ts CHANGED
@@ -28,21 +28,17 @@ import {
28
28
  shortRunId,
29
29
  taskLog,
30
30
  } from '../orchestrator/index.js'
31
- import { outcomeWord, type WireEvent } from '../report/index.js'
31
+ import { formatRecordedRow, outcomeWord, taskList, type WireEvent } from '../report/index.js'
32
32
  import {
33
- detectColors,
34
- ERROR,
35
33
  formatElapsed,
36
- paint,
34
+ nearest,
37
35
  parseDecimalInt,
38
36
  refusal,
39
37
  splitTaskId,
40
- SUCCESS,
41
38
  UserError,
42
- sayNotice,
43
- ui,
44
- type ColorSupport,
45
39
  } from '../util/index.js'
40
+ import * as ui from '../ui/index.js'
41
+ import { sayNotice } from '../ui/index.js'
46
42
  import { findWorkspaceRoot } from '../workspace/index.js'
47
43
  import { findCwdProject } from './select.js'
48
44
  import { cliCacheDir, parseCacheDirFlag, rootSpelled } from './workspace-config.js'
@@ -315,7 +311,7 @@ async function printStats(
315
311
  )
316
312
  }
317
313
  if (format === 'json') {
318
- process.stdout.write(`${JSON.stringify({ window: provider.recent, branch, stats })}\n`)
314
+ ui.data('out', `${JSON.stringify({ window: provider.recent, branch, stats })}\n`)
319
315
  return 0
320
316
  }
321
317
  if (stats.length === 0) {
@@ -324,33 +320,37 @@ async function printStats(
324
320
  }
325
321
  const pct = (r: number): string => `${Math.round(r * 100)}%`
326
322
  const ms = (v: number | null): string => (v === null ? '-' : fmtMs(v))
327
- const rows = [
328
- ['task', 'runs', 'p50', 'p99', 'pass', 'hit', 'failure mode', 'peak rss', 'cpu'],
329
- ...stats.map((s) => [
330
- s.taskId,
331
- String(s.runs),
332
- ms(s.p50DurationMs),
333
- ms(s.p99DurationMs),
334
- pct(s.successRate),
335
- pct(s.hitRate),
336
- s.failureMode,
337
- s.maxPeakRssBytes === null ? '-' : formatBytes(s.maxPeakRssBytes),
338
- s.maxCpuParallelism === null ? '-' : `${s.maxCpuParallelism.toFixed(1)}×`,
339
- ]),
340
- ]
341
- const widths = rows[0]!.map((_, c) => Math.max(...rows.map((r) => r[c]!.length)))
342
- const c = detectColors(process.stdout)
343
- const lines = rows.map((r, i) =>
344
- r
345
- .map((cell, col) => {
346
- const padded = col === r.length - 1 ? cell : cell.padEnd(widths[col]!)
347
- if (i === 0) return paint('', padded, c, { dim: true })
348
- return col === 0 ? `${ui.inline(ui.id(cell), c)}${padded.slice(cell.length)}` : padded
349
- })
350
- .join(' ')
351
- .trimEnd(),
323
+ const right = (title: string): ui.Column => ({ title, align: 'right' })
324
+ ui.print(
325
+ 'out',
326
+ ui.section(
327
+ 'Task stats',
328
+ ui.table(
329
+ [
330
+ 'task',
331
+ right('runs'),
332
+ right('p50'),
333
+ right('p99'),
334
+ right('pass'),
335
+ right('hit'),
336
+ 'failure mode',
337
+ right('peak rss'),
338
+ right('cpu'),
339
+ ],
340
+ stats.map((s) => [
341
+ ui.id(s.taskId),
342
+ String(s.runs),
343
+ ms(s.p50DurationMs),
344
+ ms(s.p99DurationMs),
345
+ pct(s.successRate),
346
+ pct(s.hitRate),
347
+ s.failureMode,
348
+ s.maxPeakRssBytes === null ? '-' : formatBytes(s.maxPeakRssBytes),
349
+ s.maxCpuParallelism === null ? '-' : `${s.maxCpuParallelism.toFixed(1)}×`,
350
+ ]),
351
+ ),
352
+ ),
352
353
  )
353
- process.stdout.write(`${lines.join('\n')}\n`)
354
354
  return 0
355
355
  }
356
356
 
@@ -412,7 +412,7 @@ function printQuery(db: Database, sql: string, format: 'pretty' | 'json'): numbe
412
412
  )
413
413
  }
414
414
  const rows = values.map((v) => Object.fromEntries(columns.map((c, i) => [c, v[i]])))
415
- process.stdout.write(`${JSON.stringify({ rows })}\n`)
415
+ ui.data('out', `${JSON.stringify({ rows })}\n`)
416
416
  return 0
417
417
  }
418
418
  if (values.length === 0) return 0
@@ -423,15 +423,13 @@ function printQuery(db: Database, sql: string, format: 'pretty' | 'json'): numbe
423
423
  : v instanceof Uint8Array
424
424
  ? `<${v.length} bytes>`
425
425
  : String(v)
426
- const table = [columns, ...values.map((r) => r.map((v) => cell(v as Parameters<typeof cell>[0])))]
427
- const widths = table[0]!.map((_, c) => Math.max(...table.map((r) => r[c]!.length)))
428
- const lines = table.map((r) =>
429
- r
430
- .map((v, c) => v.padEnd(widths[c]!))
431
- .join(' ')
432
- .trimEnd(),
426
+ ui.print(
427
+ 'out',
428
+ ui.table(
429
+ columns,
430
+ values.map((r) => r.map((v) => ui.strong(cell(v as Parameters<typeof cell>[0])))),
431
+ ),
433
432
  )
434
- process.stdout.write(`${lines.join('\n')}\n`)
435
433
  return 0
436
434
  }
437
435
 
@@ -464,12 +462,15 @@ async function printLive(
464
462
  const line = (text: string): void => {
465
463
  if (snapshot === undefined) {
466
464
  snapshot = JSON.parse(text) as LiveSnapshot
467
- if (format === 'json') process.stdout.write(`${text}\n`)
468
- else process.stdout.write(`${formatLive(snapshot).join('\n')}\n`)
465
+ if (format === 'json') ui.data('out', `${text}\n`)
466
+ else ui.print('out', ...formatLive(snapshot))
469
467
  return
470
468
  }
471
- if (format === 'json') process.stdout.write(`${text}\n`)
472
- else process.stdout.write(formatLiveEvent(JSON.parse(text) as WireEvent))
469
+ if (format === 'json') ui.data('out', `${text}\n`)
470
+ else {
471
+ const event = formatLiveEvent(JSON.parse(text) as WireEvent)
472
+ if (event !== undefined) ui.print('out', event)
473
+ }
473
474
  }
474
475
  try {
475
476
  await Bun.connect({
@@ -506,49 +507,72 @@ async function printLive(
506
507
  return 0
507
508
  }
508
509
 
509
- function formatLive(s: LiveSnapshot): string[] {
510
+ function formatLive(s: LiveSnapshot): ui.Block[] {
510
511
  const count = (status: string): number => s.tasks.filter((t) => t.status === status).length
511
512
  const running = count('running')
512
513
  const waiting = count('waiting')
513
514
  const failed = count('failed')
514
- const lines = [
515
- `run ${s.live.runId} — running ${fmtMs(s.live.elapsedMs)}`,
516
- ` $ ${s.live.command}`,
517
- ` ${running} running · ${s.tasks.length - running - waiting} ended${failed > 0 ? ` · ${failed} failed` : ''} · ${waiting} waiting`,
518
- '',
519
- ]
520
- const width = Math.max(...s.tasks.map((t) => t.status.length), 7)
521
- const idWidth = Math.max(...s.tasks.map((t) => t.taskId.length), 0)
522
515
  const now = s.live.startedAt + s.live.elapsedMs
523
- for (const t of s.tasks) {
524
- const ms =
525
- t.durationMs !== null ? t.durationMs : t.startedAt !== null ? now - t.startedAt : null
526
- lines.push(
527
- ` ${t.status.padEnd(width)} ${ms === null ? t.taskId : `${t.taskId.padEnd(idWidth)} ${fmtMs(ms)}`}`,
528
- )
529
- }
530
- if (s.warnings.length > 0) {
531
- lines.push('', ' warnings', ...s.warnings.flatMap((w) => w.split('\n').map((l) => ` ${l}`)))
532
- }
533
- return lines
516
+ const blocks = [
517
+ ui.section(
518
+ 'Run',
519
+ s.live.runId,
520
+ ui.fields([
521
+ ['status', ui.tone('info', `running ${fmtMs(s.live.elapsedMs)}`)],
522
+ ['command', ui.cmd(s.live.command)],
523
+ [
524
+ 'tasks',
525
+ `${running} running · ${s.tasks.length - running - waiting} ended${failed > 0 ? ` · ${failed} failed` : ''} · ${waiting} waiting`,
526
+ ],
527
+ ]),
528
+ ),
529
+ ui.section(
530
+ 'Tasks',
531
+ String(s.tasks.length),
532
+ ui.table(
533
+ ['status', 'task', { title: 'time', align: 'right' }],
534
+ s.tasks.map((t) => {
535
+ const ms =
536
+ t.durationMs !== null ? t.durationMs : t.startedAt !== null ? now - t.startedAt : null
537
+ return [
538
+ ui.tone(
539
+ t.status === 'failed' ? 'error' : t.status === 'running' ? 'info' : 'muted',
540
+ t.status,
541
+ ),
542
+ ui.id(t.taskId),
543
+ ms === null ? '' : fmtMs(ms),
544
+ ]
545
+ }),
546
+ ),
547
+ ),
548
+ ]
549
+ if (s.warnings.length > 0)
550
+ blocks.push(ui.section('Warnings', String(s.warnings.length), ui.text(...s.warnings)))
551
+ return blocks
534
552
  }
535
553
 
536
- /** One followed event as a line: a task's output prefixed by its id, an end, a warning. */
537
- function formatLiveEvent(e: WireEvent): string {
554
+ /** One followed event: a task's output under its id, an end, a warning. */
555
+ function formatLiveEvent(e: WireEvent): ui.Block | undefined {
538
556
  switch (e.kind) {
539
557
  case 'task:stdout':
540
558
  case 'task:stderr':
541
- return e.chunk
542
- .replace(/\n$/, '')
543
- .split('\n')
544
- .map((l) => `${e.taskId} │ ${l}\n`)
545
- .join('')
559
+ return ui.text(
560
+ ...e.chunk
561
+ .replace(/\n$/, '')
562
+ .split('\n')
563
+ .map((l): ui.Inline => [ui.id(e.taskId), ui.dim(' │ '), ui.tone('plain', l)]),
564
+ )
546
565
  case 'task:complete':
547
- return `${outcomeWord(e.outcome)} ${e.outcome.taskId} ${fmtMs(e.outcome.durationMs)}\n`
566
+ return ui.text([
567
+ ui.tone(e.outcome.status === 'failed' ? 'error' : 'ok', outcomeWord(e.outcome)),
568
+ ' ',
569
+ ui.id(e.outcome.taskId),
570
+ ui.dim(` ${fmtMs(e.outcome.durationMs)}`),
571
+ ])
548
572
  case 'run:status':
549
- return 'warn' in e ? `warn ${Bun.stripANSI(e.line)}\n` : ''
573
+ return 'warn' in e ? ui.status('warn', 'warn', Bun.stripANSI(e.line)) : undefined
550
574
  default:
551
- return ''
575
+ return undefined
552
576
  }
553
577
  }
554
578
 
@@ -556,24 +580,6 @@ const fmtWhen = (ms: number): string => new Date(ms).toISOString()
556
580
 
557
581
  const fmtMs = (ms: number): string => formatElapsed(ms, true)
558
582
 
559
- /**
560
- * What an executed task used — its peak RSS and its CPU parallelism (CPU
561
- * time over wall time) — as the runner recorded them. This is the number
562
- * `@vzn/vx-history` reserves from, so the replay shows it where
563
- * a developer can read it; a hit spent nothing and shows nothing.
564
- */
565
- function fmtUsage(t: {
566
- cpuMs: number | null
567
- peakRssBytes: number | null
568
- durationMs: number
569
- }): string {
570
- const parts: string[] = []
571
- if (t.peakRssBytes !== null) parts.push(formatBytes(t.peakRssBytes))
572
- if (t.cpuMs !== null && t.durationMs > 0)
573
- parts.push(`${(t.cpuMs / t.durationMs).toFixed(1)}× cpu`)
574
- return parts.length > 0 ? ` ${parts.join(' · ')}` : ''
575
- }
576
-
577
583
  /**
578
584
  * Why a row failed or was skipped, as the run's own footer said it (the
579
585
  * v27 columns): a timeout or a never-ready server instead of the signal
@@ -632,16 +638,17 @@ const HITS_SHOWN = 16
632
638
  * The steps the run did not wait on (`vx:prefetch`), which its own output
633
639
  * never showed (2026-10-16): here for debugging, marked `internal`.
634
640
  */
635
- function formatStepRows(steps: readonly RunStep[], c: ColorSupport): string[] {
636
- if (steps.length === 0) return []
637
- const idW = Math.max(...steps.map((s) => s.id.length), 4)
638
- return [
639
- '',
640
- ...steps.map(
641
- (s) =>
642
- ` ${paint(s.ok ? SUCCESS : ERROR, (s.ok ? 'success' : 'failed').padEnd(17), c)} ${paint('', s.id.padEnd(idW), c, { bold: true })} ${paint('', `${fmtMs(s.durationMs).padStart(8)} internal`, c, { dim: true })}`,
643
- ),
644
- ]
641
+ function formatStepRows(steps: readonly RunStep[]): ui.Block | undefined {
642
+ if (steps.length === 0) return undefined
643
+ return ui.table(
644
+ ['', '', { title: '', align: 'right' }, ''],
645
+ steps.map((s) => [
646
+ ui.tone(s.ok ? 'ok' : 'error', s.ok ? 'success' : 'failed'),
647
+ ui.strong(s.id),
648
+ fmtMs(s.durationMs),
649
+ 'internal',
650
+ ]),
651
+ )
645
652
  }
646
653
 
647
654
  /**
@@ -655,10 +662,11 @@ function formatStepRows(steps: readonly RunStep[], c: ColorSupport): string[] {
655
662
 
656
663
  export function formatTaskRows(
657
664
  tasks: readonly RunSummaryRow[],
658
- c: ColorSupport = { enabled: false },
659
665
  /** Task id → the input that moved under it, from the run's kept failures. */
660
666
  inputMoved: ReadonlyMap<string, string> = new Map(),
661
- ): string[] {
667
+ /** Task id → its usual time now, from history; a task absent was never timed. */
668
+ usual?: ReadonlyMap<string, number>,
669
+ ): ui.Block[] {
662
670
  if (tasks.length === 0) return []
663
671
  const failed = tasks.filter((t) => t.status === 'failed')
664
672
  const hits = tasks.filter((t) => t.status !== 'failed' && t.cacheHit === true)
@@ -668,50 +676,28 @@ export function formatTaskRows(
668
676
  ? [...hits].sort((a, b) => b.durationMs - a.durationMs).slice(0, HITS_SHOWN)
669
677
  : hits
670
678
  const ordered = [...failed, ...rest, ...shownHits]
671
- const at = (t: (typeof ordered)[number]): string =>
672
- t.placement !== null ? `@${t.placement}` : ''
673
- const idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}${at(t)}`.length), 4)
674
679
  const skippedHere = new Set(
675
680
  tasks.filter((t) => t.status === 'skipped').map((t) => `${t.project}#${t.task}`),
676
681
  )
677
- const lines = ['']
678
- for (const t of ordered) {
679
- const id = `${t.project}#${t.task}${at(t)}`
680
- // The terminal summary's own word for a task that runs every time
681
- // by design; a reader must not take its row for a miss.
682
- // A failure's row reads as the frame did — `failed (exit 137)` —
683
- // and above 128 names the signal the number stands for (260).
684
- // A hit reads as the run's own summary said it: up-to-date (nothing
685
- // restored) or restored-local / restored-remote.
686
- const status =
687
- t.status === 'failed'
688
- ? `failed (exit ${t.exitCode})`
689
- : t.cacheHit === true && t.restored !== null
690
- ? outcomeWord({ status: t.status as 'cache-hit', restored: t.restored })
691
- : t.status
692
- const hue = t.status === 'failed' ? ERROR : t.status === 'skipped' ? '' : SUCCESS
693
- lines.push(
694
- ` ${paint(hue, status.padEnd(17), c, { dim: hue === '' })} ${ui.inline(ui.id(id), c)}${' '.repeat(idW - id.length)} ${fmtMs(t.durationMs).padStart(8)}` +
695
- paint(
696
- '',
697
- `${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}` +
698
- reasonParts(t, skippedHere, inputMoved.get(`${t.project}#${t.task}`)),
699
- c,
700
- { dim: true },
701
- ),
702
- )
703
- }
704
- if (folded) {
705
- lines.push(
706
- paint(
707
- '',
708
- ` … +${hits.length - HITS_SHOWN} more cache hit${hits.length - HITS_SHOWN === 1 ? '' : 's'} (the ${HITS_SHOWN} slowest restores shown) — vx last --format json lists every row`,
709
- c,
710
- { dim: true },
682
+ // The run's own grid (`taskList`): a replay reads as the run did. What
683
+ // the run said below the row (an exit, why it skipped) rides it dim.
684
+ const rows = ordered.map((t) => {
685
+ const note = (
686
+ (t.status === 'failed' ? `exit ${t.exitCode}` : '') +
687
+ reasonParts(t, skippedHere, inputMoved.get(`${t.project}#${t.task}`))
688
+ ).trim()
689
+ const id = `${t.project}#${t.task}`
690
+ const usualMs = usual === undefined ? undefined : (usual.get(id) ?? null)
691
+ return (c: ui.ColorSupport) => formatRecordedRow(t, c, note, usualMs)
692
+ })
693
+ const blocks = [taskList(rows)]
694
+ if (folded)
695
+ blocks.push(
696
+ ui.text(
697
+ `… +${hits.length - HITS_SHOWN} more cache hit${hits.length - HITS_SHOWN === 1 ? '' : 's'} (the ${HITS_SHOWN} slowest restores shown): \`vx last --format json\` lists every row`,
711
698
  ),
712
699
  )
713
- }
714
- return lines
700
+ return blocks
715
701
  }
716
702
 
717
703
  /**
@@ -755,22 +741,20 @@ export function formatRunList(
755
741
  >
756
742
  id: string
757
743
  }[],
758
- c: ColorSupport = { enabled: false },
759
- ): string[] {
760
- const rows = runs.map(({ inv, id }) => ({
761
- head: `${inv.exitOk ? 'ok ' : 'FAILED'} ${fmtWhen(inv.startedAt)}`,
762
- id,
763
- counts: `${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}`,
764
- failed: inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : '',
765
- ms: fmtMs(inv.totalDurationMs),
766
- command: inv.command,
767
- }))
768
- const idW = Math.max(...rows.map((r) => r.id.length))
769
- const countsW = Math.max(...rows.map((r) => r.counts.length + r.failed.length))
770
- const msW = Math.max(...rows.map((r) => r.ms.length))
771
- return rows.map(
772
- (r) =>
773
- `${paint(r.head.startsWith('ok') ? SUCCESS : ERROR, r.head.slice(0, 6), c)}${paint('', r.head.slice(6), c, { dim: true })} ${paint('', r.id.padEnd(idW), c, { bold: true })} ${paint('', r.counts, c, { dim: true })}${paint(ERROR, r.failed, c)}${paint('', `${' '.repeat(countsW - r.counts.length - r.failed.length)} ${r.ms.padStart(msW)} $`, c, { dim: true })} ${r.command}`,
744
+ ): ui.Block {
745
+ return ui.table(
746
+ ['', 'when', 'run', 'tasks', { title: 'time', align: 'right' }, 'command'],
747
+ runs.map(({ inv, id }) => [
748
+ inv.exitOk ? ui.tone('ok', 'ok') : ui.tone('error', 'FAILED'),
749
+ fmtWhen(inv.startedAt),
750
+ ui.strong(id),
751
+ [
752
+ ui.dim(`${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}`),
753
+ ...(inv.failedCount > 0 ? [ui.tone('error', ` · ${inv.failedCount} failed`)] : []),
754
+ ],
755
+ fmtMs(inv.totalDurationMs),
756
+ ui.cmd(inv.command),
757
+ ]),
774
758
  )
775
759
  }
776
760
 
@@ -826,14 +810,26 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
826
810
  const log = taskLog(db, await bareTaskId(db, await rootSpelled(root, parsed.log)), runId)
827
811
  if (log === null) {
828
812
  // The id's form is hinted only when it lacks one: a task that
829
- // never ran was told how to spell a task id.
813
+ // never ran was told how to spell a task id. A typo of one that
814
+ // ran is named, as `vx why` names it.
815
+ const ran = parsed.log.includes('#')
816
+ ? nearest(
817
+ parsed.log,
818
+ (
819
+ db.query('SELECT DISTINCT project, task FROM runs').all() as {
820
+ project: string
821
+ task: string
822
+ }[]
823
+ ).map((r) => `${r.project}#${r.task}`),
824
+ )
825
+ : undefined
830
826
  throw new UserError(
831
- `vx last: no recorded run of ${parsed.log}${parsed.runId === undefined ? '' : ` in run ${parsed.runId}`}${parsed.log.includes('#') ? '' : ' (a task id is project#task)'}`,
827
+ `vx last: no recorded run of ${parsed.log}${parsed.runId === undefined ? '' : ` in run ${parsed.runId}`}${parsed.log.includes('#') ? '' : ' (a task id is project#task)'}${ran === undefined ? '' : ` — did you mean ${ran}?`}`,
832
828
  'VX_E_NO_HISTORY',
833
829
  )
834
830
  }
835
831
  if (parsed.format === 'json') {
836
- process.stdout.write(`${JSON.stringify(log)}\n`)
832
+ ui.data('out', `${JSON.stringify(log)}\n`)
837
833
  return 0
838
834
  }
839
835
  if (log.source === null) {
@@ -859,7 +855,8 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
859
855
  } else if (log.output === '') {
860
856
  sayNotice('info', 'vx last', `${log.taskId} ended ${log.status} and printed nothing`)
861
857
  }
862
- process.stdout.write(log.output)
858
+ // The task's own bytes, as it wrote them.
859
+ ui.data('out', log.output)
863
860
  return 0
864
861
  }
865
862
 
@@ -869,7 +866,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
869
866
  ? failedInvocations(db, parsed.list)
870
867
  : listInvocations(db, { limit: parsed.list })
871
868
  if (parsed.format === 'json') {
872
- process.stdout.write(`${JSON.stringify(invocations)}\n`)
869
+ ui.data('out', `${JSON.stringify(invocations)}\n`)
873
870
  return 0
874
871
  }
875
872
  if (invocations.length === 0) {
@@ -877,11 +874,14 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
877
874
  ui.print('out', ui.status('info', 'vx last', none))
878
875
  return 0
879
876
  }
880
- const lines = formatRunList(
881
- invocations.map((inv) => ({ inv, id: shortRunId(db, inv.runId) })),
882
- detectColors(process.stdout),
877
+ ui.print(
878
+ 'out',
879
+ ui.section(
880
+ 'Runs',
881
+ String(invocations.length),
882
+ formatRunList(invocations.map((inv) => ({ inv, id: shortRunId(db, inv.runId) }))),
883
+ ),
883
884
  )
884
- process.stdout.write(`${lines.join('\n')}\n`)
885
885
  return 0
886
886
  }
887
887
 
@@ -930,7 +930,8 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
930
930
  })
931
931
  const steps = keptSteps(db, inv.runId)
932
932
  const warnings = keptWarnings(db, inv.runId)
933
- process.stdout.write(
933
+ ui.data(
934
+ 'out',
934
935
  `${JSON.stringify({
935
936
  invocation: inv,
936
937
  tasks,
@@ -941,36 +942,48 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
941
942
  return 0
942
943
  }
943
944
 
944
- const c = detectColors(process.stdout)
945
- const dim = (s: string): string => paint('', s, c, { dim: true })
946
- const lines: string[] = []
947
- lines.push(
948
- `${paint('', 'run', c, { bold: true })} ${paint('', inv.runId, c, { bold: true })} ${dim('—')} ${inv.exitOk ? paint(SUCCESS, 'ok', c) : paint(ERROR, 'FAILED', c)}`,
949
- )
950
- lines.push(` ${dim('$')} ${paint('', inv.command, c, { bold: true })}`)
951
- lines.push(
952
- dim(
953
- ` ${fmtWhen(inv.startedAt)} · ${fmtMs(inv.totalDurationMs)}` +
954
- `${inv.branch !== null ? ` · ${inv.branch}` : ''}` +
955
- `${inv.commitSha !== null ? ` @ ${inv.commitSha.slice(0, 8)}${inv.dirty === true ? '+dirty' : ''}` : ''}` +
956
- `${inv.ci ? ` · CI${inv.ciProvider !== null ? ` (${inv.ciProvider})` : ''}` : ''}`,
957
- ),
958
- )
959
- lines.push(
960
- dim(` ${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}`) +
961
- `${inv.failedCount > 0 ? paint(ERROR, ` · ${inv.failedCount} failed`, c) : ''}`,
962
- )
963
945
  const moved = new Map<string, string>()
964
946
  if (inv.failedCount > 0) {
965
947
  for (const f of runFailures(db, inv.runId)?.tasks ?? [])
966
948
  if (f.inputMoved !== undefined) moved.set(f.taskId, f.inputMoved)
967
949
  }
968
- lines.push(...formatTaskRows(detail?.tasks ?? [], c, moved))
969
- lines.push(...formatStepRows(keptSteps(db, inv.runId), c))
950
+ const blocks: ui.Block[] = [
951
+ ui.section(
952
+ 'Run',
953
+ inv.runId,
954
+ ui.fields([
955
+ ['status', inv.exitOk ? ui.tone('ok', 'ok') : ui.tone('error', 'FAILED', true)],
956
+ ['command', ui.cmd(inv.command)],
957
+ [
958
+ 'when',
959
+ `${fmtWhen(inv.startedAt)} · ${fmtMs(inv.totalDurationMs)}` +
960
+ `${inv.branch !== null ? ` · ${inv.branch}` : ''}` +
961
+ `${inv.commitSha !== null ? ` @ ${inv.commitSha.slice(0, 8)}${inv.dirty === true ? '+dirty' : ''}` : ''}` +
962
+ `${inv.ci ? ` · CI${inv.ciProvider !== null ? ` (${inv.ciProvider})` : ''}` : ''}`,
963
+ ],
964
+ [
965
+ 'tasks',
966
+ [
967
+ ui.dim(`${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}`),
968
+ ...(inv.failedCount > 0 ? [ui.tone('error', ` · ${inv.failedCount} failed`)] : []),
969
+ ],
970
+ ],
971
+ ]),
972
+ ),
973
+ ]
974
+ // ±usual against the task's usual time now, as a run on this branch reads it.
975
+ const ranIds = (detail?.tasks ?? [])
976
+ .filter((t) => t.status === 'success' || t.status === 'failed')
977
+ .map((t) => `${t.project}#${t.task}`)
978
+ const usual = new LocalHistoryProvider(db, undefined, currentBranch(root)).p50sNow(ranIds)
979
+ const taskRows = formatTaskRows(detail?.tasks ?? [], moved, usual)
980
+ if (taskRows.length > 0)
981
+ blocks.push(ui.section('Tasks', String(detail?.tasks.length ?? 0), ...taskRows))
982
+ const stepRows = formatStepRows(keptSteps(db, inv.runId))
983
+ if (stepRows !== undefined) blocks.push(ui.section('Steps', stepRows))
970
984
  const warnings = keptWarnings(db, inv.runId)
971
- if (warnings.length > 0) {
972
- lines.push('', ' warnings', ...warnings.flatMap((w) => w.split('\n').map((l) => ` ${l}`)))
973
- }
985
+ if (warnings.length > 0)
986
+ blocks.push(ui.section('Warnings', String(warnings.length), ui.text(...warnings)))
974
987
  // The next command after reading a failed run, with the arguments the
975
988
  // run forwarded to the tasks that got them (a failure under `-- --shard
976
989
  // 2` is that shard's); a dependency never got them, so its line has none.
@@ -983,14 +996,11 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
983
996
  const ids = failed
984
997
  .filter((t) => t.forwarded === withArgs)
985
998
  .map((t) => shellQuote(`${t.project}#${t.task}`))
986
- if (ids.length > 0)
987
- rerun.push(
988
- ` ${dim('re-run what failed:')} ${paint('', `vx run ${ids.join(' ')}${withArgs ? args : ''}`, c, { bold: true })}`,
989
- )
999
+ if (ids.length > 0) rerun.push(`vx run ${ids.join(' ')}${withArgs ? args : ''}`)
990
1000
  }
991
- lines.push('', ...rerun)
1001
+ blocks.push(ui.fields([['next', ...rerun.map((r) => ui.cmd(r))]]))
992
1002
  }
993
- process.stdout.write(`${lines.join('\n')}\n`)
1003
+ ui.print('out', ...blocks)
994
1004
  return 0
995
1005
  } finally {
996
1006
  cache.close()