@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
@@ -16,7 +16,11 @@ export type TaskStatus =
16
16
  // are partial.
17
17
  | 'aborted'
18
18
 
19
- /** Where a task's result came from, derived ONCE in core from the status. */
19
+ /**
20
+ * Where a task's result came from, derived ONCE in core from the status.
21
+ * `none`: it never engaged the cache, or its hit's restore failed (a
22
+ * failed task, in no cache column).
23
+ */
20
24
  export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
21
25
 
22
26
  /**
package/src/handoff.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  // from the workspace root, the one lookup every verb shares. Here for
3
3
  // the compiled binary installed by hand; an npm install's launcher
4
4
  // (npm-launcher.sh) walks the same way and `exec`s, which Bun cannot. The
5
- // walk runs before anything else loads, through `Bun.file(…).exists()`:
6
- // importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
5
+ // walk runs before anything else loads, through `Bun.Glob` and
6
+ // `Bun.file(…).exists()`: importing `node:fs` alone was ~1.1 ms of a 4 ms
7
+ // start (2026-10-13).
7
8
 
8
9
  /** `vx upgrade -g`: moves only the global vx, so it never hands off (2026-10-16). */
9
10
  export function upgradesOnlyItself(argv: readonly string[]): boolean {
@@ -85,21 +86,55 @@ export function workspaceFile(dir: string): Promise<string | null> {
85
86
  /** vx wrote a workspace config (`vx init`): what was learned before it is stale. */
86
87
  export function workspaceFileWritten(): void {
87
88
  files.clear()
89
+ broken.clear()
90
+ }
91
+
92
+ const workspaceNames = new Bun.Glob('workspace.*')
93
+ const broken = new Set<string>()
94
+
95
+ /**
96
+ * The workspace config in `d`'s `.vx`: the first of the names that is a
97
+ * file, else the first entry by one of them (a broken link, a directory),
98
+ * which marks the root all the same, as `vx lock`'s discovery counts it,
99
+ * and is refused there ({@link workspaceFileBroken}). A level without
100
+ * one costs a stat and a failed open, where a stat per name was six:
101
+ * 9 us against 15 us from three levels down, 1 us at a root holding
102
+ * `.vx/workspace.ts` either way (min of 2000, 2026-11-07).
103
+ */
104
+ async function workspaceEntryIn(d: string): Promise<string | null> {
105
+ // The name `vx init` writes, first: at the root, the stat it always was.
106
+ const first = `${d}/${WORKSPACE_CONFIG_FILENAMES[0]}`
107
+ if (await Bun.file(first).exists()) return first
108
+ let names: string[]
109
+ try {
110
+ names = [...workspaceNames.scanSync({ cwd: `${d}/.vx`, onlyFiles: false })]
111
+ } catch {
112
+ return null
113
+ }
114
+ const ranked = WORKSPACE_CONFIG_FILENAMES.filter((n) => names.includes(n.slice(4)))
115
+ if (ranked.length === 0) return null
116
+ // `exists()` is false for a directory and a broken link.
117
+ for (const name of ranked)
118
+ if (name !== WORKSPACE_CONFIG_FILENAMES[0] && (await Bun.file(`${d}/${name}`).exists()))
119
+ return `${d}/${name}`
120
+ const file = `${d}/${ranked[0]}`
121
+ broken.add(file)
122
+ return file
123
+ }
124
+
125
+ /** The workspace config {@link workspaceFile} found is no file: a broken link, a directory. */
126
+ export function workspaceFileBroken(file: string): boolean {
127
+ return broken.has(file)
88
128
  }
89
129
 
90
130
  async function findWorkspaceFile(start: string): Promise<string | null> {
91
131
  let top = await gitTop(start)
92
132
  const passed: string[] = []
93
133
  let found: string | null = null
94
- walk: for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
134
+ for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
95
135
  passed.push(d || '/')
96
- for (const name of WORKSPACE_CONFIG_FILENAMES) {
97
- const file = `${d}/${name}`
98
- if (await Bun.file(file).exists()) {
99
- found = file
100
- break walk
101
- }
102
- }
136
+ found = await workspaceEntryIn(d)
137
+ if (found !== null) break
103
138
  if (d === '') break
104
139
  if (d === top) {
105
140
  // A submodule whose `.git` is a directory (added from a clone, not
@@ -30,7 +30,7 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
30
30
  * `status`, `cache_hit`, `cached` columns) with how often it failed, how often it
31
31
  * passed — an executed success or a cache hit, which replays a success —
32
32
  * and how often it failed AFTER its first pass (`relapses`). A task with
33
- * no cache block keys on its config alone and runs every time, so its key
33
+ * no `cache: true` keys on its config alone and runs every time, so its key
34
34
  * says nothing about its inputs: no reader counts it, as no run's row
35
35
  * calls it flaky. Nor is a row that names a `blocked_by`: it ran behind a
36
36
  * failure (`--continue=always`), on bytes its key never named, so its
@@ -75,7 +75,7 @@ export function mixedOutcomeKeysSql(source: string, where = ''): string {
75
75
  export function mixedOutcomeKeyCount(db: Database, project: string, task: string): number {
76
76
  const row = db
77
77
  .query(
78
- `SELECT COUNT(*) AS n FROM (${mixedOutcomeKeysSql('runs', ' AND project = ? AND task = ?')})`,
78
+ `SELECT COUNT(*) AS n FROM (${mixedOutcomeKeysSql('main.runs', ' AND project = ? AND task = ?')})`,
79
79
  )
80
80
  .get(project, task) as { n: number }
81
81
  return row.n
@@ -198,6 +198,29 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
198
198
  return out
199
199
  }
200
200
 
201
+ /**
202
+ * Whether a task that just failed on `hash` is known to be flaky: this key
203
+ * passed before (the failure itself is the flake), or the retained history
204
+ * holds a key of the task that failed after it passed, or a pass that took
205
+ * a retry. Asked only after a failure, so a green run never reads it.
206
+ */
207
+ export function knownFlaky(db: Database, project: string, task: string, hash: string): boolean {
208
+ const passed = db
209
+ .query(
210
+ `SELECT 1 FROM ${OUTCOMES} WHERE hash = ? AND project = ? AND task = ?
211
+ AND (status = 'success' OR status IN ${HIT_STATUSES}) LIMIT 1`,
212
+ )
213
+ .get(hash, project, task)
214
+ if (passed !== null) return true
215
+ const flaked = db
216
+ .query(
217
+ `SELECT 1 FROM (${keyOutcomesSql(OUTCOMES, ' AND project = ? AND task = ?')})
218
+ WHERE relapses > 0 OR retried_failures > 0 LIMIT 1`,
219
+ )
220
+ .get(project, task)
221
+ return flaked !== null
222
+ }
223
+
201
224
  /** One task the recorded history shows failing on a key that had passed. */
202
225
  export interface FlakyTask {
203
226
  /** `project#task`. */
@@ -213,18 +236,28 @@ export interface FlakyTask {
213
236
 
214
237
  /**
215
238
  * Every task with a key that failed after it had passed anywhere in the retained history
216
- * (30 days, `RunHistory.pruneOlderThan`), most failures first: the
239
+ * (until a bounded clean's `RunHistory.pruneOlderThan`), most failures first: the
217
240
  * doctor's list. A pass that took a retry mixes its key on its own: it is
218
241
  * one `success` row whose failed attempts count as failures (`vx info`
219
- * said `none` over one). A full scan of `task_outcomes`, which an inspection verb
220
- * affords.
242
+ * said `none` over one). Only keys with a failure or a retry can be on it,
243
+ * so one plain pass over `task_outcomes` finds those and the projection
244
+ * reads their rows alone, by `task_outcomes_hash`: projecting every key
245
+ * sorted the whole table, 4.2 s at 2M rows against 0.15. An index on the
246
+ * candidates would make the pass a probe; it is a schema change, which
247
+ * resets every cache.
221
248
  */
222
249
  export function flakyTasks(db: Database): FlakyTask[] {
223
250
  const rows = db
224
251
  .query(
225
252
  `SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
226
253
  FROM (SELECT project, task, passes, failures + retried_failures AS failures
227
- FROM (${keyOutcomesSql(OUTCOMES)})
254
+ FROM (${keyOutcomesSql(
255
+ OUTCOMES,
256
+ // NOT INDEXED: by `task_outcomes_hash` the pass looked each
257
+ // row up from the index, 1.1 s against 0.15.
258
+ ` AND hash IN (SELECT hash FROM ${OUTCOMES} NOT INDEXED
259
+ WHERE attempts > 1 OR status = 'failed')`,
260
+ )})
228
261
  WHERE relapses > 0 OR retried_failures > 0)
229
262
  GROUP BY project, task
230
263
  ORDER BY failures DESC, passes DESC, project, task`,
@@ -121,6 +121,8 @@ export class EmptyHistoryProvider implements HistoryProvider {
121
121
  export interface LocalHistory extends HistoryProvider {
122
122
  /** Each task's measured need (`p50DurationMs`, peak cores and memory). */
123
123
  resourcesFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, TaskResources>>
124
+ /** Only each task's `p50DurationMs` as `resourcesFor` answers it, read without the rest. */
125
+ durationsFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>>
124
126
  /** Whether timings of commit `sha` were imported. */
125
127
  hasImported(sha: string): boolean
126
128
  /** Keep commit `sha`'s timings (task id → ms) and needs as forecast rows. */
@@ -243,7 +245,7 @@ export class LocalHistoryProvider implements LocalHistory {
243
245
  THEN ${NEED_SQL} END),
244
246
  MAX(CASE WHEN r.cache_hit = 1 AND e.cpu_ms IS NOT NULL AND e.duration_ms > 0
245
247
  THEN e.cpu_ms * 1.0 / e.duration_ms END)) AS cpu
246
- FROM runs r
248
+ FROM main.runs r
247
249
  -- A hit executed nothing here, but its entry carries what the
248
250
  -- PRODUCING execution used (the artifact's sidecar, indexed at save
249
251
  -- and ingest alike), so a task this machine has only ever restored
@@ -400,6 +402,13 @@ export class LocalHistoryProvider implements LocalHistory {
400
402
  const out = new Map<string, number>()
401
403
  this.lackedBranchHistory = false
402
404
  if (wanted.size === 0) return out
405
+ // A store that has timed nothing (a cold cache, a first run) answers no
406
+ // tier: one row's probe, where asking every task of 8,002 was 20 ms of
407
+ // a cold run's schedule stage (2026-11-07).
408
+ if (this.db.query('SELECT 1 FROM task_durations LIMIT 1').get() === null) {
409
+ this.lackedBranchHistory = true
410
+ return out
411
+ }
403
412
  // Tiers, first answer wins: a WHERE over `task_durations`, its params,
404
413
  // and how many of the newest rows it reads.
405
414
  type Tier = { where: string; params: string[]; limit: number }
@@ -480,7 +489,7 @@ export class LocalHistoryProvider implements LocalHistory {
480
489
  `SELECT r.project, r.task${this.byDef}, MAX(r.peak_rss_bytes) AS rss,
481
490
  AVG(CASE WHEN r.cpu_ms IS NOT NULL AND r.duration_ms > 0
482
491
  THEN ${NEED_SQL} END) AS cpu
483
- FROM runs r
492
+ FROM main.runs r
484
493
  WHERE r.id >= ? AND r.status = 'success' AND (r.cache_hit IS NULL OR r.cache_hit = 0)
485
494
  GROUP BY r.project, r.task${this.byDef}`,
486
495
  )
@@ -495,25 +504,7 @@ export class LocalHistoryProvider implements LocalHistory {
495
504
  ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
496
505
  })
497
506
  }
498
- type Hit = {
499
- project: string
500
- task: string
501
- def?: string | null
502
- rss: number | null
503
- cpu: number | null
504
- ms: number | null
505
- }
506
- const hits = this.db
507
- .query(
508
- `SELECT r.project, r.task${this.byDef}, MAX(e.peak_rss_bytes) AS rss,
509
- MAX(CASE WHEN e.cpu_ms IS NOT NULL AND e.duration_ms > 0
510
- THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu,
511
- MAX(CASE WHEN e.duration_ms > 0 THEN e.duration_ms END) AS ms
512
- FROM (SELECT DISTINCT project, task, hash, def FROM runs WHERE id >= ? AND cache_hit = 1) r
513
- JOIN entries e ON e.hash = r.hash
514
- GROUP BY r.project, r.task${this.byDef}`,
515
- )
516
- .all(floor) as Hit[]
507
+ const hits = this.producerRows(floor)
517
508
  // A hit's CPU is its producer's use, not need: the entry keeps no
518
509
  // share, so a crowded producer's reads low. Only a task with no need
519
510
  // from a run here or the default branch's import takes it: main's
@@ -522,12 +513,7 @@ export class LocalHistoryProvider implements LocalHistory {
522
513
  const hitCpu = new Map<string, number>()
523
514
  for (const row of hits) {
524
515
  const key = `${row.project}#${row.task}`
525
- if (
526
- !wanted.has(key) ||
527
- !this.current(key, row.def) ||
528
- (row.rss === null && row.cpu === null && row.ms === null)
529
- )
530
- continue
516
+ if (!wanted.has(key) || !this.produced(key, row)) continue
531
517
  if (row.cpu !== null) hitCpu.set(key, row.cpu)
532
518
  const had = out.get(key)
533
519
  const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
@@ -558,7 +544,7 @@ export class LocalHistoryProvider implements LocalHistory {
558
544
  AVG(${NEED_SQL} / r.placement_cores) AS fill,
559
545
  MAX(CASE WHEN r.peak_rss_bytes >= ${SATURATED} * r.placement_memory
560
546
  THEN r.placement_memory END) AS memory
561
- FROM runs r
547
+ FROM main.runs r
562
548
  WHERE r.id >= ? AND r.placement_cores IS NOT NULL AND r.duration_ms > 0
563
549
  AND r.status IN ('success', 'failed') AND (r.cache_hit IS NULL OR r.cache_hit = 0)
564
550
  GROUP BY r.project, r.task${this.byDef}, r.placement_cores) r
@@ -589,6 +575,51 @@ export class LocalHistoryProvider implements LocalHistory {
589
575
  return out
590
576
  }
591
577
 
578
+ /**
579
+ * Only the `p50DurationMs` of each task `resourcesFor` answers: its
580
+ * forecast, else a restored task's producer time. For a reader that
581
+ * needs nothing else (`@vzn/vx-history`'s timings file): the window's
582
+ * grouped reads are skipped, and the hits are read only when some task
583
+ * has no forecast. 23-27 ms against 40 at 8,002 tasks.
584
+ */
585
+ async durationsFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>> {
586
+ if (taskIds.length === 0) return new Map()
587
+ const wanted = new Set(taskIds)
588
+ const out = this.forecasts(wanted)
589
+ if (out.size === wanted.size) return out
590
+ // As `resourcesFor` sets it: the last row of a task wins, a row of no time unsetting it.
591
+ const produced = new Map<string, number | null>()
592
+ for (const row of this.producerRows(this.floor())) {
593
+ const key = `${row.project}#${row.task}`
594
+ if (wanted.has(key) && !out.has(key) && this.produced(key, row)) produced.set(key, row.ms)
595
+ }
596
+ for (const [key, ms] of produced) if (ms !== null) out.set(key, ms)
597
+ return out
598
+ }
599
+
600
+ /**
601
+ * The window's restored tasks, by what their entries' producers used:
602
+ * each distinct hit key joined to its entry once.
603
+ */
604
+ private producerRows(floor: number): ProducerRow[] {
605
+ return this.db
606
+ .query(
607
+ `SELECT r.project, r.task${this.byDef}, MAX(e.peak_rss_bytes) AS rss,
608
+ MAX(CASE WHEN e.cpu_ms IS NOT NULL AND e.duration_ms > 0
609
+ THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu,
610
+ MAX(CASE WHEN e.duration_ms > 0 THEN e.duration_ms END) AS ms
611
+ FROM (SELECT DISTINCT project, task, hash, def FROM main.runs WHERE id >= ? AND cache_hit = 1) r
612
+ JOIN entries e ON e.hash = r.hash
613
+ GROUP BY r.project, r.task${this.byDef}`,
614
+ )
615
+ .all(floor) as ProducerRow[]
616
+ }
617
+
618
+ /** Whether a producer row is of the task as it is now and measured anything. */
619
+ private produced(key: string, row: ProducerRow): boolean {
620
+ return this.current(key, row.def) && (row.rss !== null || row.cpu !== null || row.ms !== null)
621
+ }
622
+
592
623
  /**
593
624
  * A task never measured here takes its need from the newest imported
594
625
  * row that has one (`importTimings`): a fresh CI machine packs by what
@@ -641,6 +672,38 @@ export class LocalHistoryProvider implements LocalHistory {
641
672
  }
642
673
  }
643
674
 
675
+ /**
676
+ * Of `taskIds`, each whose one timing (of its definition, given `defs`)
677
+ * was recorded at or after `since`: a run's own, with no usual to compare
678
+ * it with. At most two rows per task off `task_durations_task`, where
679
+ * grouping every task's rows cost `vx last` 1.5 s at 2M rows.
680
+ */
681
+ timedOnlySince(taskIds: readonly string[], since: number): string[] {
682
+ if (taskIds.length === 0) return []
683
+ const rows = this.db
684
+ .query(
685
+ `SELECT j.key AS i FROM json_each(?) AS j
686
+ WHERE (SELECT MIN(recorded_at) FROM (
687
+ SELECT recorded_at FROM task_durations
688
+ WHERE project = j.value ->> 0 AND task = j.value ->> 1 ${this.durationDef}
689
+ LIMIT 2) HAVING COUNT(*) = 1) >= ?`,
690
+ )
691
+ .all(JSON.stringify(taskIds.map((id) => this.keyOf(id))), since) as { i: number }[]
692
+ return rows.map((r) => taskIds[r.i]!)
693
+ }
694
+
695
+ /**
696
+ * Each task id the window's rows name, sorted: what a mistyped id is
697
+ * matched against. Every pair of the whole table was a scan of it, 270 ms
698
+ * at 2M rows, on an error's path.
699
+ */
700
+ recentTaskIds(): string[] {
701
+ const rows = this.db
702
+ .query('SELECT DISTINCT project, task FROM main.runs WHERE id >= ? ORDER BY project, task')
703
+ .all(this.floor()) as { project: string; task: string }[]
704
+ return rows.map((r) => `${r.project}#${r.task}`)
705
+ }
706
+
644
707
  /** The first `runs` id of the newest `recent` invocations (see `loadFor`). */
645
708
  private floor(): number {
646
709
  // The first row of each invocation in the window, an index probe each:
@@ -649,7 +712,7 @@ export class LocalHistoryProvider implements LocalHistory {
649
712
  const row = this.db
650
713
  .query(
651
714
  `SELECT COUNT(*) AS n, MIN(first) AS id,
652
- (SELECT COALESCE(MAX(id), 0) + 1 FROM runs) AS past
715
+ (SELECT COALESCE(MAX(id), 0) + 1 FROM main.runs) AS past
653
716
  FROM (SELECT (SELECT MIN(id) FROM runs WHERE run_id = i.run_id) AS first
654
717
  FROM (SELECT run_id FROM invocations ORDER BY rowid DESC LIMIT ?) i)`,
655
718
  )
@@ -671,7 +734,7 @@ export class LocalHistoryProvider implements LocalHistory {
671
734
  const rows = this.db
672
735
  .query(
673
736
  `SELECT project, task, COUNT(*) AS n FROM (${mixedOutcomeKeysSql(
674
- 'runs',
737
+ 'main.runs',
675
738
  ` AND id >= ? AND (project, task) IN (VALUES ${placeholders})`,
676
739
  )}) GROUP BY project, task`,
677
740
  )
@@ -685,6 +748,16 @@ export class LocalHistoryProvider implements LocalHistory {
685
748
  }
686
749
  }
687
750
 
751
+ /** A restored task's producer use, from the window's hits (`producerRows`). */
752
+ interface ProducerRow {
753
+ project: string
754
+ task: string
755
+ def?: string | null
756
+ rss: number | null
757
+ cpu: number | null
758
+ ms: number | null
759
+ }
760
+
688
761
  // A group holds at least one row, so every SUM is a number and `sorted` is
689
762
  // never empty; q < 1 keeps the index inside it.
690
763
  function pickPercentile(sorted: number[], q: number): number {
@@ -16,6 +16,7 @@ export {
16
16
  } from './history.js'
17
17
  export {
18
18
  detectFlaky,
19
+ knownFlaky,
19
20
  type FailureMode,
20
21
  type FlakyCandidate,
21
22
  type FlakyFinding,
@@ -32,6 +33,7 @@ export {
32
33
  latestRunId,
33
34
  listInvocations,
34
35
  listRuns,
36
+ recordedTasks,
35
37
  rootCauses,
36
38
  upstreamTask,
37
39
  whyDidThisRerun,