@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /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,18 +423,25 @@ 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
 
436
+ /** `text` as JSON, or undefined where it is not. */
437
+ function parsed(text: string): unknown {
438
+ try {
439
+ return JSON.parse(text)
440
+ } catch {
441
+ return undefined
442
+ }
443
+ }
444
+
438
445
  /** How long a run has to answer before `--live` gives up on it. */
439
446
  const LIVE_ANSWER_MS = 5_000
440
447
 
@@ -459,17 +466,38 @@ async function printLive(
459
466
  }
460
467
  let buffered = ''
461
468
  let snapshot: LiveSnapshot | undefined
469
+ /** The run answered a snapshot this vx cannot read: another vx version's. */
470
+ let unreadable = false
462
471
  let settle!: (answered: boolean) => void
463
472
  const done = new Promise<boolean>((resolve) => (settle = resolve))
464
473
  const line = (text: string): void => {
474
+ if (unreadable) return
465
475
  if (snapshot === undefined) {
466
- 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`)
476
+ const s = parsed(text) as Partial<LiveSnapshot> | undefined
477
+ const l = s?.live
478
+ if (
479
+ typeof l?.runId !== 'string' ||
480
+ typeof l.command !== 'string' ||
481
+ typeof l.startedAt !== 'number' ||
482
+ typeof l.elapsedMs !== 'number' ||
483
+ !Array.isArray(s!.tasks) ||
484
+ !Array.isArray(s!.warnings)
485
+ ) {
486
+ unreadable = true
487
+ return settle(false)
488
+ }
489
+ snapshot = s as LiveSnapshot
490
+ if (format === 'json') ui.data('out', `${text}\n`)
491
+ else ui.print('out', ...formatLive(snapshot))
469
492
  return
470
493
  }
471
- if (format === 'json') process.stdout.write(`${text}\n`)
472
- else process.stdout.write(formatLiveEvent(JSON.parse(text) as WireEvent))
494
+ if (format === 'json') ui.data('out', `${text}\n`)
495
+ else {
496
+ const e = parsed(text)
497
+ const event =
498
+ typeof e === 'object' && e !== null ? formatLiveEvent(e as WireEvent) : undefined
499
+ if (event !== undefined) ui.print('out', event)
500
+ }
473
501
  }
474
502
  try {
475
503
  await Bun.connect({
@@ -502,53 +530,81 @@ async function printLive(
502
530
  : setTimeout(() => settle(snapshot !== undefined), LIVE_ANSWER_MS)
503
531
  const answered = await done
504
532
  clearTimeout(timer)
533
+ if (unreadable)
534
+ throw refusal({
535
+ headline: 'the run in progress answered in a form this vx does not read',
536
+ fix: 'ask with the vx that started the run',
537
+ })
505
538
  if (!answered) none()
506
539
  return 0
507
540
  }
508
541
 
509
- function formatLive(s: LiveSnapshot): string[] {
542
+ function formatLive(s: LiveSnapshot): ui.Block[] {
510
543
  const count = (status: string): number => s.tasks.filter((t) => t.status === status).length
511
544
  const running = count('running')
512
545
  const waiting = count('waiting')
513
546
  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
547
  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
548
+ const blocks = [
549
+ ui.section(
550
+ 'Run',
551
+ s.live.runId,
552
+ ui.fields([
553
+ ['status', ui.tone('info', `running ${fmtMs(s.live.elapsedMs)}`)],
554
+ ['command', ui.cmd(s.live.command)],
555
+ [
556
+ 'tasks',
557
+ `${running} running · ${s.tasks.length - running - waiting} ended${failed > 0 ? ` · ${failed} failed` : ''} · ${waiting} waiting`,
558
+ ],
559
+ ]),
560
+ ),
561
+ ui.section(
562
+ 'Tasks',
563
+ String(s.tasks.length),
564
+ ui.table(
565
+ ['status', 'task', { title: 'time', align: 'right' }],
566
+ s.tasks.map((t) => {
567
+ const ms =
568
+ t.durationMs !== null ? t.durationMs : t.startedAt !== null ? now - t.startedAt : null
569
+ return [
570
+ ui.tone(
571
+ t.status === 'failed' ? 'error' : t.status === 'running' ? 'info' : 'muted',
572
+ t.status,
573
+ ),
574
+ ui.id(t.taskId),
575
+ ms === null ? '' : fmtMs(ms),
576
+ ]
577
+ }),
578
+ ),
579
+ ),
580
+ ]
581
+ if (s.warnings.length > 0)
582
+ blocks.push(ui.section('Warnings', String(s.warnings.length), ui.text(...s.warnings)))
583
+ return blocks
534
584
  }
535
585
 
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 {
586
+ /** One followed event: a task's output under its id, an end, a warning. */
587
+ function formatLiveEvent(e: WireEvent): ui.Block | undefined {
538
588
  switch (e.kind) {
539
589
  case 'task:stdout':
540
590
  case 'task:stderr':
541
- return e.chunk
542
- .replace(/\n$/, '')
543
- .split('\n')
544
- .map((l) => `${e.taskId} │ ${l}\n`)
545
- .join('')
591
+ return ui.text(
592
+ ...e.chunk
593
+ .replace(/\n$/, '')
594
+ .split('\n')
595
+ .map((l): ui.Inline => [ui.id(e.taskId), ui.dim(' │ '), ui.tone('plain', l)]),
596
+ )
546
597
  case 'task:complete':
547
- return `${outcomeWord(e.outcome)} ${e.outcome.taskId} ${fmtMs(e.outcome.durationMs)}\n`
598
+ return ui.text([
599
+ ui.tone(e.outcome.status === 'failed' ? 'error' : 'ok', outcomeWord(e.outcome)),
600
+ ' ',
601
+ ui.id(e.outcome.taskId),
602
+ ui.dim(` ${fmtMs(e.outcome.durationMs)}`),
603
+ ])
548
604
  case 'run:status':
549
- return 'warn' in e ? `warn ${Bun.stripANSI(e.line)}\n` : ''
605
+ return 'warn' in e ? ui.status('warn', 'warn', Bun.stripANSI(e.line)) : undefined
550
606
  default:
551
- return ''
607
+ return undefined
552
608
  }
553
609
  }
554
610
 
@@ -556,24 +612,6 @@ const fmtWhen = (ms: number): string => new Date(ms).toISOString()
556
612
 
557
613
  const fmtMs = (ms: number): string => formatElapsed(ms, true)
558
614
 
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
615
  /**
578
616
  * Why a row failed or was skipped, as the run's own footer said it (the
579
617
  * v27 columns): a timeout or a never-ready server instead of the signal
@@ -632,16 +670,17 @@ const HITS_SHOWN = 16
632
670
  * The steps the run did not wait on (`vx:prefetch`), which its own output
633
671
  * never showed (2026-10-16): here for debugging, marked `internal`.
634
672
  */
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
- ]
673
+ function formatStepRows(steps: readonly RunStep[]): ui.Block | undefined {
674
+ if (steps.length === 0) return undefined
675
+ return ui.table(
676
+ ['', '', { title: '', align: 'right' }, ''],
677
+ steps.map((s) => [
678
+ ui.tone(s.ok ? 'ok' : 'error', s.ok ? 'success' : 'failed'),
679
+ ui.strong(s.id),
680
+ fmtMs(s.durationMs),
681
+ 'internal',
682
+ ]),
683
+ )
645
684
  }
646
685
 
647
686
  /**
@@ -655,10 +694,11 @@ function formatStepRows(steps: readonly RunStep[], c: ColorSupport): string[] {
655
694
 
656
695
  export function formatTaskRows(
657
696
  tasks: readonly RunSummaryRow[],
658
- c: ColorSupport = { enabled: false },
659
697
  /** Task id → the input that moved under it, from the run's kept failures. */
660
698
  inputMoved: ReadonlyMap<string, string> = new Map(),
661
- ): string[] {
699
+ /** Task id → its usual time now, from history; a task absent was never timed. */
700
+ usual?: ReadonlyMap<string, number>,
701
+ ): ui.Block[] {
662
702
  if (tasks.length === 0) return []
663
703
  const failed = tasks.filter((t) => t.status === 'failed')
664
704
  const hits = tasks.filter((t) => t.status !== 'failed' && t.cacheHit === true)
@@ -668,50 +708,28 @@ export function formatTaskRows(
668
708
  ? [...hits].sort((a, b) => b.durationMs - a.durationMs).slice(0, HITS_SHOWN)
669
709
  : hits
670
710
  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
711
  const skippedHere = new Set(
675
712
  tasks.filter((t) => t.status === 'skipped').map((t) => `${t.project}#${t.task}`),
676
713
  )
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 },
714
+ // The run's own grid (`taskList`): a replay reads as the run did. What
715
+ // the run said below the row (an exit, why it skipped) rides it dim.
716
+ const rows = ordered.map((t) => {
717
+ const note = (
718
+ (t.status === 'failed' ? `exit ${t.exitCode}` : '') +
719
+ reasonParts(t, skippedHere, inputMoved.get(`${t.project}#${t.task}`))
720
+ ).trim()
721
+ const id = `${t.project}#${t.task}`
722
+ const usualMs = usual === undefined ? undefined : (usual.get(id) ?? null)
723
+ return (c: ui.ColorSupport) => formatRecordedRow(t, c, note, usualMs)
724
+ })
725
+ const blocks = [taskList(rows)]
726
+ if (folded)
727
+ blocks.push(
728
+ ui.text(
729
+ `… +${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
730
  ),
712
731
  )
713
- }
714
- return lines
732
+ return blocks
715
733
  }
716
734
 
717
735
  /**
@@ -755,22 +773,20 @@ export function formatRunList(
755
773
  >
756
774
  id: string
757
775
  }[],
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}`,
776
+ ): ui.Block {
777
+ return ui.table(
778
+ ['', 'when', 'run', 'tasks', { title: 'time', align: 'right' }, 'command'],
779
+ runs.map(({ inv, id }) => [
780
+ inv.exitOk ? ui.tone('ok', 'ok') : ui.tone('error', 'FAILED'),
781
+ fmtWhen(inv.startedAt),
782
+ ui.strong(id),
783
+ [
784
+ ui.dim(`${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}`),
785
+ ...(inv.failedCount > 0 ? [ui.tone('error', ` · ${inv.failedCount} failed`)] : []),
786
+ ],
787
+ fmtMs(inv.totalDurationMs),
788
+ ui.cmd(inv.command),
789
+ ]),
774
790
  )
775
791
  }
776
792
 
@@ -826,14 +842,26 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
826
842
  const log = taskLog(db, await bareTaskId(db, await rootSpelled(root, parsed.log)), runId)
827
843
  if (log === null) {
828
844
  // 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.
845
+ // never ran was told how to spell a task id. A typo of one that
846
+ // ran is named, as `vx why` names it.
847
+ const ran = parsed.log.includes('#')
848
+ ? nearest(
849
+ parsed.log,
850
+ (
851
+ db.query('SELECT DISTINCT project, task FROM runs').all() as {
852
+ project: string
853
+ task: string
854
+ }[]
855
+ ).map((r) => `${r.project}#${r.task}`),
856
+ )
857
+ : undefined
830
858
  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)'}`,
859
+ `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
860
  'VX_E_NO_HISTORY',
833
861
  )
834
862
  }
835
863
  if (parsed.format === 'json') {
836
- process.stdout.write(`${JSON.stringify(log)}\n`)
864
+ ui.data('out', `${JSON.stringify(log)}\n`)
837
865
  return 0
838
866
  }
839
867
  if (log.source === null) {
@@ -859,7 +887,8 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
859
887
  } else if (log.output === '') {
860
888
  sayNotice('info', 'vx last', `${log.taskId} ended ${log.status} and printed nothing`)
861
889
  }
862
- process.stdout.write(log.output)
890
+ // The task's own bytes, as it wrote them.
891
+ ui.data('out', log.output)
863
892
  return 0
864
893
  }
865
894
 
@@ -869,7 +898,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
869
898
  ? failedInvocations(db, parsed.list)
870
899
  : listInvocations(db, { limit: parsed.list })
871
900
  if (parsed.format === 'json') {
872
- process.stdout.write(`${JSON.stringify(invocations)}\n`)
901
+ ui.data('out', `${JSON.stringify(invocations)}\n`)
873
902
  return 0
874
903
  }
875
904
  if (invocations.length === 0) {
@@ -877,11 +906,14 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
877
906
  ui.print('out', ui.status('info', 'vx last', none))
878
907
  return 0
879
908
  }
880
- const lines = formatRunList(
881
- invocations.map((inv) => ({ inv, id: shortRunId(db, inv.runId) })),
882
- detectColors(process.stdout),
909
+ ui.print(
910
+ 'out',
911
+ ui.section(
912
+ 'Runs',
913
+ String(invocations.length),
914
+ formatRunList(invocations.map((inv) => ({ inv, id: shortRunId(db, inv.runId) }))),
915
+ ),
883
916
  )
884
- process.stdout.write(`${lines.join('\n')}\n`)
885
917
  return 0
886
918
  }
887
919
 
@@ -930,7 +962,8 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
930
962
  })
931
963
  const steps = keptSteps(db, inv.runId)
932
964
  const warnings = keptWarnings(db, inv.runId)
933
- process.stdout.write(
965
+ ui.data(
966
+ 'out',
934
967
  `${JSON.stringify({
935
968
  invocation: inv,
936
969
  tasks,
@@ -941,36 +974,48 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
941
974
  return 0
942
975
  }
943
976
 
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
977
  const moved = new Map<string, string>()
964
978
  if (inv.failedCount > 0) {
965
979
  for (const f of runFailures(db, inv.runId)?.tasks ?? [])
966
980
  if (f.inputMoved !== undefined) moved.set(f.taskId, f.inputMoved)
967
981
  }
968
- lines.push(...formatTaskRows(detail?.tasks ?? [], c, moved))
969
- lines.push(...formatStepRows(keptSteps(db, inv.runId), c))
982
+ const blocks: ui.Block[] = [
983
+ ui.section(
984
+ 'Run',
985
+ inv.runId,
986
+ ui.fields([
987
+ ['status', inv.exitOk ? ui.tone('ok', 'ok') : ui.tone('error', 'FAILED', true)],
988
+ ['command', ui.cmd(inv.command)],
989
+ [
990
+ 'when',
991
+ `${fmtWhen(inv.startedAt)} · ${fmtMs(inv.totalDurationMs)}` +
992
+ `${inv.branch !== null ? ` · ${inv.branch}` : ''}` +
993
+ `${inv.commitSha !== null ? ` @ ${inv.commitSha.slice(0, 8)}${inv.dirty === true ? '+dirty' : ''}` : ''}` +
994
+ `${inv.ci ? ` · CI${inv.ciProvider !== null ? ` (${inv.ciProvider})` : ''}` : ''}`,
995
+ ],
996
+ [
997
+ 'tasks',
998
+ [
999
+ ui.dim(`${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}`),
1000
+ ...(inv.failedCount > 0 ? [ui.tone('error', ` · ${inv.failedCount} failed`)] : []),
1001
+ ],
1002
+ ],
1003
+ ]),
1004
+ ),
1005
+ ]
1006
+ // ±usual against the task's usual time now, as a run on this branch reads it.
1007
+ const ranIds = (detail?.tasks ?? [])
1008
+ .filter((t) => t.status === 'success' || t.status === 'failed')
1009
+ .map((t) => `${t.project}#${t.task}`)
1010
+ const usual = new LocalHistoryProvider(db, undefined, currentBranch(root)).p50sNow(ranIds)
1011
+ const taskRows = formatTaskRows(detail?.tasks ?? [], moved, usual)
1012
+ if (taskRows.length > 0)
1013
+ blocks.push(ui.section('Tasks', String(detail?.tasks.length ?? 0), ...taskRows))
1014
+ const stepRows = formatStepRows(keptSteps(db, inv.runId))
1015
+ if (stepRows !== undefined) blocks.push(ui.section('Steps', stepRows))
970
1016
  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
- }
1017
+ if (warnings.length > 0)
1018
+ blocks.push(ui.section('Warnings', String(warnings.length), ui.text(...warnings)))
974
1019
  // The next command after reading a failed run, with the arguments the
975
1020
  // run forwarded to the tasks that got them (a failure under `-- --shard
976
1021
  // 2` is that shard's); a dependency never got them, so its line has none.
@@ -983,14 +1028,11 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
983
1028
  const ids = failed
984
1029
  .filter((t) => t.forwarded === withArgs)
985
1030
  .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
- )
1031
+ if (ids.length > 0) rerun.push(`vx run ${ids.join(' ')}${withArgs ? args : ''}`)
990
1032
  }
991
- lines.push('', ...rerun)
1033
+ blocks.push(ui.fields([['next', ...rerun.map((r) => ui.cmd(r))]]))
992
1034
  }
993
- process.stdout.write(`${lines.join('\n')}\n`)
1035
+ ui.print('out', ...blocks)
994
1036
  return 0
995
1037
  } finally {
996
1038
  cache.close()