@vzn/vx 0.0.16 → 0.0.18

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 (111) hide show
  1. package/README.md +66 -89
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +88 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +12 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,61 @@
1
+ // The flakiness verdict, in ONE place. Two surfaces classify the same
2
+ // `runs` rows — the run-history readers and `LocalHistoryProvider` (what
3
+ // `--dry` predictions read) — and they used
4
+ // to encode the rule independently, which let them drift into opposite
5
+ // verdicts on identical data. Both now call `classifyFailureMode`, so the
6
+ // rule cannot fork again.
7
+
8
+ import type { Database } from 'bun:sqlite'
9
+ import { KEYED_RUNS_SQL } from '../cache/index.js'
10
+ import { isCacheHit, TASK_STATUSES } from './telemetry.js'
11
+
12
+ // SQL hit set derived from the predicate — never a hand-typed list and never
13
+ // a prefix LIKE (a prefix counts any status merely NAMED cache-hit-*). The
14
+ // status-vocabulary tripwire greps both wrong forms.
15
+ const HIT_STATUSES = `(${TASK_STATUSES.filter(isCacheHit)
16
+ .map((s) => `'${s}'`)
17
+ .join(', ')})`
18
+
19
+ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
20
+
21
+ /**
22
+ * Distinct cache keys that produced BOTH a failure and a success — the
23
+ * definitional flake: identical inputs, different outcomes. A failure whose
24
+ * key never succeeded is a legitimate break (a changed input that fails),
25
+ * which belongs to the regressions surface, not flakiness.
26
+ */
27
+ export function mixedOutcomeKeyCount(db: Database, project: string, task: string): number {
28
+ const row = db
29
+ .query(
30
+ `SELECT COUNT(*) AS n FROM (
31
+ SELECT hash FROM runs
32
+ WHERE project = ? AND task = ? AND ${KEYED_RUNS_SQL}
33
+ GROUP BY hash
34
+ HAVING SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) > 0
35
+ AND SUM(CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
36
+ THEN 1 ELSE 0 END) > 0
37
+ )`,
38
+ )
39
+ .get(project, task) as { n: number }
40
+ return row.n
41
+ }
42
+
43
+ /**
44
+ * Flaky requires a NONDETERMINISM signal: a within-run retry, or a cache key
45
+ * that both failed and succeeded. Failures alone — each on its own key — are
46
+ * legitimate breaks, however many there are.
47
+ *
48
+ * The `failures > 0` short-circuit keeps the key-count query off the common
49
+ * path: a task that never failed is stable without touching the DB.
50
+ */
51
+ export function classifyFailureMode(
52
+ db: Database,
53
+ project: string,
54
+ task: string,
55
+ counts: { total: number; failures: number; retried: number },
56
+ ): FailureMode {
57
+ const flakySignal =
58
+ counts.retried > 0 || (counts.failures > 0 && mixedOutcomeKeyCount(db, project, task) > 0)
59
+ if (!flakySignal) return 'stable'
60
+ return counts.failures < counts.total / 5 ? 'flaky-recoverable' : 'flaky-fatal'
61
+ }
@@ -60,6 +60,14 @@ export interface TaskBlockBody {
60
60
  stdout?: string
61
61
  /** stderr chunks. Renders under `├─ stderr`. */
62
62
  stderr?: string
63
+ /**
64
+ * Characters the capture dropped from the head of each stream. Non-zero
65
+ * only for a task whose output is bounded (a persistent one — nothing
66
+ * else can outrun its own exit), and rendered so a capped log can never
67
+ * read as a complete one.
68
+ */
69
+ droppedStdout?: number
70
+ droppedStderr?: number
63
71
  }
64
72
 
65
73
  export function formatTaskBlock(
@@ -84,16 +92,7 @@ export function formatTaskBlock(
84
92
 
85
93
  const idPainted = paintTaskId(node, colors, { bold: true })
86
94
  const corner = (s: string) => paint('', s, colors, { dim: true })
87
- // Section labels are bold + state-colored (owner design): stdout
88
- // green, stderr red, sandbox yellow, command plain white — only
89
- // the command CONTENT is greyed (context, not signal). A dim rule
90
- // trails each label to the frame width, and sections get vertical
91
- // margins so content stands clear of the furniture.
92
- const section = (title: string, color?: string) => {
93
- const label = paint(color ?? '', title, colors, { bold: true })
94
- const rule = corner('\u2500'.repeat(Math.max(1, FRAME_WIDTH - 4 - title.length)))
95
- return `${corner('\u251c\u2500')} ${label} ${rule}`
96
- }
95
+ const section = (title: string, color?: string) => sectionLine(title, color ?? '', colors)
97
96
  const header = formatBlockHeader(outcome, colors)
98
97
  const lines: string[] = [`${corner('┌─')} ${idPainted} ${corner('>')} ${header}`]
99
98
 
@@ -107,17 +106,8 @@ export function formatTaskBlock(
107
106
  lines.push('', corner(`$ ${node.config.exec?.command ?? ''}`), '')
108
107
  }
109
108
 
110
- if (stdout.trim().length > 0) {
111
- lines.push(section('STDOUT', SUCCESS), '')
112
- pushBodyLines(lines, stdout)
113
- lines.push('')
114
- }
115
-
116
- if (stderr.trim().length > 0) {
117
- lines.push(section('STDERR', ERROR), '')
118
- pushBodyLines(lines, stderr)
119
- lines.push('')
120
- }
109
+ pushStreamSection(lines, stdout, 'STDOUT', SUCCESS, body.droppedStdout ?? 0, colors)
110
+ pushStreamSection(lines, stderr, 'STDERR', ERROR, body.droppedStderr ?? 0, colors)
121
111
 
122
112
  // Sandbox violations get a dedicated section inside the frame so the
123
113
  // user sees them in context with the failing task, not as loose
@@ -133,6 +123,42 @@ export function formatTaskBlock(
133
123
  return lines.join('\n') + '\n'
134
124
  }
135
125
 
126
+ /**
127
+ * One `├─ <LABEL>` section of captured output, with the head-dropped notice
128
+ * when the capture was bounded. Shared by the task frame and the persistent
129
+ * tail block so the two renderings of the same bytes cannot drift.
130
+ */
131
+ function pushStreamSection(
132
+ lines: string[],
133
+ text: string,
134
+ title: string,
135
+ color: string,
136
+ lost: number,
137
+ colors: ColorSupport,
138
+ ): void {
139
+ if (text.trim().length === 0) return
140
+ lines.push(sectionLine(title, color, colors), '')
141
+ if (lost > 0) {
142
+ lines.push(paint('', `… ${lost} earlier characters dropped`, colors, { dim: true }))
143
+ }
144
+ pushBodyLines(lines, text)
145
+ lines.push('')
146
+ }
147
+
148
+ /**
149
+ * Section labels are bold + state-colored (owner design): stdout green,
150
+ * stderr red, sandbox yellow, command plain white — only the command
151
+ * CONTENT is greyed (context, not signal). A dim rule trails each label to
152
+ * the frame width, and sections get vertical margins so content stands
153
+ * clear of the furniture.
154
+ */
155
+ function sectionLine(title: string, color: string, colors: ColorSupport): string {
156
+ const corner = (s: string) => paint('', s, colors, { dim: true })
157
+ const label = paint(color, title, colors, { bold: true })
158
+ const rule = corner('─'.repeat(Math.max(1, FRAME_WIDTH - 4 - title.length)))
159
+ return `${corner('├─')} ${label} ${rule}`
160
+ }
161
+
136
162
  /**
137
163
  * Append each line of `text` raw. Content carries no border/indent so
138
164
  * terminal wrapping never collides with frame glyphs and copy/paste
@@ -143,7 +169,7 @@ function pushBodyLines(lines: string[], text: string): void {
143
169
  }
144
170
 
145
171
  /** Stable per-project hue: FNV-style fold of the name into the palette. */
146
- export function projectColor(projectName: string): string {
172
+ function projectColor(projectName: string): string {
147
173
  let h = 0
148
174
  for (let i = 0; i < projectName.length; i++) {
149
175
  h = (h * 31 + projectName.charCodeAt(i)) >>> 0
@@ -172,7 +198,7 @@ export function paintIdParts(
172
198
  )
173
199
  }
174
200
 
175
- export function paintId(
201
+ function paintId(
176
202
  projectName: string,
177
203
  taskName: string,
178
204
  colors: ColorSupport,
@@ -181,11 +207,7 @@ export function paintId(
181
207
  return paintIdParts(projectName, projectName, taskName, colors, opts)
182
208
  }
183
209
 
184
- export function paintTaskId(
185
- node: TaskNode,
186
- colors: ColorSupport,
187
- opts: { bold?: boolean } = {},
188
- ): string {
210
+ function paintTaskId(node: TaskNode, colors: ColorSupport, opts: { bold?: boolean } = {}): string {
189
211
  return paintId(node.projectName, node.taskName, colors, opts)
190
212
  }
191
213
 
@@ -197,8 +219,8 @@ export function paintTaskId(
197
219
  // fixed cell so durations line up and a ticking elapsed never shifts
198
220
  // the row. All detail (exit code, output) lives in the framed block.
199
221
  export const TIME_COL = 7 // "0ms" … "999.99s"
200
- export const STATUS_COL = 7 // "success" / "running" / "skipped"
201
- export const CACHE_COL = 6 // "remote" / "local" / "fresh" / "miss"
222
+ const STATUS_COL = 7 // "success" / "running" / "skipped"
223
+ const CACHE_COL = 6 // "remote" / "local" / "fresh" / "miss"
202
224
 
203
225
  /** Right-align the duration in a TIME_COL cell (pad on the left). */
204
226
  function timeCell(ms: number | null, colors: ColorSupport): string {
@@ -271,7 +293,7 @@ function cacheOf(o: TaskOutcome): { word: string; color: string } {
271
293
  }
272
294
 
273
295
  /** The painted status glyph (shape = cache axis, color = task axis). */
274
- export function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
296
+ function taskGlyph(o: TaskOutcome, colors: ColorSupport): string {
275
297
  return paint(statusOf(o).color, glyphShape(o), colors)
276
298
  }
277
299
 
@@ -385,6 +407,33 @@ function isPersistentNode(node: TaskNode): boolean {
385
407
  return node.config.exec?.persistent !== undefined
386
408
  }
387
409
 
410
+ /**
411
+ * Everything a persistent task wrote AFTER it signalled ready, as one
412
+ * trailing block. Its outcome landed at ready while the child kept
413
+ * running, so this uses the live frame's `▸ … running` vocabulary rather
414
+ * than the success frame `formatTaskBlock` renders — the block is a tail,
415
+ * not a completed task's log. Section titles say `(since ready)` because
416
+ * the pre-ready output already went out with the outcome, and a
417
+ * head-dropped tail names how much it lost so a truncated log can never
418
+ * read as complete. Empty body → '' (the caller writes nothing).
419
+ */
420
+ export function formatPersistentTailBlock(
421
+ node: TaskNode,
422
+ outcome: TaskOutcome,
423
+ body: TaskBlockBody,
424
+ dropped: { stdout?: number; stderr?: number } = {},
425
+ colors: ColorSupport = NO_COLOR,
426
+ ): string {
427
+ const stdout = body.stdout ?? ''
428
+ const stderr = body.stderr ?? ''
429
+ if (stdout.trim().length === 0 && stderr.trim().length === 0) return ''
430
+ const lines: string[] = [formatFrameOpen(node, colors)]
431
+ pushStreamSection(lines, stdout, 'STDOUT (since ready)', SUCCESS, dropped.stdout ?? 0, colors)
432
+ pushStreamSection(lines, stderr, 'STDERR (since ready)', ERROR, dropped.stderr ?? 0, colors)
433
+ lines.push(formatFrameClose(node, outcome, colors))
434
+ return lines.join('\n') + '\n'
435
+ }
436
+
388
437
  /**
389
438
  * Between the requested task's frame and the summary footer, list the
390
439
  * persistent tasks (dev servers / watchers) the run is keeping alive in
@@ -430,10 +479,13 @@ function formatBlockHeader(o: TaskOutcome, colors: ColorSupport): string {
430
479
  }
431
480
 
432
481
  function formatBlockFooter(o: TaskOutcome, colors: ColorSupport): string {
433
- // Footer pattern: ` (<dur>) <status>`. Duration is always shown.
434
- // For cache hits it's the *original* exec time the entry was
435
- // stored with (set by execute-task), not the ~0ms replay cost.
436
- // Status differs by outcome — see formatStatusTag.
482
+ // Footer pattern: ` (<dur>) <status>`. Duration is always shown, and it is
483
+ // always what THIS run spent — for a cache hit, the probe + restore, not
484
+ // the exec time the entry was stored with. (The comment here used to claim
485
+ // the opposite; the code never did, and `--report` summed these as "time
486
+ // saved" on the strength of it. The stored time lives on
487
+ // `TaskOutcome.storedDurationMs`.) Status differs by outcome — see
488
+ // formatStatusTag.
437
489
  const dur = paint('', `(${formatDuration(o.durationMs)})`, colors, { dim: true })
438
490
  const tag = formatStatusTag(o, colors)
439
491
  return ` ${dur} ${tag}`
@@ -12,6 +12,9 @@
12
12
  // when such an RPC actually exists.
13
13
 
14
14
  import type { Database } from 'bun:sqlite'
15
+ import { EXECUTED_RUNS_SQL } from '../cache/index.js'
16
+ import { classifyFailureMode } from './failure-mode.js'
17
+ import type { FailureMode } from './failure-mode.js'
15
18
 
16
19
  const DEFAULT_RECENT = 50
17
20
 
@@ -27,8 +30,9 @@ export interface TaskHistory {
27
30
  successRate: number
28
31
  /** Cache hit rate over the recent window ([0, 1]). */
29
32
  hitRate: number
30
- /** Failure mode classification. */
31
- failureMode: 'stable' | 'flaky-recoverable' | 'flaky-fatal'
33
+ /** Failure mode classification. Shares `classifyFailureMode` with
34
+ * the run-history readers so the two surfaces cannot disagree. */
35
+ failureMode: FailureMode
32
36
  }
33
37
 
34
38
  /** Map keyed by `project#task`. */
@@ -56,11 +60,26 @@ export class LocalHistoryProvider implements HistoryProvider {
56
60
  const out = new Map<string, TaskHistory>()
57
61
  if (taskIds.length === 0) return out
58
62
 
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
+
59
73
  // One CTE per call: rank rows per (project, task) descending by
60
74
  // started_at, keep the top `recent`, aggregate. Cache-hit rows
61
75
  // (cache_hit = 1) are excluded from the duration percentiles so
62
76
  // p50/p99 reflect work the runner actually did. successRate +
63
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.
64
83
  const sql = `
65
84
  WITH recent AS (
66
85
  SELECT
@@ -69,9 +88,11 @@ export class LocalHistoryProvider implements HistoryProvider {
69
88
  status,
70
89
  duration_ms,
71
90
  cache_hit,
91
+ attempts,
72
92
  ROW_NUMBER() OVER (PARTITION BY project, task ORDER BY started_at DESC) AS rn
73
93
  FROM runs
74
- WHERE (project || '#' || task) IN (${taskIds.map(() => '?').join(',')})
94
+ WHERE (project, task) IN (VALUES ${tuplePlaceholders})
95
+ AND ${EXECUTED_RUNS_SQL}
75
96
  )
76
97
  SELECT
77
98
  project,
@@ -80,7 +101,8 @@ export class LocalHistoryProvider implements HistoryProvider {
80
101
  SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successes,
81
102
  SUM(CASE WHEN cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
82
103
  SUM(CASE WHEN cache_hit IS NULL OR cache_hit = 0 THEN 1 ELSE 0 END) AS executed,
83
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures
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
84
106
  FROM recent
85
107
  WHERE rn <= ${this.recent}
86
108
  GROUP BY project, task
@@ -94,20 +116,30 @@ export class LocalHistoryProvider implements HistoryProvider {
94
116
  hits: number
95
117
  executed: number
96
118
  failures: number
119
+ retried: number
97
120
  }
98
- const rows = this.db.query(sql).all(...(taskIds as string[])) as Row[]
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)
99
129
 
100
130
  for (const row of rows) {
101
131
  const key = `${row.project}#${row.task}`
102
132
  const total = row.total || 0
103
- const failures = row.failures || 0
104
- const failureMode: TaskHistory['failureMode'] =
105
- failures === 0 ? 'stable' : failures < total / 5 ? 'flaky-recoverable' : 'flaky-fatal'
106
- const { p50, p99 } = this.percentilesFor(row.project, row.task)
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)
107
139
  out.set(key, {
108
140
  runs: total,
109
- p50DurationMs: p50,
110
- p99DurationMs: p99,
141
+ p50DurationMs: durations ? pickPercentile(durations, 0.5) : undefined,
142
+ p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
111
143
  successRate: total > 0 ? (row.successes || 0) / total : 0,
112
144
  hitRate: total > 0 ? (row.hits || 0) / total : 0,
113
145
  failureMode,
@@ -116,31 +148,46 @@ export class LocalHistoryProvider implements HistoryProvider {
116
148
  return out
117
149
  }
118
150
 
119
- // Percentiles need a row-wise read; do it in JS rather than try to
120
- // express the percentile_cont equivalent in SQLite. Tiny rows.
121
- private percentilesFor(
122
- project: string,
123
- task: string,
124
- ): { p50: number | undefined; p99: number | undefined } {
125
- const rows = this.db
126
- .query(
127
- `SELECT duration_ms FROM runs
128
- WHERE project = ? AND task = ?
129
- AND (cache_hit IS NULL OR cache_hit = 0)
130
- AND status = 'success'
131
- ORDER BY started_at DESC
132
- LIMIT ?`,
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'
133
166
  )
134
- .all(project, task, this.recent) as { duration_ms: number }[]
135
- if (rows.length === 0) return { p50: undefined, p99: undefined }
136
- const durations = rows.map((r) => r.duration_ms).sort((a, b) => a - b)
137
- return {
138
- p50: pickPercentile(durations, 0.5),
139
- p99: pickPercentile(durations, 0.99),
167
+ WHERE rn <= ${this.recent}`
168
+ const rows = this.db.query(sql).all(...pairs.flat()) as {
169
+ project: string
170
+ task: string
171
+ duration_ms: number
172
+ }[]
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])
140
179
  }
180
+ for (const list of out.values()) list.sort((a, b) => a - b)
181
+ return out
141
182
  }
142
183
  }
143
184
 
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
+
144
191
  function pickPercentile(sorted: number[], q: number): number {
145
192
  if (sorted.length === 0) return 0
146
193
  const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length))
@@ -11,7 +11,8 @@ export type { RunOptions, RunSummary } from './options.js'
11
11
  export { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
12
12
  export { defaultLogger, resolveOutputView } from './logger.js'
13
13
  export type { Logger, OutputView } from './logger.js'
14
- export type { RunPlan, PlannedTask, CacheStatus } from './plan.js'
14
+ export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
15
+ export { formatDuration } from './summary.js'
15
16
  export {
16
17
  createEventBus,
17
18
  wireForwarder,
@@ -27,39 +28,7 @@ export type {
27
28
  TaskView,
28
29
  OutcomeView,
29
30
  } from './events.js'
30
- export { createVxSurface } from './devframe-surface.js'
31
- export { createWireRenderer } from './wire-render.js'
32
- export { optionsToRequest, requestToOptions } from './protocol.js'
33
- export type { ClientMessage, RunBackend, RunRequest, RunResult, ServerMessage } from './protocol.js'
34
- export { formatRunReportMarkdown } from './run-report.js'
35
- export {
36
- clientMessageToEnvelope,
37
- decodeEnvelope,
38
- encodeForNDJSON,
39
- encodeForSSE,
40
- encodeForWS,
41
- ENVELOPE_ERRORS,
42
- envelopeToClientMessage,
43
- envelopeToServerMessage,
44
- isEnvelope,
45
- isNotification,
46
- isRequest,
47
- makeError,
48
- makeNotification,
49
- makeRequest,
50
- makeResponse,
51
- serverMessageToEnvelope,
52
- WIRE_CHANNELS,
53
- WIRE_PROTOCOL_VERSION,
54
- } from './wire.js'
55
- export type {
56
- Envelope,
57
- ErrorResponse,
58
- Notification,
59
- Request as WireRequest,
60
- Response as WireResponse,
61
- WireChannel,
62
- } from './wire.js'
31
+ export { escapeMarkdownCell, formatRunReportMarkdown, type RunResult } from './run-report.js'
63
32
  export {
64
33
  EmptyHistoryProvider,
65
34
  type HistoryProvider,
@@ -67,13 +36,17 @@ export {
67
36
  LocalHistoryProvider,
68
37
  type TaskHistory,
69
38
  } from './history.js'
70
- export { computePredictedPriorities } from './predict.js'
71
39
  export {
72
40
  installPlugins,
73
- type BackendContext,
74
41
  type CacheContext,
75
- type EventSink,
76
- type EventSinkContext,
42
+ type ExecutorContext,
43
+ type CommandContext,
44
+ type GraphHookContext,
45
+ type KeyHookContext,
46
+ type PluginCommand,
47
+ type ProjectHookContext,
48
+ type ScheduleHookContext,
49
+ type WorkspaceHookContext,
77
50
  type InstallPluginsArgs,
78
51
  type Plugin,
79
52
  type PluginContext,
@@ -82,9 +55,31 @@ export {
82
55
  type PluginSetupContext,
83
56
  type VxPlugin,
84
57
  } from './plugin.js'
85
- export { resolveBackend, resolveCache, subscribeEventSinks } from './plugin-host.js'
58
+ export {
59
+ applyConfigHooks,
60
+ applyGraphHooks,
61
+ applyKeyHooks,
62
+ applyProjectHooks,
63
+ applyScheduleHooks,
64
+ hasHook,
65
+ resolveCache,
66
+ resolveExecutors,
67
+ } from './plugin-host.js'
68
+ export { MISSING_PLUGIN_HINT } from './missing-plugin.js'
86
69
  export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
87
- export { createTelemetrySource, deriveCacheSource, TELEMETRY_SCHEMA_VERSION } from './telemetry.js'
70
+ // The bounded log-capture buffer every telemetry sink shares — see the
71
+ // module header for why one implementation, not one per sink.
72
+ export { LOG_WIRE_VERSION, TaskLogBuffer } from './task-log-buffer.js'
73
+ export type { TaskLogBundle, TaskLogEntry } from './task-log-buffer.js'
74
+ export {
75
+ assembleRunSummary,
76
+ createTelemetrySource,
77
+ deriveCacheSource,
78
+ isCacheHit,
79
+ isPassStatus,
80
+ TASK_STATUSES,
81
+ TELEMETRY_SCHEMA_VERSION,
82
+ } from './telemetry.js'
88
83
  export type {
89
84
  CacheSource,
90
85
  RunContextRecord,
@@ -100,75 +95,33 @@ export type {
100
95
  export { diffOutputTrees } from './verify.js'
101
96
  export { deriveStableKeys } from './stable-keys.js'
102
97
  export type { DeriveStableKeysArgs, StableKey } from './stable-keys.js'
103
- export { captureGitContext, captureWorkspaceIdentity, resolveCacheScope } from './run-context.js'
104
- export type { GitContext, WorkspaceIdentity } from './run-context.js'
98
+ export {
99
+ captureDefaultBranch,
100
+ captureGitContext,
101
+ captureHostContext,
102
+ captureWorkspaceIdentity,
103
+ detectCi,
104
+ } from './run-context.js'
105
+ export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
105
106
  export {
106
107
  cacheKeyDiff,
107
- compareRuns,
108
108
  explainCacheKey as explainCacheKeyQuery,
109
- getBottlenecks,
110
- getCacheBreakdown,
111
- getCacheSavings,
112
- getCacheStatsSql,
113
- getFlakiestTasks,
114
- getHistory,
115
- getHitRateSplit,
116
109
  getInvocation,
117
- getParallelismHistory,
118
- getPeriodComparison,
119
- getPrunableEntries,
120
- getRecentFailures,
121
- getRegressions,
122
110
  getRun,
123
- getRunHeatmap,
124
- getRunTrends,
125
- getStorageGrowth,
126
- getTaskDetail,
127
- getTopTimeBurners,
128
- listCacheEntries,
129
111
  listInvocations,
130
- listProjects,
131
112
  listRuns,
132
113
  whyDidThisRerun as whyDidThisRerunQuery,
133
114
  } from './metrics.js'
134
115
  export type {
135
- BottleneckRow,
136
116
  CacheEntryRow,
137
117
  CacheKeyDiff,
138
118
  CacheKeyExplanation,
139
- CacheProjectRow,
140
- CacheSavings,
141
- CacheStatsResult,
142
- CompareRuns,
143
- CompareTaskRow,
144
- CompareTaskSide,
145
- FailureRow,
146
- FlakyTask,
147
- GetHistoryArgs,
148
- HeatmapCell,
149
- HitRateSplit,
150
119
  InputDiffEntry,
151
120
  InvocationDetail,
152
121
  InvocationRow,
153
- ListCacheEntriesArgs,
154
122
  ListInvocationsArgs,
155
123
  ListRunsArgs,
156
- ParallelismPoint,
157
- PeriodComparison,
158
- PeriodComparisonArgs,
159
- PeriodStats,
160
- PrunableEntry,
161
- ProjectRollup,
162
- RegressedTask,
163
- RegressionArgs,
164
- TaskMover,
165
124
  RunDetail,
166
125
  RunSummaryRow,
167
- StoragePoint,
168
- TaskDetail,
169
- TaskHistoryRow,
170
- TopTaskRow,
171
- TrendBucket,
172
- TrendPoint,
173
126
  WhyDidThisRerun,
174
127
  } from './metrics.js'
@@ -38,6 +38,7 @@
38
38
  // conservative exclusion). Those tasks still get an exec-tier probe
39
39
  // reuse entry so there's no double work.
40
40
 
41
+ import { span } from '../util/index.js'
41
42
  import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
42
43
  import type { TaskNode } from '../graph/index.js'
43
44
  import { deriveStableKeys } from './stable-keys.js'
@@ -95,10 +96,13 @@ const EMPTY: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
95
96
  */
96
97
  export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<ShortCircuit> {
97
98
  let stableKeys
99
+ const endKeys = span('stable keys')
98
100
  try {
99
101
  stableKeys = await deriveStableKeys(args)
100
102
  } catch {
101
103
  return EMPTY
104
+ } finally {
105
+ endKeys()
102
106
  }
103
107
  const candidates = stableKeys.filter(({ node }) => node.config.cache !== undefined)
104
108
  if (candidates.length === 0) return EMPTY
@@ -125,6 +129,24 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
125
129
  // it); a miss stays exec-tier with a known-miss reuse entry. The pool
126
130
  // keeps the up-front pass from being a serial pre-phase on a wide warm
127
131
  // graph — the probes parallelize at the run's concurrency.
132
+ // A layer with a batched `get` answers every probe in a couple of
133
+ // queries; the pool below is for layers without one.
134
+ if (args.cache.getMany !== undefined) {
135
+ try {
136
+ const endProbe = span('probe')
137
+ const hits = await args.cache.getMany(candidates.map((c) => c.hash))
138
+ endProbe()
139
+ for (const { hash, node } of candidates) {
140
+ const hit = hits.get(hash) ?? null
141
+ preProbed.set(node.id, { hash, hit })
142
+ if (hit !== null && !anyWorkspaceOutputs) restoreTier.add(node.id)
143
+ }
144
+ return { preProbed, restoreTier }
145
+ } catch {
146
+ // Fall through to the per-hash pool, which isolates a failing probe
147
+ // to its own task.
148
+ }
149
+ }
128
150
  let next = 0
129
151
  const workers = Math.max(1, Math.min(args.concurrency, candidates.length))
130
152
  const pump = async (): Promise<void> => {