@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -28,6 +28,8 @@
28
28
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
29
29
  import { paint, type ColorSupport } from './colors.js'
30
30
  import { formatDuration } from './summary.js'
31
+ import { outcomeLabel, skippedReason } from './events.js'
32
+ import type { RecapTail } from './failure-recap.js'
31
33
 
32
34
  const NO_COLOR: ColorSupport = { enabled: false }
33
35
 
@@ -92,7 +94,6 @@ export function formatTaskBlock(
92
94
 
93
95
  const idPainted = paintTaskId(node, colors, { bold: true })
94
96
  const corner = (s: string) => paint('', s, colors, { dim: true })
95
- const section = (title: string, color?: string) => sectionLine(title, color ?? '', colors)
96
97
  const header = formatBlockHeader(outcome, colors)
97
98
  const lines: string[] = [`${corner('┌─')} ${idPainted} ${corner('>')} ${header}`]
98
99
 
@@ -109,15 +110,7 @@ export function formatTaskBlock(
109
110
  pushStreamSection(lines, stdout, 'STDOUT', SUCCESS, body.droppedStdout ?? 0, colors)
110
111
  pushStreamSection(lines, stderr, 'STDERR', ERROR, body.droppedStderr ?? 0, colors)
111
112
 
112
- // Sandbox violations get a dedicated section inside the frame so the
113
- // user sees them in context with the failing task, not as loose
114
- // status output above the box.
115
- const vlines = outcome.sandboxViolationLines
116
- if (vlines && vlines.length > 0) {
117
- lines.push(section(`SANDBOX VIOLATIONS (${vlines.length})`, WARN), '')
118
- for (const v of vlines) lines.push(v)
119
- lines.push('')
120
- }
113
+ lines.push(...violationSection(outcome, colors))
121
114
 
122
115
  lines.push(`${corner('└─')} ${idPainted} ${corner('──')}${formatBlockFooter(outcome, colors)}`)
123
116
  return lines.join('\n') + '\n'
@@ -213,14 +206,14 @@ function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolea
213
206
 
214
207
  // ── Reported-line grid: glyph · time · status · cache · name ────────
215
208
  // glyph SHAPE encodes the cache axis (⏺ miss · ► fresh · ⇢ local · ⇣
216
- // remote; ◼ failed · ⊘ skipped · ⦿ running), glyph COLOR encodes the
209
+ // remote; ◼ failed · ⊘ skipped), glyph COLOR encodes the
217
210
  // task axis (green/red/yellow/cyan). The status and cache WORDS spell
218
211
  // the two axes out, each in its own color. Time is right-aligned in a
219
212
  // fixed cell so durations line up and a ticking elapsed never shifts
220
213
  // the row. All detail (exit code, output) lives in the framed block.
221
214
  export const TIME_COL = 7 // "0ms" … "999.99s"
222
215
  const STATUS_COL = 7 // "success" / "running" / "skipped"
223
- const CACHE_COL = 6 // "remote" / "local" / "fresh" / "miss"
216
+ const CACHE_COL = 8 // "no-cache" / "remote" / "local" / "fresh" / "miss"
224
217
 
225
218
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
226
219
  function timeCell(ms: number | null, colors: ColorSupport): string {
@@ -278,7 +271,8 @@ function cacheOf(o: TaskOutcome): { word: string; color: string } {
278
271
  switch (o.status) {
279
272
  case 'success':
280
273
  case 'failed':
281
- return { word: 'miss', color: '' } // dim
274
+ // A task with no `cache` block never consulted the cache: not a miss.
275
+ return { word: o.node.config.cache === undefined ? 'no-cache' : 'miss', color: '' } // dim
282
276
  case 'cache-hit':
283
277
  return o.restored === false
284
278
  ? { word: 'fresh', color: SUCCESS }
@@ -357,17 +351,29 @@ function formatOutcomeRow(
357
351
  )
358
352
  }
359
353
 
360
- /** Compact one-liner for a skipped task — it never ran (blank time). */
361
- export function formatTaskSkippedLine(node: TaskNode, colors: ColorSupport = NO_COLOR): string {
362
- return formatTaskRow(
363
- paint(WARN, '\u2298', colors),
364
- null,
365
- 'skipped',
366
- WARN,
367
- '',
368
- '',
369
- paintTaskId(node, colors),
370
- colors,
354
+ /**
355
+ * Compact one-liner for a skipped task — it never ran (blank time). The
356
+ * blocker rides the row (`• blocked by lib#build`): the footer's Skipped
357
+ * section groups the same fact, but the row is where the reader's eye is.
358
+ */
359
+ export function formatTaskSkippedLine(
360
+ node: TaskNode,
361
+ colors: ColorSupport = NO_COLOR,
362
+ blockedBy?: string,
363
+ ): string {
364
+ const why = skippedReason(blockedBy)
365
+ const suffix = why === '' ? '' : ` ${paint('', `• ${why}`, colors, { dim: true })}`
366
+ return (
367
+ formatTaskRow(
368
+ paint(WARN, '\u2298', colors),
369
+ null,
370
+ 'skipped',
371
+ WARN,
372
+ '',
373
+ '',
374
+ paintTaskId(node, colors),
375
+ colors,
376
+ ) + suffix
371
377
  )
372
378
  }
373
379
 
@@ -385,6 +391,27 @@ export function formatFrameOpen(node: TaskNode, colors: ColorSupport = NO_COLOR)
385
391
  return `${corner('┌─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('>')} $ ${cmd}`
386
392
  }
387
393
 
394
+ /**
395
+ * Sandbox violations get a dedicated section inside the frame so the user
396
+ * sees them in context with the failing task, not as loose status output
397
+ * above the box. Shared by BOTH renderers: the focused/live frame used to
398
+ * omit it, so a single-task run — exactly how someone debugging one task
399
+ * invokes vx — showed a failure with no violations at all (owner,
400
+ * 2026-09-05).
401
+ */
402
+ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[] {
403
+ const vlines = outcome.sandboxViolationLines
404
+ if (!vlines || vlines.length === 0) return []
405
+ // Unique lines only — a task's children trip the same denial many times
406
+ // and the repeats bury everything else. The lines themselves are printed
407
+ // VERBATIM: a violation is evidence, and evidence that has been rewritten
408
+ // for display is not the thing the sandbox reported. The outcome keeps
409
+ // every record for telemetry.
410
+ const body = [...new Set(vlines)]
411
+ // Red, not amber: a violation fails the task, so it reads as a failure.
412
+ return [sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors), '', ...body, '']
413
+ }
414
+
388
415
  export function formatFrameClose(
389
416
  node: TaskNode,
390
417
  outcome: TaskOutcome,
@@ -400,7 +427,9 @@ export function formatFrameClose(
400
427
  persistent && outcome.status === 'success'
401
428
  ? ` ${paint('', `(${formatDuration(outcome.durationMs)})`, colors, { dim: true })} ${paint(ACCENT, 'running', colors)}`
402
429
  : formatBlockFooter(outcome, colors)
403
- return `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
430
+ const close = `${corner('└─')} ${mark}${paintTaskId(node, colors, { bold: true })} ${corner('──')}${tail}`
431
+ const violations = violationSection(outcome, colors)
432
+ return violations.length === 0 ? close : `${violations.join('\n')}\n${close}`
404
433
  }
405
434
 
406
435
  function isPersistentNode(node: TaskNode): boolean {
@@ -451,30 +480,78 @@ export function formatPersistentList(
451
480
  )
452
481
  }
453
482
 
483
+ /** One failed task's section of the end-of-run recap. */
484
+ export interface RecapEntry {
485
+ node: TaskNode
486
+ outcome: TaskOutcome
487
+ tail: RecapTail
488
+ /** Characters a bounded capture (a persistent task's) dropped before the tail saw them. */
489
+ droppedChars: number
490
+ }
491
+
492
+ /**
493
+ * The run's last block: each failed task's last lines, under the failure's
494
+ * label, then the ids of the failures past the tail limit. The lines are
495
+ * raw, like a frame's, and `fence` wraps them where a task's text could be
496
+ * read as something other than text (GitHub Actions' workflow commands).
497
+ */
498
+ export function formatFailureRecap(
499
+ entries: readonly RecapEntry[],
500
+ more: readonly string[],
501
+ colors: ColorSupport = NO_COLOR,
502
+ fence: (lines: string[]) => string[] = (lines) => lines,
503
+ ): string[] {
504
+ const total = entries.length + more.length
505
+ const dim = (s: string) => paint('', s, colors, { dim: true })
506
+ const lines = [
507
+ '',
508
+ ` Failed: ${total} task${total === 1 ? '' : 's'} — the last lines ${total === 1 ? 'it' : 'each one'} printed`,
509
+ ]
510
+ for (const e of entries) {
511
+ lines.push(
512
+ '',
513
+ ` ${paint(ERROR, '◼︎', colors)} ${paintTaskId(e.node, colors, { bold: true })} ${dim('—')} ${paint(ERROR, outcomeLabel(e.outcome), colors, { bold: true })}`,
514
+ )
515
+ const cut: string[] = []
516
+ const { earlierLines, cutBytes } = e.tail
517
+ if (earlierLines > 0)
518
+ cut.push(`${count(earlierLines)} earlier line${earlierLines === 1 ? '' : 's'}`)
519
+ if (cutBytes > 0) cut.push(`${count(cutBytes)} bytes cut from the start of the line below`)
520
+ if (e.droppedChars > 0) {
521
+ cut.push(`the capture dropped ${count(e.droppedChars)} characters of the task's output`)
522
+ }
523
+ if (cut.length > 0) lines.push(dim(` … ${cut.join(', and ')}`))
524
+ if (e.tail.text.length === 0) lines.push(dim(' (no output)'))
525
+ else lines.push(...fence(e.tail.text.split('\n')))
526
+ }
527
+ if (more.length > 0) {
528
+ lines.push('', ` … and ${more.length} more failed: ${more.join(', ')}`)
529
+ }
530
+ return lines
531
+ }
532
+
533
+ function count(n: number): string {
534
+ return n.toLocaleString('en-US')
535
+ }
536
+
454
537
  function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
455
538
  const shortHash = o.hash ? o.hash.slice(0, 8) : ''
456
539
  const dim = (s: string) => paint('', s, colors, { dim: true })
540
+ const word = outcomeLabel(o)
457
541
  switch (o.status) {
458
542
  case 'cache-hit':
459
- if (o.restored === false) {
460
- return `${paint(SUCCESS, 'up-to-date', colors)} ${dim(`• ${shortHash}`)}`
461
- }
462
- return `${paint(SUCCESS, 'restored-local', colors)} ${dim(`• ${shortHash}`)}`
543
+ return `${paint(SUCCESS, word, colors)} ${dim(`• ${shortHash}`)}`
463
544
  case 'cache-hit-remote':
464
- if (o.restored === false) {
465
- return `${paint(SUCCESS, 'up-to-date', colors)} ${dim(`• ${shortHash}`)}`
466
- }
467
- return `${paint(ACCENT, 'restored-remote', colors)} ${dim(`• ${shortHash}`)}`
545
+ // A remote restore is the one hit painted accent: bytes came off the wire.
546
+ return `${paint(o.restored === false ? SUCCESS : ACCENT, word, colors)} ${dim(`• ${shortHash}`)}`
468
547
  case 'failed':
469
548
  // The command lives in its own `├─ command` section; the header
470
549
  // carries the outcome like every other status.
471
- return paint(ERROR, `failed (exit ${o.exitCode})`, colors, { bold: true })
550
+ return paint(ERROR, word, colors, { bold: true })
472
551
  case 'skipped':
473
- return paint(WARN, 'skipped (upstream failed)', colors)
474
- case 'success':
475
- return dim('success')
552
+ return paint(WARN, word, colors)
476
553
  default:
477
- return o.status
554
+ return dim(word)
478
555
  }
479
556
  }
480
557
 
@@ -492,21 +569,16 @@ function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
492
569
  }
493
570
 
494
571
  function formatStatusTag(o: TaskOutcome, colors: ColorSupport): string {
572
+ const word = outcomeLabel(o)
495
573
  switch (o.status) {
574
+ case 'failed':
575
+ return paint(ERROR, word, colors, { bold: true })
576
+ case 'skipped':
577
+ return paint(WARN, word, colors)
496
578
  case 'cache-hit':
497
- return paint('', o.restored === false ? 'up-to-date' : 'restored-local', colors, {
498
- dim: true,
499
- })
500
579
  case 'cache-hit-remote':
501
- return paint('', o.restored === false ? 'up-to-date' : 'restored-remote', colors, {
502
- dim: true,
503
- })
504
580
  case 'success':
505
- return paint('', 'success', colors, { dim: true })
506
- case 'failed':
507
- return paint(ERROR, `failed (exit ${o.exitCode})`, colors, { bold: true })
508
- case 'skipped':
509
- return paint(WARN, 'skipped', colors)
581
+ return paint('', word, colors, { dim: true })
510
582
  default:
511
583
  return o.status
512
584
  }
@@ -1,19 +1,19 @@
1
- // HistoryTable + HistoryProvider — historical run data the scheduler
2
- // uses for predictive priority and `vx info --history` surfaces.
1
+ // HistoryTable + HistoryProvider — historical run data the `schedule`
2
+ // plugin (`@vzn/vx-schedule-history`, and its `vx history` verb) and
3
+ // `--dry`'s time prediction read.
3
4
  //
4
5
  // The data has been in cache.db.runs since schema v11; what's new is
5
- // surfacing it. A single SQL CTE per run pulls the last N rows per
6
- // (project, task) pair; the result becomes a read-only snapshot for
7
- // the run's lifetime. Loaded once at prepareRun; never mutated mid-run.
6
+ // surfacing it. One SQL statement per call pulls the last N executed rows
7
+ // per (project, task) pair out of a rowid-bounded slice of the table; the
8
+ // result becomes a read-only snapshot for the run's lifetime. Loaded once
9
+ // by the `schedule` stage (or `--dry`); never mutated mid-run.
8
10
  //
9
- // Two providers:
10
- // LocalHistoryProvider — reads cache.db directly (zero-config).
11
- // RemoteHistoryProvider — would call a service RPC; deferred to
12
- // when such an RPC actually exists.
11
+ // One provider ships, `LocalHistoryProvider`, reading cache.db directly
12
+ // (zero-config); the interface is the seam a plugin fills for any other.
13
13
 
14
14
  import type { Database } from 'bun:sqlite'
15
15
  import { EXECUTED_RUNS_SQL } from '../cache/index.js'
16
- import { classifyFailureMode } from './failure-mode.js'
16
+ import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
17
17
  import type { FailureMode } from './failure-mode.js'
18
18
 
19
19
  const DEFAULT_RECENT = 50
@@ -30,9 +30,22 @@ export interface TaskHistory {
30
30
  successRate: number
31
31
  /** Cache hit rate over the recent window ([0, 1]). */
32
32
  hitRate: number
33
- /** Failure mode classification. Shares `classifyFailureMode` with
34
- * the run-history readers so the two surfaces cannot disagree. */
33
+ /** Failure mode classification over the same window (`failureModeOf`,
34
+ * the one rule `vx why`'s all-time query also applies). */
35
35
  failureMode: FailureMode
36
+ /**
37
+ * Largest peak RSS (bytes) any successful execution in the window
38
+ * reported; undefined when none did (hits report nothing, and a
39
+ * platform may not expose rusage). The floor a memory reservation
40
+ * learned from history packs on.
41
+ */
42
+ maxPeakRssBytes?: number
43
+ /**
44
+ * The most CPU parallelism any successful execution in the window
45
+ * showed: cpu time over wall time, so 2.0 means two cores busy for the
46
+ * whole task. Undefined when no row carried both numbers.
47
+ */
48
+ maxCpuParallelism?: number
36
49
  }
37
50
 
38
51
  /** Map keyed by `project#task`. */
@@ -56,138 +69,144 @@ export class LocalHistoryProvider implements HistoryProvider {
56
69
  private readonly recent: number = DEFAULT_RECENT,
57
70
  ) {}
58
71
 
72
+ /**
73
+ * The window is the last `recent` INVOCATIONS, read as one rowid slice.
74
+ *
75
+ * `runs` has no (project, task) index: it cost every run a scattered
76
+ * B-tree write per task (2026-09-09, 1,000 warm hits: the record stage
77
+ * 57–79 ms with it, 14–19 ms without) and bought this reader nothing —
78
+ * ranking every pair's rows newest-first (`ROW_NUMBER` over a partition)
79
+ * sorted the whole table either way, 230 ms at 116k rows. Rows are
80
+ * appended in time order, one contiguous block per invocation, so the
81
+ * rows of the newest `recent` invocations are exactly `id >= MIN(id)` of
82
+ * the `recent`-th newest header — a primary-key range, no sort, plain
83
+ * aggregates, and at most one row per pair per invocation, so "the last
84
+ * `recent` invocations" IS the last `recent` runs for a pair that runs
85
+ * every time and whatever fewer it has for one that runs less often. A
86
+ * hint for ordering and prediction, not a ledger; `vx why` / `vx last`
87
+ * read by `run_id` and stay exact.
88
+ */
59
89
  async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
60
90
  const out = new Map<string, TaskHistory>()
61
91
  if (taskIds.length === 0) return out
62
92
 
63
- // Split `project#task` into (project, task) pairs so the WHERE filters on a
64
- // ROW-VALUE `IN (VALUES …)` the `runs(project, task)` index can SEARCH
65
- // (seek) — filtering on the concatenated `project || '#' || task`
66
- // expression forced a full index SCAN instead. Split on the FIRST `#`:
67
- // task names never contain `#`, and a project name (a package name) never
68
- // does either.
69
- const pairs = (taskIds as string[]).map(splitTaskId)
70
- const tupleParams = pairs.flat()
71
- const tuplePlaceholders = pairs.map(() => '(?,?)').join(',')
72
-
73
- // One CTE per call: rank rows per (project, task) descending by
74
- // started_at, keep the top `recent`, aggregate. Cache-hit rows
75
- // (cache_hit = 1) are excluded from the duration percentiles so
76
- // p50/p99 reflect work the runner actually did. successRate +
77
- // hitRate are computed over ALL recent rows.
78
- //
79
- // `skipped` rows are excluded from the window entirely: a skip is a task
80
- // the run never executed, so it neither belongs in a success/hit RATE nor
81
- // deserves to occupy one of the `recent` slots — a task whose upstream
82
- // keeps breaking would otherwise push its own real history out of view.
83
- const sql = `
84
- WITH recent AS (
85
- SELECT
86
- project,
87
- task,
88
- status,
89
- duration_ms,
90
- cache_hit,
91
- attempts,
92
- ROW_NUMBER() OVER (PARTITION BY project, task ORDER BY started_at DESC) AS rn
93
- FROM runs
94
- WHERE (project, task) IN (VALUES ${tuplePlaceholders})
95
- AND ${EXECUTED_RUNS_SQL}
93
+ const floorRow = this.db
94
+ .query(
95
+ `SELECT MIN(id) AS id FROM runs WHERE run_id =
96
+ (SELECT run_id FROM invocations ORDER BY rowid DESC LIMIT 1 OFFSET ?)`,
96
97
  )
98
+ .get(this.recent - 1) as { id: number | null }
99
+ // Fewer invocations than the window (or none): the whole table is the window.
100
+ const floor = floorRow.id ?? 0
101
+
102
+ // `skipped` rows are excluded: a skip is a task the run never executed,
103
+ // so it belongs in no success/hit RATE and no duration. Percentiles are
104
+ // over the executed-success rows of the slice — work the runner actually
105
+ // did — while the rates count every executed row.
106
+ const sql = `
97
107
  SELECT
98
- project,
99
- task,
108
+ r.project,
109
+ r.task,
100
110
  COUNT(*) AS total,
101
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successes,
102
- SUM(CASE WHEN cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
103
- SUM(CASE WHEN cache_hit IS NULL OR cache_hit = 0 THEN 1 ELSE 0 END) AS executed,
104
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
105
- SUM(CASE WHEN attempts > 1 THEN 1 ELSE 0 END) AS retried
106
- FROM recent
107
- WHERE rn <= ${this.recent}
108
- GROUP BY project, task
111
+ SUM(CASE WHEN r.status = 'success' THEN 1 ELSE 0 END) AS successes,
112
+ SUM(CASE WHEN r.cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
113
+ SUM(CASE WHEN r.status = 'failed' THEN 1 ELSE 0 END) AS failures,
114
+ SUM(CASE WHEN r.attempts > 1 THEN 1 ELSE 0 END) AS retried,
115
+ GROUP_CONCAT(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
116
+ THEN r.duration_ms END) AS ds,
117
+ MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
118
+ THEN r.peak_rss_bytes
119
+ WHEN r.cache_hit = 1 THEN e.peak_rss_bytes END) AS rss,
120
+ MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
121
+ AND r.cpu_ms IS NOT NULL AND r.duration_ms > 0
122
+ THEN r.cpu_ms * 1.0 / r.duration_ms
123
+ WHEN r.cache_hit = 1 AND e.cpu_ms IS NOT NULL AND e.duration_ms > 0
124
+ THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
125
+ FROM runs r
126
+ -- A hit executed nothing here, but its entry carries what the
127
+ -- PRODUCING execution used (the artifact's sidecar, indexed at save
128
+ -- and ingest alike), so a task this machine has only ever restored
129
+ -- — a fresh runner behind a remote cache — still has a number. The
130
+ -- join is by primary key and only for hit rows; an entry pruned
131
+ -- since contributes nothing.
132
+ LEFT JOIN entries e ON r.cache_hit = 1 AND e.hash = r.hash
133
+ WHERE r.id >= ? AND ${EXECUTED_RUNS_SQL}
134
+ GROUP BY r.project, r.task
109
135
  `
110
-
111
136
  type Row = {
112
137
  project: string
113
138
  task: string
114
139
  total: number
115
140
  successes: number
116
141
  hits: number
117
- executed: number
118
142
  failures: number
119
143
  retried: number
144
+ ds: string | null
145
+ rss: number | null
146
+ cpu: number | null
120
147
  }
121
- const rows = this.db.query(sql).all(...tupleParams) as Row[]
122
-
123
- // Percentiles need a row-wise read (percentile_cont isn't in SQLite), but
124
- // ONE windowed query pulls the last `recent` EXECUTED+SUCCESS durations for
125
- // EVERY task at once — the old per-task `percentilesFor` was an N+1 (one
126
- // query per task on top of the counts CTE). Same per-task window (last
127
- // `recent` executed-success rows by started_at DESC), grouped in JS.
128
- const durationsByTask = this.durationsFor(taskIds)
148
+ // Every pair in the slice is grouped (the slice is bounded, so this is
149
+ // cheap) and the asked-for ones are picked out here — a per-row IN-list
150
+ // probe in SQL cost more than the grouping it would have saved.
151
+ const wanted = new Set(taskIds)
152
+ const rows = (this.db.query(sql).all(floor) as Row[]).filter((r) =>
153
+ wanted.has(`${r.project}#${r.task}`),
154
+ )
155
+
156
+ // The flakiness signal needs per-key outcomes, which the aggregate above
157
+ // folded away — one more pass over the slice, only for the pairs whose
158
+ // verdict actually depends on it (failed, and no retry already proving
159
+ // nondeterminism); a green history never pays for it.
160
+ const mixed = this.mixedOutcomeKeys(
161
+ floor,
162
+ rows.filter((r) => (r.failures || 0) > 0 && (r.retried || 0) === 0),
163
+ )
129
164
 
130
165
  for (const row of rows) {
131
166
  const key = `${row.project}#${row.task}`
132
167
  const total = row.total || 0
133
- const failureMode = classifyFailureMode(this.db, row.project, row.task, {
134
- total,
135
- failures: row.failures || 0,
136
- retried: row.retried || 0,
137
- })
138
- const durations = durationsByTask.get(key)
168
+ const counts = { total, failures: row.failures || 0, retried: row.retried || 0 }
169
+ const durations = row.ds === null ? undefined : row.ds.split(',').map(Number)
170
+ if (durations !== undefined) durations.sort((a, b) => a - b)
139
171
  out.set(key, {
140
172
  runs: total,
141
173
  p50DurationMs: durations ? pickPercentile(durations, 0.5) : undefined,
142
174
  p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
143
175
  successRate: total > 0 ? (row.successes || 0) / total : 0,
144
176
  hitRate: total > 0 ? (row.hits || 0) / total : 0,
145
- failureMode,
177
+ failureMode: failureModeOf(counts, () => mixed.get(key) ?? 0),
178
+ ...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
179
+ ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
146
180
  })
147
181
  }
148
182
  return out
149
183
  }
150
184
 
151
- /**
152
- * The last `recent` executed-success `duration_ms` values per `project#task`,
153
- * ASCENDING (ready for percentile picking), in ONE windowed query for the
154
- * whole task set. A `project#task` with no executed-success rows is absent.
155
- */
156
- private durationsFor(taskIds: readonly string[]): Map<string, number[]> {
157
- const pairs = (taskIds as string[]).map(splitTaskId)
158
- const sql = `
159
- SELECT project, task, duration_ms FROM (
160
- SELECT project, task, duration_ms,
161
- ROW_NUMBER() OVER (PARTITION BY project, task ORDER BY started_at DESC) AS rn
162
- FROM runs
163
- WHERE (project, task) IN (VALUES ${pairs.map(() => '(?,?)').join(',')})
164
- AND (cache_hit IS NULL OR cache_hit = 0)
165
- AND status = 'success'
185
+ /** Mixed-outcome key count per `project#task`, over the slice, for `pairs` only. */
186
+ private mixedOutcomeKeys(
187
+ floor: number,
188
+ pairs: readonly { project: string; task: string }[],
189
+ ): Map<string, number> {
190
+ const out = new Map<string, number>()
191
+ if (pairs.length === 0) return out
192
+ const placeholders = pairs.map(() => '(?,?)').join(',')
193
+ const rows = this.db
194
+ .query(
195
+ `SELECT project, task, COUNT(*) AS n FROM (${mixedOutcomeKeysSql(
196
+ 'runs',
197
+ ` AND id >= ? AND (project, task) IN (VALUES ${placeholders})`,
198
+ )}) GROUP BY project, task`,
166
199
  )
167
- WHERE rn <= ${this.recent}`
168
- const rows = this.db.query(sql).all(...pairs.flat()) as {
200
+ .all(floor, ...pairs.flatMap((p) => [p.project, p.task])) as {
169
201
  project: string
170
202
  task: string
171
- duration_ms: number
203
+ n: number
172
204
  }[]
173
- const out = new Map<string, number[]>()
174
- for (const r of rows) {
175
- const key = `${r.project}#${r.task}`
176
- const list = out.get(key)
177
- if (list) list.push(r.duration_ms)
178
- else out.set(key, [r.duration_ms])
179
- }
180
- for (const list of out.values()) list.sort((a, b) => a - b)
205
+ for (const r of rows) out.set(`${r.project}#${r.task}`, r.n)
181
206
  return out
182
207
  }
183
208
  }
184
209
 
185
- /** Split a `project#task` id on its FIRST `#` (task names contain no `#`). */
186
- function splitTaskId(id: string): [string, string] {
187
- const i = id.indexOf('#')
188
- return i < 0 ? [id, ''] : [id.slice(0, i), id.slice(i + 1)]
189
- }
190
-
191
210
  function pickPercentile(sorted: number[], q: number): number {
192
211
  if (sorted.length === 0) return 0
193
212
  const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length))