@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
package/src/cli/last.ts CHANGED
@@ -2,11 +2,12 @@
2
2
  // without re-executing anything. Comparison gap #12: with the dashboard's
3
3
  // run-detail page gone (the 2026-08-23 cloud removal), the local run
4
4
  // history in cache.db is the only replay surface, and this verb reads it.
5
- // Read-only — no config evaluation, no re-hash, no cache probe.
5
+ // Read-only — no config evaluation, no re-hash, no cache probe; `--stats`
6
+ // reads the lock only for each task's definition.
6
7
 
7
8
  import type { Database } from 'bun:sqlite'
8
9
  import { indexDb, Cache, keptSteps, keptWarnings, type RunStep } from '../cache/index.js'
9
- import { formatBytes } from './format.js'
10
+ import { buildTaskGraph, expandRequested, isDefaultBuild, taskDefinitions } from '../graph/index.js'
10
11
  import { flagHint, formatValue, seeHelp } from './help.js'
11
12
  import {
12
13
  type FailureMode,
@@ -18,7 +19,9 @@ import {
18
19
  type RunSummaryRow,
19
20
  } from '../history/index.js'
20
21
  import {
22
+ applyGraphStage,
21
23
  currentBranch,
24
+ hasHook,
22
25
  type LiveSnapshot,
23
26
  liveSocketPath,
24
27
  exitSignal,
@@ -28,7 +31,13 @@ import {
28
31
  shortRunId,
29
32
  taskLog,
30
33
  } from '../orchestrator/index.js'
31
- import { formatRecordedRow, outcomeWord, taskList, type WireEvent } from '../report/index.js'
34
+ import {
35
+ formatRecordedRow,
36
+ formatSize,
37
+ outcomeWord,
38
+ taskList,
39
+ type WireEvent,
40
+ } from '../report/index.js'
32
41
  import {
33
42
  formatElapsed,
34
43
  nearest,
@@ -39,9 +48,18 @@ import {
39
48
  } from '../util/index.js'
40
49
  import * as ui from '../ui/index.js'
41
50
  import { sayNotice } from '../ui/index.js'
42
- import { findWorkspaceRoot } from '../workspace/index.js'
51
+ import { runRules } from '../settings.js'
52
+ import { buildPackageGraph, findWorkspaceRoot } from '../workspace/index.js'
43
53
  import { findCwdProject } from './select.js'
44
- import { cliCacheDir, parseCacheDirFlag, rootSpelled } from './workspace-config.js'
54
+ import {
55
+ cliCacheDir,
56
+ discoverCliProjects,
57
+ loadCliProjects,
58
+ loadCliWorkspace,
59
+ parseCacheDirFlag,
60
+ rootSpelled,
61
+ stageCacheDir,
62
+ } from './workspace-config.js'
45
63
 
46
64
  interface LastArgs {
47
65
  runId?: string
@@ -68,6 +86,9 @@ interface LastArgs {
68
86
  error?: string
69
87
  }
70
88
 
89
+ /** The run grid's mark for a number history does not hold. */
90
+ const UNKNOWN = '\u2014'
91
+
71
92
  /**
72
93
  * A run id as typed beside `--list`: a UUIDv7's first group (8 hex digits)
73
94
  * at least, whole or cut as `--list` prints it.
@@ -127,10 +148,16 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
127
148
  }
128
149
  if (a === '--sql' || a.startsWith('--sql=')) {
129
150
  const v = a === '--sql' ? args[++i] : a.slice('--sql='.length)
130
- if (v === undefined || v.trim() === '') {
151
+ // The space form took the next flag as the query, then blamed that
152
+ // flag for combining with --sql.
153
+ const flag =
154
+ a === '--sql' && v?.startsWith('-') === true
155
+ ? ` (got flag ${v}; a query that starts with - needs --sql=${v})`
156
+ : ''
157
+ if (v === undefined || v.trim() === '' || flag !== '') {
131
158
  return {
132
159
  ...out,
133
- error: `--sql requires a query, like 'SELECT * FROM runs'${seeHelp('last')}`,
160
+ error: `--sql requires a query, like 'SELECT * FROM runs'${flag}${seeHelp('last')}`,
134
161
  }
135
162
  }
136
163
  out.sql = v
@@ -276,18 +303,71 @@ export interface TaskStats {
276
303
  maxCpuParallelism: number | null
277
304
  }
278
305
 
306
+ /**
307
+ * Each task's definition (`taskDefinitions`) as a run builds the graph
308
+ * now: the lock's configs with the `project` and `graph` stages applied.
309
+ * Undefined when none loads (no lock, a broken config): then every
310
+ * definition's rows are read, as before definitions were kept.
311
+ */
312
+ async function definitionsNow(
313
+ root: string,
314
+ cacheDir: string | undefined,
315
+ ): Promise<Map<string, string> | undefined> {
316
+ try {
317
+ // A plugin's warning is the run's to say, not a history read's.
318
+ const opts = { warn: () => {}, ...(cacheDir === undefined ? {} : { cacheDir }) }
319
+ const metas = await discoverCliProjects(root)
320
+ const projects = await loadCliProjects(root, metas, 'all', opts)
321
+ const ws = await loadCliWorkspace(root, opts.warn)
322
+ const rules = runRules(ws.workspaceConfig)
323
+ const names = new Set<string>()
324
+ for (const p of projects.values())
325
+ for (const t of Object.keys(p.config.tasks ?? {})) names.add(t)
326
+ const nodes = buildTaskGraph({
327
+ projects,
328
+ packageGraph: buildPackageGraph(metas),
329
+ requested: expandRequested([...names], [...projects.keys()], projects, isDefaultBuild),
330
+ workspaceRoot: root,
331
+ rules,
332
+ })
333
+ if (hasHook(ws.plugins, 'graph'))
334
+ await applyGraphStage(ws.plugins, nodes, {
335
+ workspaceRoot: root,
336
+ cacheDir: stageCacheDir(ws, opts),
337
+ projectMetas: metas,
338
+ warn: opts.warn,
339
+ rules,
340
+ })
341
+ return taskDefinitions(nodes.values())
342
+ } catch {
343
+ return undefined
344
+ }
345
+ }
346
+
347
+ /** Each task's definition as run `runId` recorded it: what its live rows read history by. */
348
+ function runDefinitions(db: Database, runId: string): Map<string, string> {
349
+ const rows = db
350
+ .query(
351
+ `SELECT r.project, r.task, r.def FROM main.runs r JOIN main.run_ids k ON k.id = r.run
352
+ WHERE k.run_id = ? AND r.def IS NOT NULL`,
353
+ )
354
+ .all(runId) as { project: string; task: string; def: string }[]
355
+ return new Map(rows.map((r) => [`${r.project}#${r.task}`, r.def]))
356
+ }
357
+
279
358
  /**
280
359
  * `--stats`: what the scheduler and `--dry` learn from history, per task,
281
360
  * over the same window (`LocalHistoryProvider`), with the p50 a run on
282
- * `branch` forecasts.
361
+ * `branch` forecasts from the timings of each task's definition `defs`.
283
362
  */
284
363
  async function printStats(
285
364
  db: Database,
286
365
  target: string | undefined,
287
366
  format: 'pretty' | 'json',
288
367
  branch: string | null,
368
+ defs: ReadonlyMap<string, string> | undefined,
289
369
  ): Promise<number> {
290
- const provider = new LocalHistoryProvider(db, undefined, branch)
370
+ const provider = new LocalHistoryProvider(db, undefined, branch, defs)
291
371
  const matches = (id: string): boolean =>
292
372
  target === undefined || (target.includes('#') ? id === target : splitTaskId(id)[1] === target)
293
373
  const stats: TaskStats[] = [...(await provider.loadAll())]
@@ -319,7 +399,9 @@ async function printStats(
319
399
  return 0
320
400
  }
321
401
  const pct = (r: number): string => `${Math.round(r * 100)}%`
322
- const ms = (v: number | null): string => (v === null ? '-' : fmtMs(v))
402
+ // The run grid's units and its unknown mark, so one number reads alike
403
+ // in both: cpu `127%` (was `1.3×`), mem `3.6MB` (was `3.6 MB`), `—`.
404
+ const ms = (v: number | null): string => (v === null ? UNKNOWN : fmtMs(v))
323
405
  const right = (title: string): ui.Column => ({ title, align: 'right' })
324
406
  ui.print(
325
407
  'out',
@@ -345,8 +427,8 @@ async function printStats(
345
427
  pct(s.successRate),
346
428
  pct(s.hitRate),
347
429
  s.failureMode,
348
- s.maxPeakRssBytes === null ? '-' : formatBytes(s.maxPeakRssBytes),
349
- s.maxCpuParallelism === null ? '-' : `${s.maxCpuParallelism.toFixed(1)}×`,
430
+ s.maxPeakRssBytes === null ? UNKNOWN : formatSize(s.maxPeakRssBytes),
431
+ s.maxCpuParallelism === null ? UNKNOWN : pct(s.maxCpuParallelism),
350
432
  ]),
351
433
  ),
352
434
  ),
@@ -373,7 +455,8 @@ function printQuery(db: Database, sql: string, format: 'pretty' | 'json'): numbe
373
455
  db.exec('PRAGMA query_only = ON')
374
456
  const refused = (err: unknown): never => {
375
457
  const message = err instanceof Error ? err.message : String(err)
376
- if (/readonly|query_only/i.test(message)) {
458
+ // `runs` is a view: a write to it fails before query_only is asked.
459
+ if (/readonly|query_only|cannot modify/i.test(message)) {
377
460
  throw new UserError(`vx last: --sql reads only: ${message}`, 'VX_E_USAGE')
378
461
  }
379
462
  throw new UserError(`vx last: --sql: ${message}`, 'VX_E_USAGE')
@@ -433,6 +516,15 @@ function printQuery(db: Database, sql: string, format: 'pretty' | 'json'): numbe
433
516
  return 0
434
517
  }
435
518
 
519
+ /** `text` as JSON, or undefined where it is not. */
520
+ function parsed(text: string): unknown {
521
+ try {
522
+ return JSON.parse(text)
523
+ } catch {
524
+ return undefined
525
+ }
526
+ }
527
+
436
528
  /** How long a run has to answer before `--live` gives up on it. */
437
529
  const LIVE_ANSWER_MS = 5_000
438
530
 
@@ -457,18 +549,36 @@ async function printLive(
457
549
  }
458
550
  let buffered = ''
459
551
  let snapshot: LiveSnapshot | undefined
552
+ /** The run answered a snapshot this vx cannot read: another vx version's. */
553
+ let unreadable = false
460
554
  let settle!: (answered: boolean) => void
461
555
  const done = new Promise<boolean>((resolve) => (settle = resolve))
462
556
  const line = (text: string): void => {
557
+ if (unreadable) return
463
558
  if (snapshot === undefined) {
464
- snapshot = JSON.parse(text) as LiveSnapshot
559
+ const s = parsed(text) as Partial<LiveSnapshot> | undefined
560
+ const l = s?.live
561
+ if (
562
+ typeof l?.runId !== 'string' ||
563
+ typeof l.command !== 'string' ||
564
+ typeof l.startedAt !== 'number' ||
565
+ typeof l.elapsedMs !== 'number' ||
566
+ !Array.isArray(s!.tasks) ||
567
+ !Array.isArray(s!.warnings)
568
+ ) {
569
+ unreadable = true
570
+ return settle(false)
571
+ }
572
+ snapshot = s as LiveSnapshot
465
573
  if (format === 'json') ui.data('out', `${text}\n`)
466
574
  else ui.print('out', ...formatLive(snapshot))
467
575
  return
468
576
  }
469
577
  if (format === 'json') ui.data('out', `${text}\n`)
470
578
  else {
471
- const event = formatLiveEvent(JSON.parse(text) as WireEvent)
579
+ const e = parsed(text)
580
+ const event =
581
+ typeof e === 'object' && e !== null ? formatLiveEvent(e as WireEvent) : undefined
472
582
  if (event !== undefined) ui.print('out', event)
473
583
  }
474
584
  }
@@ -503,6 +613,11 @@ async function printLive(
503
613
  : setTimeout(() => settle(snapshot !== undefined), LIVE_ANSWER_MS)
504
614
  const answered = await done
505
615
  clearTimeout(timer)
616
+ if (unreadable)
617
+ throw refusal({
618
+ headline: 'the run in progress answered in a form this vx does not read',
619
+ fix: 'ask with the vx that started the run',
620
+ })
506
621
  if (!answered) none()
507
622
  return 0
508
623
  }
@@ -682,9 +797,19 @@ export function formatTaskRows(
682
797
  // The run's own grid (`taskList`): a replay reads as the run did. What
683
798
  // the run said below the row (an exit, why it skipped) rides it dim.
684
799
  const rows = ordered.map((t) => {
800
+ // A cached failure with no cache outcome is a hit whose restore failed: no
801
+ // exit. One whose command exited 0 failed in its save (a server that
802
+ // exited 0 before ready says so in its reason).
803
+ const failure =
804
+ t.status !== 'failed'
805
+ ? ''
806
+ : t.cached === true && t.cacheHit === null
807
+ ? 'cache restore failed'
808
+ : t.exitCode === 0 && t.notReady === null
809
+ ? 'cache save failed'
810
+ : `exit ${t.exitCode}`
685
811
  const note = (
686
- (t.status === 'failed' ? `exit ${t.exitCode}` : '') +
687
- reasonParts(t, skippedHere, inputMoved.get(`${t.project}#${t.task}`))
812
+ failure + reasonParts(t, skippedHere, inputMoved.get(`${t.project}#${t.task}`))
688
813
  ).trim()
689
814
  const id = `${t.project}#${t.task}`
690
815
  const usualMs = usual === undefined ? undefined : (usual.get(id) ?? null)
@@ -766,7 +891,9 @@ export function formatRunList(
766
891
  async function bareTaskId(db: Database, task: string): Promise<string> {
767
892
  if (task.includes('#')) return task
768
893
  const ids = (
769
- db.query('SELECT DISTINCT project FROM runs WHERE task = ? ORDER BY project').all(task) as {
894
+ db
895
+ .query('SELECT DISTINCT project FROM main.runs WHERE task = ? ORDER BY project')
896
+ .all(task) as {
770
897
  project: string
771
898
  }[]
772
899
  ).map((r) => `${r.project}#${task}`)
@@ -799,7 +926,13 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
799
926
  if (parsed.stats === true) {
800
927
  const branch = currentBranch(root, undefined, parsed.branch)
801
928
  const task = parsed.task === undefined ? undefined : await rootSpelled(root, parsed.task)
802
- return await printStats(db, task, parsed.format, branch)
929
+ return await printStats(
930
+ db,
931
+ task,
932
+ parsed.format,
933
+ branch,
934
+ await definitionsNow(root, parsed.cacheDir),
935
+ )
803
936
  }
804
937
 
805
938
  if (parsed.log !== undefined) {
@@ -812,19 +945,22 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
812
945
  // The id's form is hinted only when it lacks one: a task that
813
946
  // never ran was told how to spell a task id. A typo of one that
814
947
  // 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
948
+ const recorded = parsed.log.includes('#')
949
+ ? new LocalHistoryProvider(db).recentTaskIds()
950
+ : []
951
+ const ran = nearest(parsed.log, recorded)
952
+ // No near name: what of that project has run, so the next try is a pick.
953
+ const project = parsed.log.slice(0, parsed.log.lastIndexOf('#') + 1)
954
+ const theirs = recorded
955
+ .filter((id) => project !== '' && id.startsWith(project))
956
+ .map((id) => id.slice(project.length))
957
+ .sort()
958
+ const listed =
959
+ ran === undefined && theirs.length > 0
960
+ ? `; ${project.slice(0, -1)}'s recorded tasks: ${theirs.join(', ')}`
961
+ : ''
826
962
  throw new UserError(
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}?`}`,
963
+ `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}?`}${listed}`,
828
964
  'VX_E_NO_HISTORY',
829
965
  )
830
966
  }
@@ -833,8 +969,9 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
833
969
  return 0
834
970
  }
835
971
  if (log.source === null) {
836
- // A cached task with no entry was not saved (a read-only policy) or
837
- // was pruned; calling it uncached sends the user to the config.
972
+ // A cached task with no entry of its own was not saved (a read-only
973
+ // policy, a `--force` run that kept the entry it found), or its entry
974
+ // was pruned or saved over; calling it uncached sends the user to the config.
838
975
  const [project, task] = splitTaskId(log.taskId)
839
976
  const cached =
840
977
  log.status !== 'failed' &&
@@ -843,14 +980,27 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
843
980
  .query('SELECT cached FROM runs WHERE run_id = ? AND project = ? AND task = ?')
844
981
  .get(log.runId, project, task) as { cached: number | null } | null
845
982
  )?.cached === 1
983
+ // A skip ran nothing: the output to read is its blocker's.
984
+ const blocker =
985
+ log.status === 'skipped'
986
+ ? (
987
+ db
988
+ .query(
989
+ 'SELECT blocked_by AS b FROM runs WHERE run_id = ? AND project = ? AND task = ?',
990
+ )
991
+ .get(log.runId, project, task) as { b: string | null } | null
992
+ )?.b
993
+ : null
846
994
  sayNotice(
847
995
  'info',
848
996
  'vx last',
849
- log.status === 'failed'
850
- ? `${log.taskId} ended failed, and vx kept no output for it: it keeps the newest 50 failed runs`
851
- : cached
852
- ? `${log.taskId} ended ${log.status}, and its entry is not in the cache: the run did not save it, or it was pruned since`
853
- : `${log.taskId} ended ${log.status}. vx keeps output only for failed and cached tasks`,
997
+ typeof blocker === 'string'
998
+ ? `${log.taskId} did not run: ${blocker} blocked it (vx last --log ${blocker})`
999
+ : log.status === 'failed'
1000
+ ? `${log.taskId} ended failed, and vx kept no output for it: it keeps the newest 50 failed runs`
1001
+ : cached
1002
+ ? `${log.taskId} ended ${log.status}, and the cache does not hold its output: the run did not save it, or it was pruned or saved over since`
1003
+ : `${log.taskId} ended ${log.status}. vx keeps output only for failed and cached tasks`,
854
1004
  )
855
1005
  } else if (log.output === '') {
856
1006
  sayNotice('info', 'vx last', `${log.taskId} ended ${log.status} and printed nothing`)
@@ -885,18 +1035,34 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
885
1035
  return 0
886
1036
  }
887
1037
 
1038
+ const named = parsed.runId === undefined ? null : resolveRunId(db, parsed.runId, 'vx last')
888
1039
  const inv =
889
1040
  parsed.runId !== undefined
890
- ? getInvocation(db, resolveRunId(db, parsed.runId, 'vx last') ?? parsed.runId)
1041
+ ? getInvocation(db, named ?? parsed.runId)
891
1042
  : parsed.failed === true
892
1043
  ? (failedInvocations(db, 1)[0] ?? null)
893
1044
  : (listInvocations(db, { limit: 1 })[0] ?? null)
1045
+ // A run with rows and no header is one a stop ended (item 854): `vx why`
1046
+ // printed its id, so it is named, not called unrecorded.
1047
+ if (named !== null && (inv === null || inv === undefined)) {
1048
+ throw new UserError(
1049
+ `vx last: run ${named} was stopped before it finished, so vx kept its tasks but no summary: \`vx last ${named} --log <task>\` prints one's output`,
1050
+ 'VX_E_NO_HISTORY',
1051
+ )
1052
+ }
894
1053
  if ((inv === null || inv === undefined) && noRuns()) {
895
1054
  throw refusal(
896
1055
  { tone: 'info', headline: 'no runs yet', fix: 'vx run' },
897
1056
  { code: 'VX_E_NO_HISTORY', message: 'vx last: no recorded runs yet — run something first' },
898
1057
  )
899
1058
  }
1059
+ // Nothing failed is no fault: said as `--list --failed` says it.
1060
+ if ((inv === null || inv === undefined) && parsed.failed === true) {
1061
+ throw refusal(
1062
+ { tone: 'info', headline: 'no failed runs', fix: 'vx last' },
1063
+ { code: 'VX_E_NO_HISTORY', message: 'vx last: no failed runs' },
1064
+ )
1065
+ }
900
1066
  if (inv === null || inv === undefined) {
901
1067
  throw new UserError(
902
1068
  noRuns()
@@ -905,9 +1071,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
905
1071
  ? `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`
906
1072
  : parsed.runId !== undefined
907
1073
  ? `vx last: no recorded run ${parsed.runId} (vx last --list shows recent runs)`
908
- : parsed.failed === true
909
- ? 'vx last: no recorded run failed'
910
- : 'vx last: no recorded runs yet — run something first',
1074
+ : 'vx last: no recorded runs yet — run something first',
911
1075
  'VX_E_NO_HISTORY',
912
1076
  )
913
1077
  }
@@ -975,7 +1139,18 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
975
1139
  const ranIds = (detail?.tasks ?? [])
976
1140
  .filter((t) => t.status === 'success' || t.status === 'failed')
977
1141
  .map((t) => `${t.project}#${t.task}`)
978
- const usual = new LocalHistoryProvider(db, undefined, currentBranch(root)).p50sNow(ranIds)
1142
+ // Each task's timings of the definition it ran under, as its live row
1143
+ // read them: an older definition's timings are another task's.
1144
+ const history = new LocalHistoryProvider(
1145
+ db,
1146
+ undefined,
1147
+ currentBranch(root),
1148
+ runDefinitions(db, inv.runId),
1149
+ )
1150
+ const usual = new Map(history.p50sNow(ranIds))
1151
+ // A task whose one timing is this run's own has no usual to compare
1152
+ // with: `+0ms` claimed a baseline the live row rightly showed as `—`.
1153
+ for (const id of history.timedOnlySince(ranIds, inv.startedAt)) usual.delete(id)
979
1154
  const taskRows = formatTaskRows(detail?.tasks ?? [], moved, usual)
980
1155
  if (taskRows.length > 0)
981
1156
  blocks.push(ui.section('Tasks', String(detail?.tasks.length ?? 0), ...taskRows))