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