@vzn/vx 0.0.646 → 0.0.649

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
package/src/cli/last.ts CHANGED
@@ -19,18 +19,36 @@ import {
19
19
  } from '../history/index.js'
20
20
  import {
21
21
  currentBranch,
22
+ type LiveSnapshot,
23
+ liveSocketPath,
22
24
  exitSignal,
23
25
  resolveRunId,
24
26
  runFailures,
25
27
  type RunStep,
26
28
  runSteps,
29
+ runWarnings,
30
+ shellQuote,
27
31
  shortRunId,
28
32
  taskLog,
29
33
  } from '../orchestrator/index.js'
30
- import { outcomeWord } from '../report/index.js'
31
- import { formatElapsed, refusal, splitTaskId, UserError, sayNotice, ui } from '../util/index.js'
34
+ import { outcomeWord, type WireEvent } from '../report/index.js'
35
+ import {
36
+ detectColors,
37
+ ERROR,
38
+ formatElapsed,
39
+ paint,
40
+ parseDecimalInt,
41
+ refusal,
42
+ splitTaskId,
43
+ SUCCESS,
44
+ UserError,
45
+ sayNotice,
46
+ ui,
47
+ type ColorSupport,
48
+ } from '../util/index.js'
32
49
  import { findWorkspaceRoot } from '../workspace/index.js'
33
- import { cliCacheDir, parseCacheDirFlag } from './workspace-config.js'
50
+ import { findCwdProject } from './select.js'
51
+ import { cliCacheDir, parseCacheDirFlag, rootSpelled } from './workspace-config.js'
34
52
 
35
53
  interface LastArgs {
36
54
  runId?: string
@@ -48,6 +66,12 @@ interface LastArgs {
48
66
  branch?: string
49
67
  /** `--cache-dir`: read the history a run with the same flag wrote. */
50
68
  cacheDir?: string
69
+ /** `--sql <query>`: the history's rows a read-only query selects. */
70
+ sql?: string
71
+ /** `--live`: the run in progress, from the run itself. */
72
+ live?: boolean
73
+ /** `--follow`: with `--live`, the run's events from now to its end. */
74
+ follow?: boolean
51
75
  error?: string
52
76
  }
53
77
 
@@ -77,8 +101,13 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
77
101
  (!RUN_ID_SHAPE.test(next) && (!next.startsWith('-') || /^-\d/.test(next))))
78
102
  const lv = a === '--list' ? (spaced ? next : '10') : a.slice(7)
79
103
  if (spaced) i++
80
- const n = Number(lv)
81
- if (!Number.isInteger(n) || n < 1 || n > 500) {
104
+ if (lv === '')
105
+ return {
106
+ ...out,
107
+ error: `--list= requires a count from 1 to 500; bare --list shows 10${seeHelp('last')}`,
108
+ }
109
+ const n = parseDecimalInt(lv)
110
+ if (n === null || n < 1 || n > 500) {
82
111
  return {
83
112
  ...out,
84
113
  error: `--list must be a whole number from 1 to 500 (got ${lv})${seeHelp('last')}`,
@@ -95,6 +124,25 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
95
124
  out.stats = true
96
125
  continue
97
126
  }
127
+ if (a === '--live') {
128
+ out.live = true
129
+ continue
130
+ }
131
+ if (a === '--follow') {
132
+ out.follow = true
133
+ continue
134
+ }
135
+ if (a === '--sql' || a.startsWith('--sql=')) {
136
+ const v = a === '--sql' ? args[++i] : a.slice('--sql='.length)
137
+ if (v === undefined || v.trim() === '') {
138
+ return {
139
+ ...out,
140
+ error: `--sql requires a query, like 'SELECT * FROM runs'${seeHelp('last')}`,
141
+ }
142
+ }
143
+ out.sql = v
144
+ continue
145
+ }
98
146
  if (a === '--branch' || a.startsWith('--branch=')) {
99
147
  const v = a === '--branch' ? args[++i] : a.slice('--branch='.length)
100
148
  if (v === undefined || v === '' || (a === '--branch' && v.startsWith('-'))) {
@@ -105,6 +153,9 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
105
153
  }
106
154
  if (a === '--log') {
107
155
  const v = args[i + 1]
156
+ if (v === '') {
157
+ return { ...out, error: `--log requires a task id, like app#build${seeHelp('last')}` }
158
+ }
108
159
  if (v === undefined || v.startsWith('-')) bareLog = true
109
160
  else {
110
161
  out.log = v
@@ -135,10 +186,35 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
135
186
  }
136
187
  if (a.startsWith('-'))
137
188
  return { ...out, error: `unknown flag: ${a}${flagHint('last', a)}${seeHelp('last')}` }
189
+ // Empty is no run id: '' is the start of every one.
190
+ if (a === '') continue
138
191
  if (out.runId !== undefined)
139
192
  return { ...out, error: `unexpected argument: ${a}${seeHelp('last')}` }
140
193
  out.runId = a
141
194
  }
195
+ if (out.follow === true && out.live !== true) {
196
+ return { ...out, error: `--follow needs --live${seeHelp('last')}` }
197
+ }
198
+ if (out.live === true) {
199
+ const other = [out.runId, out.list, out.failed, out.log, out.stats, out.branch, out.sql]
200
+ if (bareLog || other.some((v) => v !== undefined)) {
201
+ return {
202
+ ...out,
203
+ error: `--live combines only with --follow, --format and --cache-dir${seeHelp('last')}`,
204
+ }
205
+ }
206
+ return out
207
+ }
208
+ if (out.sql !== undefined) {
209
+ const other = [out.runId, out.list, out.failed, out.log, out.stats, out.branch]
210
+ if (bareLog || other.some((v) => v !== undefined)) {
211
+ return {
212
+ ...out,
213
+ error: `--sql combines only with --format and --cache-dir${seeHelp('last')}`,
214
+ }
215
+ }
216
+ return out
217
+ }
142
218
  if (bareLog) {
143
219
  if (out.runId === undefined) {
144
220
  return { ...out, error: `--log requires a task id, like app#build${seeHelp('last')}` }
@@ -266,9 +342,14 @@ async function printStats(
266
342
  ]),
267
343
  ]
268
344
  const widths = rows[0]!.map((_, c) => Math.max(...rows.map((r) => r[c]!.length)))
269
- const lines = rows.map((r) =>
345
+ const c = detectColors(process.stdout)
346
+ const lines = rows.map((r, i) =>
270
347
  r
271
- .map((cell, c) => cell.padEnd(widths[c]!))
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
+ })
272
353
  .join(' ')
273
354
  .trimEnd(),
274
355
  )
@@ -276,6 +357,203 @@ async function printStats(
276
357
  return 0
277
358
  }
278
359
 
360
+ /**
361
+ * `--sql`: one SELECT over the history (`runs`, `invocations`, and the
362
+ * store's `entries`, `entry_inputs`, `task_durations`, `task_outcomes`),
363
+ * so an agent asks what no flag answers: a task's memory trend, the
364
+ * flakiest project, which input moved a key. `query_only` stops a write
365
+ * the statement hides (`WITH … DELETE`); it does not stop ATTACH, which
366
+ * creates a file, hence only SELECT or WITH may lead.
367
+ */
368
+ function printQuery(db: Database, sql: string, format: 'pretty' | 'json'): number {
369
+ if (!/^\s*(select|with)\b/i.test(sql)) {
370
+ throw new UserError(
371
+ `vx last: --sql reads only: one SELECT (or WITH … SELECT) over the history`,
372
+ 'VX_E_USAGE',
373
+ )
374
+ }
375
+ db.exec('PRAGMA query_only = ON')
376
+ const refused = (err: unknown): never => {
377
+ const message = err instanceof Error ? err.message : String(err)
378
+ if (/readonly|query_only/i.test(message)) {
379
+ throw new UserError(`vx last: --sql reads only: ${message}`, 'VX_E_USAGE')
380
+ }
381
+ throw new UserError(`vx last: --sql: ${message}`, 'VX_E_USAGE')
382
+ }
383
+ let stmt: ReturnType<Database['query']>
384
+ try {
385
+ stmt = db.query(sql)
386
+ } catch (err) {
387
+ return refused(err)
388
+ }
389
+ // SQLite prepares the first statement and drops the rest unread. Its
390
+ // text is the statement's own (a bound `?` expanded, hence the guard).
391
+ const own = stmt.toString()
392
+ const tail = sql.startsWith(own) ? sql.slice(own.length) : ''
393
+ if (tail.replace(/--[^\n]*|\/\*[^]*?\*\/|[\s;]/g, '') !== '') {
394
+ throw new UserError(
395
+ `vx last: --sql takes one statement, and ${JSON.stringify(tail.replace(/^[\s;]+/, '').trimEnd())} follows the first: run each in its own --sql`,
396
+ 'VX_E_USAGE',
397
+ )
398
+ }
399
+ // Read by position: a join's `SELECT *` names `run_id` twice, and rows
400
+ // keyed by name kept one value per name.
401
+ const columns = stmt.columnNames
402
+ let values: unknown[][]
403
+ try {
404
+ values = stmt.values() as unknown[][]
405
+ } catch (err) {
406
+ return refused(err)
407
+ }
408
+ if (format === 'json') {
409
+ const twice = columns.find((c, i) => columns.indexOf(c) !== i)
410
+ if (twice !== undefined) {
411
+ throw new UserError(
412
+ `vx last: --sql: column ${JSON.stringify(twice)} is named twice, and a JSON row holds one value per name; name each with AS`,
413
+ 'VX_E_USAGE',
414
+ )
415
+ }
416
+ const rows = values.map((v) => Object.fromEntries(columns.map((c, i) => [c, v[i]])))
417
+ process.stdout.write(`${JSON.stringify({ rows })}\n`)
418
+ return 0
419
+ }
420
+ if (values.length === 0) return 0
421
+ // A column's value as SQLite returns it: TEXT, a number, NULL or a BLOB.
422
+ const cell = (v: string | number | bigint | Uint8Array | null): string =>
423
+ v === null || v === undefined
424
+ ? '-'
425
+ : v instanceof Uint8Array
426
+ ? `<${v.length} bytes>`
427
+ : 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(),
435
+ )
436
+ process.stdout.write(`${lines.join('\n')}\n`)
437
+ return 0
438
+ }
439
+
440
+ /** How long a run has to answer before `--live` gives up on it. */
441
+ const LIVE_ANSWER_MS = 5_000
442
+
443
+ /**
444
+ * `--live`: ask the run in progress (orchestrator/run-live.ts) for its
445
+ * snapshot, and with `--follow` print its events until it lets go. The
446
+ * history is not opened: the run itself answers.
447
+ */
448
+ async function printLive(
449
+ root: string,
450
+ follow: boolean,
451
+ format: 'pretty' | 'json',
452
+ ): Promise<number> {
453
+ const none = (): never => {
454
+ throw refusal(
455
+ { tone: 'info', headline: 'no run in progress', fix: 'vx last' },
456
+ {
457
+ code: 'VX_E_NO_HISTORY',
458
+ message: 'vx last: no run in progress — `vx last` reads the last one',
459
+ },
460
+ )
461
+ }
462
+ let buffered = ''
463
+ let snapshot: LiveSnapshot | undefined
464
+ let settle!: (answered: boolean) => void
465
+ const done = new Promise<boolean>((resolve) => (settle = resolve))
466
+ const line = (text: string): void => {
467
+ if (snapshot === undefined) {
468
+ 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`)
471
+ return
472
+ }
473
+ if (format === 'json') process.stdout.write(`${text}\n`)
474
+ else process.stdout.write(formatLiveEvent(JSON.parse(text) as WireEvent))
475
+ }
476
+ try {
477
+ await Bun.connect({
478
+ unix: liveSocketPath(root),
479
+ socket: {
480
+ open(sock) {
481
+ sock.write(follow ? 'follow\n' : 'snapshot\n')
482
+ },
483
+ data(_sock, chunk) {
484
+ buffered += chunk.toString()
485
+ for (let at = buffered.indexOf('\n'); at !== -1; at = buffered.indexOf('\n')) {
486
+ line(buffered.slice(0, at))
487
+ buffered = buffered.slice(at + 1)
488
+ }
489
+ },
490
+ close() {
491
+ settle(snapshot !== undefined)
492
+ },
493
+ error() {
494
+ settle(snapshot !== undefined)
495
+ },
496
+ },
497
+ })
498
+ } catch {
499
+ // ENOENT, ECONNREFUSED: no run listens there (a killed run's socket).
500
+ none()
501
+ }
502
+ const timer = follow
503
+ ? undefined
504
+ : setTimeout(() => settle(snapshot !== undefined), LIVE_ANSWER_MS)
505
+ const answered = await done
506
+ clearTimeout(timer)
507
+ if (!answered) none()
508
+ return 0
509
+ }
510
+
511
+ function formatLive(s: LiveSnapshot): string[] {
512
+ const count = (status: string): number => s.tasks.filter((t) => t.status === status).length
513
+ const running = count('running')
514
+ const waiting = count('waiting')
515
+ 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
+ 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
536
+ }
537
+
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 {
540
+ switch (e.kind) {
541
+ case 'task:stdout':
542
+ case 'task:stderr':
543
+ return e.chunk
544
+ .replace(/\n$/, '')
545
+ .split('\n')
546
+ .map((l) => `${e.taskId} │ ${l}\n`)
547
+ .join('')
548
+ case 'task:complete':
549
+ return `${outcomeWord(e.outcome)} ${e.outcome.taskId} ${fmtMs(e.outcome.durationMs)}\n`
550
+ case 'run:status':
551
+ return 'warn' in e ? `warn ${Bun.stripANSI(e.line)}\n` : ''
552
+ default:
553
+ return ''
554
+ }
555
+ }
556
+
279
557
  const fmtWhen = (ms: number): string => new Date(ms).toISOString()
280
558
 
281
559
  const fmtMs = (ms: number): string => formatElapsed(ms, true)
@@ -283,7 +561,7 @@ const fmtMs = (ms: number): string => formatElapsed(ms, true)
283
561
  /**
284
562
  * What an executed task used — its peak RSS and its CPU parallelism (CPU
285
563
  * time over wall time) — as the runner recorded them. This is the number
286
- * `@vzn/vx-schedule-history` reserves from, so the replay shows it where
564
+ * `@vzn/vx-history` reserves from, so the replay shows it where
287
565
  * a developer can read it; a hit spent nothing and shows nothing.
288
566
  */
289
567
  function fmtUsage(t: {
@@ -304,13 +582,21 @@ function fmtUsage(t: {
304
582
  * their exit stands for, the sandbox's violation count, a skip's blocker.
305
583
  * Older rows carry none and read as before.
306
584
  */
307
- function reasonParts(t: RunSummaryRow): string {
585
+ function reasonParts(
586
+ t: RunSummaryRow,
587
+ skippedHere: ReadonlySet<string>,
588
+ inputMoved: string | undefined,
589
+ ): string {
308
590
  const parts: string[] = []
309
591
  if (t.status === 'failed') {
310
592
  if (t.notReady !== null) {
311
593
  parts.push(
312
594
  `never ready: ${t.notReady === 'timeout' ? 'timed out' : t.notReady === 'exited' ? 'exited' : 'spawn failed'}`,
313
595
  )
596
+ } else if (inputMoved !== undefined) {
597
+ // `inputStability.severity: 'error'` forced the exit; the run keeps
598
+ // the input with the failure's output, not in the row.
599
+ parts.push(`${inputMoved === '' ? 'an input' : inputMoved} changed during the run`)
314
600
  } else if (t.timedOut === true) {
315
601
  parts.push('timed out')
316
602
  } else {
@@ -321,27 +607,41 @@ function reasonParts(t: RunSummaryRow): string {
321
607
  parts.push(`${t.sandboxViolations} sandbox violation${t.sandboxViolations === 1 ? '' : 's'}`)
322
608
  }
323
609
  } else if (t.status === 'skipped' && t.blockedBy !== null) {
324
- parts.push(`after ${t.blockedBy} failed`)
610
+ // A blocker that is itself a skip is a platform skip: a failure's
611
+ // dependants name the failure, never a skip between.
612
+ parts.push(
613
+ skippedHere.has(t.blockedBy)
614
+ ? `after ${t.blockedBy} skipped: ${OTHER_PLATFORM}`
615
+ : `after ${t.blockedBy} failed`,
616
+ )
617
+ } else if (t.status === 'skipped' && t.exitCode === 0) {
618
+ // Exit 0 with no blocker is a platform skip; a fail-fast skip records 1.
619
+ parts.push(OTHER_PLATFORM)
325
620
  }
621
+ // The run's own row said `flaky - 2 attempts`: a pass that needed a
622
+ // retry is the one sign of a flaky task a replay can give.
623
+ if (t.attempts !== null && t.attempts > 1) parts.push(`${t.attempts} attempts`)
326
624
  if (t.remoteError !== null) parts.push(`remote \u2717 ${t.remoteError}`)
327
625
  return parts.map((p) => ` ${p}`).join('')
328
626
  }
329
627
 
628
+ const OTHER_PLATFORM = 'needs another platform'
629
+
330
630
  /** Hit rows shown before the rest fold — the slowest restores. */
331
631
  const HITS_SHOWN = 16
332
632
 
333
633
  /**
334
634
  * The steps the run did not wait on (`vx:prefetch`), which its own output
335
- * never showed (M, 2026-10-16): here for debugging, marked `internal`.
635
+ * never showed (2026-10-16): here for debugging, marked `internal`.
336
636
  */
337
- function formatStepRows(steps: readonly RunStep[]): string[] {
637
+ function formatStepRows(steps: readonly RunStep[], c: ColorSupport): string[] {
338
638
  if (steps.length === 0) return []
339
639
  const idW = Math.max(...steps.map((s) => s.id.length), 4)
340
640
  return [
341
641
  '',
342
642
  ...steps.map(
343
643
  (s) =>
344
- ` ${(s.ok ? 'success' : 'failed').padEnd(17)} ${s.id.padEnd(idW)} ${fmtMs(s.durationMs).padStart(8)} internal`,
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 })}`,
345
645
  ),
346
646
  ]
347
647
  }
@@ -355,7 +655,12 @@ function formatStepRows(steps: readonly RunStep[]): string[] {
355
655
  * row tells. `--format json` lists every row.
356
656
  */
357
657
 
358
- export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
658
+ export function formatTaskRows(
659
+ tasks: readonly RunSummaryRow[],
660
+ c: ColorSupport = { enabled: false },
661
+ /** Task id → the input that moved under it, from the run's kept failures. */
662
+ inputMoved: ReadonlyMap<string, string> = new Map(),
663
+ ): string[] {
359
664
  if (tasks.length === 0) return []
360
665
  const failed = tasks.filter((t) => t.status === 'failed')
361
666
  const hits = tasks.filter((t) => t.status !== 'failed' && t.cacheHit === true)
@@ -368,6 +673,9 @@ export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
368
673
  const at = (t: (typeof ordered)[number]): string =>
369
674
  t.placement !== null ? `@${t.placement}` : ''
370
675
  const idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}${at(t)}`.length), 4)
676
+ const skippedHere = new Set(
677
+ tasks.filter((t) => t.status === 'skipped').map((t) => `${t.project}#${t.task}`),
678
+ )
371
679
  const lines = ['']
372
680
  for (const t of ordered) {
373
681
  const id = `${t.project}#${t.task}${at(t)}`
@@ -383,15 +691,26 @@ export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
383
691
  : t.cacheHit === true && t.restored !== null
384
692
  ? outcomeWord({ status: t.status as 'cache-hit', restored: t.restored })
385
693
  : t.status
694
+ const hue = t.status === 'failed' ? ERROR : t.status === 'skipped' ? '' : SUCCESS
386
695
  lines.push(
387
- ` ${status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
388
- `${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}` +
389
- reasonParts(t),
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
+ ),
390
704
  )
391
705
  }
392
706
  if (folded) {
393
707
  lines.push(
394
- ` … +${hits.length - HITS_SHOWN} more cache hits (the ${HITS_SHOWN} slowest restores shown) — vx last --format json lists every row`,
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 },
713
+ ),
395
714
  )
396
715
  }
397
716
  return lines
@@ -438,22 +757,43 @@ export function formatRunList(
438
757
  >
439
758
  id: string
440
759
  }[],
760
+ c: ColorSupport = { enabled: false },
441
761
  ): string[] {
442
762
  const rows = runs.map(({ inv, id }) => ({
443
763
  head: `${inv.exitOk ? 'ok ' : 'FAILED'} ${fmtWhen(inv.startedAt)}`,
444
764
  id,
445
- counts:
446
- `${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}` +
447
- (inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''),
765
+ counts: `${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}`,
766
+ failed: inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : '',
448
767
  ms: fmtMs(inv.totalDurationMs),
449
768
  command: inv.command,
450
769
  }))
451
770
  const idW = Math.max(...rows.map((r) => r.id.length))
452
- const countsW = Math.max(...rows.map((r) => r.counts.length))
771
+ const countsW = Math.max(...rows.map((r) => r.counts.length + r.failed.length))
453
772
  const msW = Math.max(...rows.map((r) => r.ms.length))
454
773
  return rows.map(
455
774
  (r) =>
456
- `${r.head} ${r.id.padEnd(idW)} ${r.counts.padEnd(countsW)} ${r.ms.padStart(msW)} $ ${r.command}`,
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}`,
776
+ )
777
+ }
778
+
779
+ /**
780
+ * A bare task name as `--log` reads it: the one project that ran it, the
781
+ * cwd's among several (as `vx why` picks), else refused naming them. It
782
+ * read as an id and said "no recorded run of build" for a task that ran.
783
+ */
784
+ async function bareTaskId(db: Database, task: string): Promise<string> {
785
+ if (task.includes('#')) return task
786
+ const ids = (
787
+ db.query('SELECT DISTINCT project FROM runs WHERE task = ? ORDER BY project').all(task) as {
788
+ project: string
789
+ }[]
790
+ ).map((r) => `${r.project}#${task}`)
791
+ if (ids.length <= 1) return ids[0] ?? task
792
+ const here = await findCwdProject(process.cwd())
793
+ if (here !== null && ids.includes(`${here}#${task}`)) return `${here}#${task}`
794
+ throw new UserError(
795
+ `vx last: "${task}" ran in ${ids.length} projects — pick one:\n${ids.map((id) => ` ${id}`).join('\n')}`,
796
+ 'VX_E_AMBIGUOUS',
457
797
  )
458
798
  }
459
799
 
@@ -462,6 +802,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
462
802
  if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`, 'VX_E_USAGE')
463
803
 
464
804
  const root = await findWorkspaceRoot(process.cwd())
805
+ if (parsed.live === true) return await printLive(root, parsed.follow === true, parsed.format)
465
806
  const cacheDir = await cliCacheDir(root, parsed.cacheDir)
466
807
  const cache = Cache.inspect(cacheDir)
467
808
  try {
@@ -471,9 +812,12 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
471
812
  // nothing, while `--list --failed` past green runs said there were none.
472
813
  const noRuns = (): boolean => listInvocations(db, { limit: 1 }).length === 0
473
814
 
815
+ if (parsed.sql !== undefined) return printQuery(db, parsed.sql, parsed.format)
816
+
474
817
  if (parsed.stats === true) {
475
818
  const branch = currentBranch(root, undefined, parsed.branch)
476
- return await printStats(db, parsed.task, parsed.format, branch)
819
+ const task = parsed.task === undefined ? undefined : await rootSpelled(root, parsed.task)
820
+ return await printStats(db, task, parsed.format, branch)
477
821
  }
478
822
 
479
823
  if (parsed.log !== undefined) {
@@ -481,10 +825,18 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
481
825
  parsed.runId === undefined
482
826
  ? undefined
483
827
  : (resolveRunId(db, parsed.runId, 'vx last') ?? parsed.runId)
484
- const log = taskLog(cacheDir, db, parsed.log, runId)
828
+ const log = taskLog(
829
+ cacheDir,
830
+ db,
831
+ await bareTaskId(db, await rootSpelled(root, parsed.log)),
832
+ runId,
833
+ )
485
834
  if (log === null) {
835
+ // 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.
486
837
  throw new UserError(
487
- `vx last: no recorded run of ${parsed.log}${parsed.runId === undefined ? '' : ` in run ${parsed.runId}`} (a task id is project#task)`,
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)'}`,
839
+ 'VX_E_NO_HISTORY',
488
840
  )
489
841
  }
490
842
  if (parsed.format === 'json') {
@@ -492,11 +844,27 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
492
844
  return 0
493
845
  }
494
846
  if (log.source === null) {
847
+ // A cached task with no entry was not saved (a read-only policy) or
848
+ // was pruned; calling it uncached sends the user to the config.
849
+ const [project, task] = splitTaskId(log.taskId)
850
+ const cached =
851
+ log.status !== 'failed' &&
852
+ (
853
+ db
854
+ .query('SELECT cached FROM runs WHERE run_id = ? AND project = ? AND task = ?')
855
+ .get(log.runId, project, task) as { cached: number | null } | null
856
+ )?.cached === 1
495
857
  sayNotice(
496
858
  'info',
497
859
  'vx last',
498
- `${log.taskId} ended ${log.status}. vx keeps output only for failed and cached tasks`,
860
+ log.status === 'failed'
861
+ ? `${log.taskId} ended failed, and vx kept no output for it: it keeps the newest 50 failed runs`
862
+ : cached
863
+ ? `${log.taskId} ended ${log.status}, and its entry is not in the cache: the run did not save it, or it was pruned since`
864
+ : `${log.taskId} ended ${log.status}. vx keeps output only for failed and cached tasks`,
499
865
  )
866
+ } else if (log.output === '') {
867
+ sayNotice('info', 'vx last', `${log.taskId} ended ${log.status} and printed nothing`)
500
868
  }
501
869
  process.stdout.write(log.output)
502
870
  return 0
@@ -518,6 +886,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
518
886
  }
519
887
  const lines = formatRunList(
520
888
  invocations.map((inv) => ({ inv, id: shortRunId(db, inv.runId) })),
889
+ detectColors(process.stdout),
521
890
  )
522
891
  process.stdout.write(`${lines.join('\n')}\n`)
523
892
  return 0
@@ -539,11 +908,13 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
539
908
  throw new UserError(
540
909
  noRuns()
541
910
  ? 'vx last: no recorded runs yet — run something first'
542
- : parsed.runId !== undefined
543
- ? `vx last: no recorded run ${parsed.runId} (vx last --list shows recent runs)`
544
- : parsed.failed === true
545
- ? 'vx last: no recorded run failed'
546
- : 'vx last: no recorded runs yet — run something first',
911
+ : parsed.runId?.includes('#') === true
912
+ ? `vx last: ${parsed.runId} is a task, not a run id — \`vx last --log ${parsed.runId}\` prints its output, \`vx last --stats ${parsed.runId}\` its durations`
913
+ : parsed.runId !== undefined
914
+ ? `vx last: no recorded run ${parsed.runId} (vx last --list shows recent runs)`
915
+ : parsed.failed === true
916
+ ? 'vx last: no recorded run failed'
917
+ : 'vx last: no recorded runs yet — run something first',
547
918
  'VX_E_NO_HISTORY',
548
919
  )
549
920
  }
@@ -567,27 +938,48 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
567
938
  }
568
939
  })
569
940
  const steps = runSteps(cacheDir, inv.runId)
941
+ const warnings = runWarnings(cacheDir, inv.runId)
570
942
  process.stdout.write(
571
- `${JSON.stringify({ invocation: inv, tasks, ...(steps.length > 0 ? { steps } : {}) })}\n`,
943
+ `${JSON.stringify({
944
+ invocation: inv,
945
+ tasks,
946
+ ...(steps.length > 0 ? { steps } : {}),
947
+ ...(warnings.length > 0 ? { warnings } : {}),
948
+ })}\n`,
572
949
  )
573
950
  return 0
574
951
  }
575
952
 
953
+ const c = detectColors(process.stdout)
954
+ const dim = (s: string): string => paint('', s, c, { dim: true })
576
955
  const lines: string[] = []
577
- lines.push(`run ${inv.runId} — ${inv.exitOk ? 'ok' : 'FAILED'}`)
578
- lines.push(` $ ${inv.command}`)
579
956
  lines.push(
580
- ` ${fmtWhen(inv.startedAt)} · ${fmtMs(inv.totalDurationMs)}` +
581
- `${inv.branch !== null ? ` · ${inv.branch}` : ''}` +
582
- `${inv.commitSha !== null ? ` @ ${inv.commitSha.slice(0, 8)}${inv.dirty === true ? '+dirty' : ''}` : ''}` +
583
- `${inv.ci ? ` · CI${inv.ciProvider !== null ? ` (${inv.ciProvider})` : ''}` : ''}`,
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
+ ),
584
967
  )
585
968
  lines.push(
586
- ` ${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}` +
587
- `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
969
+ dim(` ${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}`) +
970
+ `${inv.failedCount > 0 ? paint(ERROR, ` · ${inv.failedCount} failed`, c) : ''}`,
588
971
  )
589
- lines.push(...formatTaskRows(detail?.tasks ?? []))
590
- lines.push(...formatStepRows(runSteps(cacheDir, inv.runId)))
972
+ const moved = new Map<string, string>()
973
+ if (inv.failedCount > 0) {
974
+ for (const f of runFailures(cacheDir, db, inv.runId)?.tasks ?? [])
975
+ if (f.inputMoved !== undefined) moved.set(f.taskId, f.inputMoved)
976
+ }
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
+ }
591
983
  // The next command after reading a failed run, with the arguments the
592
984
  // run forwarded to the tasks that got them (a failure under `-- --shard
593
985
  // 2` is that shard's); a dependency never got them, so its line has none.
@@ -599,9 +991,11 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
599
991
  for (const withArgs of [false, true]) {
600
992
  const ids = failed
601
993
  .filter((t) => t.forwarded === withArgs)
602
- .map((t) => `${t.project}#${t.task}`)
994
+ .map((t) => shellQuote(`${t.project}#${t.task}`))
603
995
  if (ids.length > 0)
604
- rerun.push(` re-run what failed: vx run ${ids.join(' ')}${withArgs ? args : ''}`)
996
+ rerun.push(
997
+ ` ${dim('re-run what failed:')} ${paint('', `vx run ${ids.join(' ')}${withArgs ? args : ''}`, c, { bold: true })}`,
998
+ )
605
999
  }
606
1000
  lines.push('', ...rerun)
607
1001
  }