@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
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)
@@ -365,10 +670,15 @@ export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
365
670
  ? [...hits].sort((a, b) => b.durationMs - a.durationMs).slice(0, HITS_SHOWN)
366
671
  : hits
367
672
  const ordered = [...failed, ...rest, ...shownHits]
368
- const idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}`.length), 4)
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
+ const skippedHere = new Set(
677
+ tasks.filter((t) => t.status === 'skipped').map((t) => `${t.project}#${t.task}`),
678
+ )
369
679
  const lines = ['']
370
680
  for (const t of ordered) {
371
- const id = `${t.project}#${t.task}`
681
+ const id = `${t.project}#${t.task}${at(t)}`
372
682
  // The terminal summary's own word for a task that runs every time
373
683
  // by design; a reader must not take its row for a miss.
374
684
  // A failure's row reads as the frame did — `failed (exit 137)` —
@@ -381,15 +691,26 @@ export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
381
691
  : t.cacheHit === true && t.restored !== null
382
692
  ? outcomeWord({ status: t.status as 'cache-hit', restored: t.restored })
383
693
  : t.status
694
+ const hue = t.status === 'failed' ? ERROR : t.status === 'skipped' ? '' : SUCCESS
384
695
  lines.push(
385
- ` ${status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
386
- `${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}` +
387
- 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
+ ),
388
704
  )
389
705
  }
390
706
  if (folded) {
391
707
  lines.push(
392
- ` … +${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
+ ),
393
714
  )
394
715
  }
395
716
  return lines
@@ -436,22 +757,43 @@ export function formatRunList(
436
757
  >
437
758
  id: string
438
759
  }[],
760
+ c: ColorSupport = { enabled: false },
439
761
  ): string[] {
440
762
  const rows = runs.map(({ inv, id }) => ({
441
763
  head: `${inv.exitOk ? 'ok ' : 'FAILED'} ${fmtWhen(inv.startedAt)}`,
442
764
  id,
443
- counts:
444
- `${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}` +
445
- (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` : '',
446
767
  ms: fmtMs(inv.totalDurationMs),
447
768
  command: inv.command,
448
769
  }))
449
770
  const idW = Math.max(...rows.map((r) => r.id.length))
450
- const countsW = Math.max(...rows.map((r) => r.counts.length))
771
+ const countsW = Math.max(...rows.map((r) => r.counts.length + r.failed.length))
451
772
  const msW = Math.max(...rows.map((r) => r.ms.length))
452
773
  return rows.map(
453
774
  (r) =>
454
- `${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',
455
797
  )
456
798
  }
457
799
 
@@ -460,6 +802,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
460
802
  if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`, 'VX_E_USAGE')
461
803
 
462
804
  const root = await findWorkspaceRoot(process.cwd())
805
+ if (parsed.live === true) return await printLive(root, parsed.follow === true, parsed.format)
463
806
  const cacheDir = await cliCacheDir(root, parsed.cacheDir)
464
807
  const cache = Cache.inspect(cacheDir)
465
808
  try {
@@ -469,9 +812,12 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
469
812
  // nothing, while `--list --failed` past green runs said there were none.
470
813
  const noRuns = (): boolean => listInvocations(db, { limit: 1 }).length === 0
471
814
 
815
+ if (parsed.sql !== undefined) return printQuery(db, parsed.sql, parsed.format)
816
+
472
817
  if (parsed.stats === true) {
473
818
  const branch = currentBranch(root, undefined, parsed.branch)
474
- 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)
475
821
  }
476
822
 
477
823
  if (parsed.log !== undefined) {
@@ -479,10 +825,18 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
479
825
  parsed.runId === undefined
480
826
  ? undefined
481
827
  : (resolveRunId(db, parsed.runId, 'vx last') ?? parsed.runId)
482
- 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
+ )
483
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.
484
837
  throw new UserError(
485
- `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',
486
840
  )
487
841
  }
488
842
  if (parsed.format === 'json') {
@@ -490,11 +844,27 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
490
844
  return 0
491
845
  }
492
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
493
857
  sayNotice(
494
858
  'info',
495
859
  'vx last',
496
- `${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`,
497
865
  )
866
+ } else if (log.output === '') {
867
+ sayNotice('info', 'vx last', `${log.taskId} ended ${log.status} and printed nothing`)
498
868
  }
499
869
  process.stdout.write(log.output)
500
870
  return 0
@@ -516,6 +886,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
516
886
  }
517
887
  const lines = formatRunList(
518
888
  invocations.map((inv) => ({ inv, id: shortRunId(db, inv.runId) })),
889
+ detectColors(process.stdout),
519
890
  )
520
891
  process.stdout.write(`${lines.join('\n')}\n`)
521
892
  return 0
@@ -537,11 +908,13 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
537
908
  throw new UserError(
538
909
  noRuns()
539
910
  ? 'vx last: no recorded runs yet — run something first'
540
- : parsed.runId !== undefined
541
- ? `vx last: no recorded run ${parsed.runId} (vx last --list shows recent runs)`
542
- : parsed.failed === true
543
- ? 'vx last: no recorded run failed'
544
- : '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',
545
918
  'VX_E_NO_HISTORY',
546
919
  )
547
920
  }
@@ -565,27 +938,48 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
565
938
  }
566
939
  })
567
940
  const steps = runSteps(cacheDir, inv.runId)
941
+ const warnings = runWarnings(cacheDir, inv.runId)
568
942
  process.stdout.write(
569
- `${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`,
570
949
  )
571
950
  return 0
572
951
  }
573
952
 
953
+ const c = detectColors(process.stdout)
954
+ const dim = (s: string): string => paint('', s, c, { dim: true })
574
955
  const lines: string[] = []
575
- lines.push(`run ${inv.runId} — ${inv.exitOk ? 'ok' : 'FAILED'}`)
576
- lines.push(` $ ${inv.command}`)
577
956
  lines.push(
578
- ` ${fmtWhen(inv.startedAt)} · ${fmtMs(inv.totalDurationMs)}` +
579
- `${inv.branch !== null ? ` · ${inv.branch}` : ''}` +
580
- `${inv.commitSha !== null ? ` @ ${inv.commitSha.slice(0, 8)}${inv.dirty === true ? '+dirty' : ''}` : ''}` +
581
- `${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
+ ),
582
967
  )
583
968
  lines.push(
584
- ` ${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}` +
585
- `${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) : ''}`,
586
971
  )
587
- lines.push(...formatTaskRows(detail?.tasks ?? []))
588
- 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
+ }
589
983
  // The next command after reading a failed run, with the arguments the
590
984
  // run forwarded to the tasks that got them (a failure under `-- --shard
591
985
  // 2` is that shard's); a dependency never got them, so its line has none.
@@ -597,9 +991,11 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
597
991
  for (const withArgs of [false, true]) {
598
992
  const ids = failed
599
993
  .filter((t) => t.forwarded === withArgs)
600
- .map((t) => `${t.project}#${t.task}`)
994
+ .map((t) => shellQuote(`${t.project}#${t.task}`))
601
995
  if (ids.length > 0)
602
- 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
+ )
603
999
  }
604
1000
  lines.push('', ...rerun)
605
1001
  }