@vzn/vx 0.0.0 → 0.0.12

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,1576 @@
1
+ // Metrics query module — pure functions over a `bun:sqlite` Database.
2
+ //
3
+ // A service plugin's serve exposes these as HTTP routes; a dashboard SPA
4
+ // and `vx mcp` both read through them. One canonical home for every
5
+ // aggregate over the runs / entries
6
+ // tables. The schema itself is owned by src/cache/cache.ts — the
7
+ // drift guard in tests/metrics.test.ts runs every exported query against
8
+ // a freshly-created cache.db so a schema bump that breaks one fails the
9
+ // gate here, not in the dashboard.
10
+ //
11
+ // Pure SQL + JSON-safe return shapes. No Cache lifecycle here; the
12
+ // caller opens and closes. bigints are serialized as decimal strings
13
+ // for JSON compatibility (matches the WireEvent timeUnixNano rule).
14
+
15
+ import type { Database } from 'bun:sqlite'
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Run listing + detail
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface RunSummaryRow {
22
+ runId: string | null
23
+ project: string
24
+ task: string
25
+ status: string
26
+ exitCode: number
27
+ durationMs: number
28
+ startedAt: number
29
+ endedAt: number
30
+ cacheHit: boolean | null
31
+ hash: string
32
+ cpuMs: number | null
33
+ peakRssBytes: number | null
34
+ // High-precision spans relative to run start. Decimal strings on the wire
35
+ // (bigints aren't JSON-safe); use BigInt or Number on the client.
36
+ wallclockStartNs: string | null
37
+ wallclockEndNs: string | null
38
+ }
39
+
40
+ export interface ListRunsArgs {
41
+ limit?: number
42
+ project?: string
43
+ task?: string
44
+ runId?: string
45
+ }
46
+
47
+ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[] {
48
+ // Ceiling is high enough that a single run's full task set (getRun passes the
49
+ // run_id) is never truncated; list views pass their own small limit.
50
+ const limit = clampInt(args.limit ?? 100, 1, 100_000)
51
+ const where: string[] = []
52
+ const params: (string | number)[] = []
53
+ if (args.project) {
54
+ where.push('project = ?')
55
+ params.push(args.project)
56
+ }
57
+ if (args.task) {
58
+ where.push('task = ?')
59
+ params.push(args.task)
60
+ }
61
+ if (args.runId) {
62
+ where.push('run_id = ?')
63
+ params.push(args.runId)
64
+ }
65
+ const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
66
+ type RawRow = Omit<RunSummaryRow, 'cacheHit' | 'wallclockStartNs' | 'wallclockEndNs'> & {
67
+ cacheHit: number | null
68
+ wallclockStartNs: bigint | null
69
+ wallclockEndNs: bigint | null
70
+ }
71
+ const rows = db
72
+ .query(
73
+ `SELECT run_id AS runId, project, task, status, exit_code AS exitCode,
74
+ duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
75
+ cache_hit AS cacheHit, hash,
76
+ cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
77
+ wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs
78
+ FROM runs ${clause} ORDER BY started_at DESC LIMIT ?`,
79
+ )
80
+ .all(...params, limit) as RawRow[]
81
+ return rows.map((r) => ({
82
+ ...r,
83
+ cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit),
84
+ wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
85
+ wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(),
86
+ }))
87
+ }
88
+
89
+ /**
90
+ * One `vx run` invocation header — every `invocations` column, camelCased,
91
+ * `tags` parsed to a `Record<string,string>` and `dirty`/`ci`/`exitOk`
92
+ * surfaced as booleans. Superset of the old `InvocationRow`: the SPA's
93
+ * existing fields (runId / startedAt / endedAt / taskCount / failedCount /
94
+ * hitCount / totalDurationMs) are all present, so existing views keep working.
95
+ */
96
+ export interface InvocationDetail {
97
+ runId: string
98
+ command: string
99
+ /** The requested task names, parsed from the persisted JSON string[]. */
100
+ requestedTasks: string[]
101
+ cachePolicy: string
102
+ concurrency: number
103
+ flow: 'focused' | 'broad' | null
104
+ startedAt: number
105
+ endedAt: number
106
+ totalDurationMs: number
107
+ taskCount: number
108
+ failedCount: number
109
+ hitCount: number
110
+ hitLocalCount: number
111
+ hitRemoteCount: number
112
+ exitOk: boolean
113
+ commitSha: string | null
114
+ branch: string | null
115
+ dirty: boolean | null
116
+ ci: boolean
117
+ ciProvider: string | null
118
+ host: string | null
119
+ os: string | null
120
+ arch: string | null
121
+ vxVersion: string
122
+ tags: Record<string, string>
123
+ }
124
+
125
+ /** @deprecated kept as an alias of `InvocationDetail` for older callers. */
126
+ export type InvocationRow = InvocationDetail
127
+
128
+ interface InvocationRawRow {
129
+ runId: string
130
+ command: string
131
+ requestedTasks: string
132
+ cachePolicy: string
133
+ concurrency: number
134
+ flow: string | null
135
+ startedAt: number
136
+ endedAt: number
137
+ totalDurationMs: number
138
+ taskCount: number
139
+ failedCount: number
140
+ hitCount: number
141
+ hitLocalCount: number
142
+ hitRemoteCount: number
143
+ exitOk: number
144
+ commitSha: string | null
145
+ branch: string | null
146
+ dirty: number | null
147
+ ci: number
148
+ ciProvider: string | null
149
+ host: string | null
150
+ os: string | null
151
+ arch: string | null
152
+ vxVersion: string
153
+ tags: string
154
+ }
155
+
156
+ const INVOCATION_COLUMNS = `
157
+ run_id AS runId, command, requested_tasks AS requestedTasks,
158
+ cache_policy AS cachePolicy, concurrency, flow,
159
+ started_at AS startedAt, ended_at AS endedAt, total_duration_ms AS totalDurationMs,
160
+ task_count AS taskCount, failed_count AS failedCount, hit_count AS hitCount,
161
+ hit_local_count AS hitLocalCount, hit_remote_count AS hitRemoteCount,
162
+ exit_ok AS exitOk,
163
+ commit_sha AS commitSha, branch, dirty, ci, ci_provider AS ciProvider,
164
+ host, os, arch, vx_version AS vxVersion, tags`
165
+
166
+ function mapInvocation(r: InvocationRawRow): InvocationDetail {
167
+ let requestedTasks: string[] = []
168
+ try {
169
+ const parsed = JSON.parse(r.requestedTasks) as unknown
170
+ if (Array.isArray(parsed)) requestedTasks = parsed.map(String)
171
+ } catch {}
172
+ let tags: Record<string, string> = {}
173
+ try {
174
+ const parsed = JSON.parse(r.tags) as unknown
175
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
176
+ tags = Object.fromEntries(
177
+ Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [k, String(v)]),
178
+ )
179
+ }
180
+ } catch {}
181
+ return {
182
+ runId: r.runId,
183
+ command: r.command,
184
+ requestedTasks,
185
+ cachePolicy: r.cachePolicy,
186
+ concurrency: r.concurrency,
187
+ flow: r.flow === 'focused' || r.flow === 'broad' ? r.flow : null,
188
+ startedAt: r.startedAt,
189
+ endedAt: r.endedAt,
190
+ totalDurationMs: r.totalDurationMs,
191
+ taskCount: r.taskCount,
192
+ failedCount: r.failedCount,
193
+ hitCount: r.hitCount,
194
+ hitLocalCount: r.hitLocalCount,
195
+ hitRemoteCount: r.hitRemoteCount,
196
+ exitOk: Boolean(r.exitOk),
197
+ commitSha: r.commitSha,
198
+ branch: r.branch,
199
+ dirty: r.dirty === null ? null : Boolean(r.dirty),
200
+ ci: Boolean(r.ci),
201
+ ciProvider: r.ciProvider,
202
+ host: r.host,
203
+ os: r.os,
204
+ arch: r.arch,
205
+ vxVersion: r.vxVersion,
206
+ tags,
207
+ }
208
+ }
209
+
210
+ export function getInvocation(db: Database, runId: string): InvocationDetail | null {
211
+ const row = db
212
+ .query(`SELECT ${INVOCATION_COLUMNS} FROM invocations WHERE run_id = ?`)
213
+ .get(runId) as InvocationRawRow | undefined
214
+ return row ? mapInvocation(row) : null
215
+ }
216
+
217
+ export interface ListInvocationsArgs {
218
+ limit?: number
219
+ branch?: string
220
+ ci?: boolean
221
+ tagKey?: string
222
+ tagValue?: string
223
+ }
224
+
225
+ /**
226
+ * List `vx run` invocations newest-first from the `invocations` header table
227
+ * with optional branch / ci / tag filters. Reading the dedicated header table
228
+ * (vs the old `GROUP BY run_id` over `runs`) is lossless — git/ci/tag context
229
+ * survives. Accepts a bare `number` for the limit (back-compat with the old
230
+ * `listInvocations(db, 50)` signature).
231
+ */
232
+ export function listInvocations(
233
+ db: Database,
234
+ args: ListInvocationsArgs | number = {},
235
+ ): InvocationDetail[] {
236
+ const opts: ListInvocationsArgs = typeof args === 'number' ? { limit: args } : args
237
+ const limit = clampInt(opts.limit ?? 50, 1, 500)
238
+ const where: string[] = []
239
+ const params: (string | number)[] = []
240
+ if (opts.branch !== undefined) {
241
+ where.push('branch = ?')
242
+ params.push(opts.branch)
243
+ }
244
+ if (opts.ci !== undefined) {
245
+ where.push('ci = ?')
246
+ params.push(opts.ci ? 1 : 0)
247
+ }
248
+ if (opts.tagKey !== undefined && opts.tagValue !== undefined) {
249
+ // The tags column is a JSON object {"k":"v"}; a LIKE over the serialized
250
+ // pair is adequate at this table's scale (see the design doc).
251
+ where.push('tags LIKE ?')
252
+ params.push(`%${jsonPairFragment(opts.tagKey, opts.tagValue)}%`)
253
+ }
254
+ const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
255
+ const rows = db
256
+ .query(
257
+ `SELECT ${INVOCATION_COLUMNS} FROM invocations ${clause}
258
+ ORDER BY started_at DESC LIMIT ?`,
259
+ )
260
+ .all(...params, limit) as InvocationRawRow[]
261
+ return rows.map(mapInvocation)
262
+ }
263
+
264
+ /** The `"key":"value"` fragment as it appears in `JSON.stringify({k:v})`. */
265
+ function jsonPairFragment(key: string, value: string): string {
266
+ const k = JSON.stringify(key).slice(1, -1)
267
+ const v = JSON.stringify(value).slice(1, -1)
268
+ return `"${k}":"${v}"`
269
+ }
270
+
271
+ export interface RunDetail {
272
+ runId: string
273
+ startedAt: number
274
+ endedAt: number
275
+ tasks: RunSummaryRow[]
276
+ }
277
+
278
+ export function getRun(db: Database, runId: string): RunDetail | null {
279
+ const tasks = listRuns(db, { runId, limit: 100_000 })
280
+ if (tasks.length === 0) return null
281
+ const startedAt = Math.min(...tasks.map((t) => t.startedAt))
282
+ const endedAt = Math.max(...tasks.map((t) => t.endedAt))
283
+ return { runId, startedAt, endedAt, tasks }
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // Cache stats
288
+ // ---------------------------------------------------------------------------
289
+
290
+ export interface CacheStatsResult {
291
+ entryCount: number
292
+ totalBytes: number
293
+ runCountLast24h: number
294
+ hitCountLast24h: number
295
+ hitRate24h: number
296
+ /** `status = 'cache-hit'` over the last 24h. */
297
+ hitLocalCountLast24h: number
298
+ /** `status = 'cache-hit-remote'` over the last 24h. */
299
+ hitRemoteCountLast24h: number
300
+ }
301
+
302
+ export function getCacheStatsSql(db: Database): CacheStatsResult {
303
+ const aggregate = db
304
+ .query('SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries')
305
+ .get() as { n: number; bytes: number }
306
+ const since = Date.now() - 24 * 60 * 60 * 1000
307
+ const runs = db
308
+ .query(
309
+ `SELECT COUNT(*) AS total,
310
+ COALESCE(SUM(CASE WHEN status = 'cache-hit' THEN 1 ELSE 0 END), 0) AS hitLocal,
311
+ COALESCE(SUM(CASE WHEN status = 'cache-hit-remote' THEN 1 ELSE 0 END), 0) AS hitRemote
312
+ FROM runs WHERE started_at >= ?`,
313
+ )
314
+ .get(since) as { total: number; hitLocal: number; hitRemote: number }
315
+ const hits = runs.hitLocal + runs.hitRemote
316
+ return {
317
+ entryCount: aggregate.n,
318
+ totalBytes: aggregate.bytes,
319
+ runCountLast24h: runs.total,
320
+ hitCountLast24h: hits,
321
+ hitRate24h: runs.total > 0 ? hits / runs.total : 0,
322
+ hitLocalCountLast24h: runs.hitLocal,
323
+ hitRemoteCountLast24h: runs.hitRemote,
324
+ }
325
+ }
326
+
327
+ export interface HitRateSplit {
328
+ total: number
329
+ hits: number
330
+ hitLocal: number
331
+ hitRemote: number
332
+ hitRate: number
333
+ localShare: number
334
+ remoteShare: number
335
+ }
336
+
337
+ /**
338
+ * Local-vs-remote cache hit split over the last `days` days. `cache-hit` is a
339
+ * local restore; `cache-hit-remote` was pulled over the network.
340
+ */
341
+ export function getHitRateSplit(db: Database, days = 1): HitRateSplit {
342
+ const since = Date.now() - days * 24 * 60 * 60 * 1000
343
+ const r = db
344
+ .query(
345
+ `SELECT COUNT(*) AS total,
346
+ COALESCE(SUM(CASE WHEN status = 'cache-hit' THEN 1 ELSE 0 END), 0) AS hitLocal,
347
+ COALESCE(SUM(CASE WHEN status = 'cache-hit-remote' THEN 1 ELSE 0 END), 0) AS hitRemote
348
+ FROM runs WHERE started_at >= ?`,
349
+ )
350
+ .get(since) as { total: number; hitLocal: number; hitRemote: number }
351
+ const hits = r.hitLocal + r.hitRemote
352
+ return {
353
+ total: r.total,
354
+ hits,
355
+ hitLocal: r.hitLocal,
356
+ hitRemote: r.hitRemote,
357
+ hitRate: r.total > 0 ? hits / r.total : 0,
358
+ localShare: hits > 0 ? r.hitLocal / hits : 0,
359
+ remoteShare: hits > 0 ? r.hitRemote / hits : 0,
360
+ }
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Task history (the same SQL CTE LocalHistoryProvider uses)
365
+ // ---------------------------------------------------------------------------
366
+
367
+ export interface TaskHistoryRow {
368
+ id: string
369
+ project: string
370
+ task: string
371
+ runs: number
372
+ successes: number
373
+ failures: number
374
+ hits: number
375
+ successRate: number
376
+ hitRate: number
377
+ failureMode: 'stable' | 'flaky-recoverable' | 'flaky-fatal'
378
+ p50DurationMs: number | undefined
379
+ p99DurationMs: number | undefined
380
+ minDurationMs: number | undefined
381
+ maxDurationMs: number | undefined
382
+ avgDurationMs: number | undefined
383
+ totalDurationMs: number
384
+ lastSeenAt: number | undefined
385
+ }
386
+
387
+ export interface GetHistoryArgs {
388
+ project?: string
389
+ task?: string
390
+ limit?: number
391
+ }
392
+
393
+ export function getHistory(db: Database, args: GetHistoryArgs = {}): TaskHistoryRow[] {
394
+ const limit = clampInt(args.limit ?? 50, 1, 500)
395
+ const where: string[] = []
396
+ const params: (string | number)[] = []
397
+ if (args.project) {
398
+ where.push('project = ?')
399
+ params.push(args.project)
400
+ }
401
+ if (args.task) {
402
+ where.push('task = ?')
403
+ params.push(args.task)
404
+ }
405
+ const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
406
+ const pairs = db.query(`SELECT DISTINCT project, task FROM runs ${clause}`).all(...params) as {
407
+ project: string
408
+ task: string
409
+ }[]
410
+
411
+ return pairs.slice(0, limit).map((p) => {
412
+ const aggregate = db
413
+ .query(
414
+ `SELECT
415
+ COUNT(*) AS total,
416
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successes,
417
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
418
+ SUM(CASE WHEN cache_hit = 1 OR status LIKE 'cache-hit%' THEN 1 ELSE 0 END) AS hits,
419
+ SUM(duration_ms) AS totalDurationMs,
420
+ MAX(ended_at) AS lastSeenAt
421
+ FROM runs WHERE project = ? AND task = ?`,
422
+ )
423
+ .get(p.project, p.task) as {
424
+ total: number
425
+ successes: number
426
+ failures: number
427
+ hits: number
428
+ totalDurationMs: number | null
429
+ lastSeenAt: number | null
430
+ }
431
+ const total = aggregate.total || 0
432
+ const failures = aggregate.failures || 0
433
+ const failureMode: TaskHistoryRow['failureMode'] =
434
+ failures === 0 ? 'stable' : failures < total / 5 ? 'flaky-recoverable' : 'flaky-fatal'
435
+ const durations = db
436
+ .query(
437
+ `SELECT duration_ms FROM runs
438
+ WHERE project = ? AND task = ?
439
+ AND (cache_hit IS NULL OR cache_hit = 0)
440
+ AND status = 'success'
441
+ ORDER BY started_at DESC LIMIT 50`,
442
+ )
443
+ .all(p.project, p.task) as { duration_ms: number }[]
444
+ const sorted = durations.map((r) => r.duration_ms).sort((a, b) => a - b)
445
+ const avg = sorted.length > 0 ? sorted.reduce((a, b) => a + b, 0) / sorted.length : undefined
446
+ return {
447
+ id: `${p.project}#${p.task}`,
448
+ project: p.project,
449
+ task: p.task,
450
+ runs: total,
451
+ successes: aggregate.successes || 0,
452
+ failures,
453
+ hits: aggregate.hits || 0,
454
+ successRate: total > 0 ? (aggregate.successes || 0) / total : 0,
455
+ hitRate: total > 0 ? (aggregate.hits || 0) / total : 0,
456
+ failureMode,
457
+ p50DurationMs: pickPercentile(sorted, 0.5),
458
+ p99DurationMs: pickPercentile(sorted, 0.99),
459
+ minDurationMs: sorted[0],
460
+ maxDurationMs: sorted[sorted.length - 1],
461
+ avgDurationMs: avg !== undefined ? Math.round(avg) : undefined,
462
+ totalDurationMs: aggregate.totalDurationMs ?? 0,
463
+ lastSeenAt: aggregate.lastSeenAt ?? undefined,
464
+ }
465
+ })
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Top time-burners — where to invest
470
+ // ---------------------------------------------------------------------------
471
+
472
+ export interface TopTaskRow {
473
+ id: string
474
+ project: string
475
+ task: string
476
+ runs: number
477
+ totalDurationMs: number
478
+ avgDurationMs: number
479
+ }
480
+
481
+ export function getTopTimeBurners(db: Database, limit = 10): TopTaskRow[] {
482
+ return db
483
+ .query(
484
+ `SELECT project || '#' || task AS id, project, task,
485
+ COUNT(*) AS runs,
486
+ SUM(duration_ms) AS totalDurationMs,
487
+ CAST(AVG(duration_ms) AS INTEGER) AS avgDurationMs
488
+ FROM runs
489
+ WHERE (cache_hit IS NULL OR cache_hit = 0) AND status = 'success'
490
+ GROUP BY project, task
491
+ ORDER BY SUM(duration_ms) DESC
492
+ LIMIT ?`,
493
+ )
494
+ .all(clampInt(limit, 1, 100)) as TopTaskRow[]
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Recent failures — what's bleeding right now
499
+ // ---------------------------------------------------------------------------
500
+
501
+ export interface FailureRow {
502
+ runId: string | null
503
+ project: string
504
+ task: string
505
+ exitCode: number
506
+ durationMs: number
507
+ startedAt: number
508
+ hash: string
509
+ }
510
+
511
+ export function getRecentFailures(db: Database, limit = 25): FailureRow[] {
512
+ return db
513
+ .query(
514
+ `SELECT run_id AS runId, project, task, exit_code AS exitCode,
515
+ duration_ms AS durationMs, started_at AS startedAt, hash
516
+ FROM runs
517
+ WHERE status = 'failed'
518
+ ORDER BY started_at DESC
519
+ LIMIT ?`,
520
+ )
521
+ .all(clampInt(limit, 1, 200)) as FailureRow[]
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Cache entries — what's actually stored
526
+ // ---------------------------------------------------------------------------
527
+
528
+ export interface CacheEntryRow {
529
+ hash: string
530
+ project: string
531
+ task: string
532
+ command: string
533
+ exitCode: number
534
+ durationMs: number
535
+ sizeBytes: number
536
+ createdAt: number
537
+ accessedAt: number
538
+ }
539
+
540
+ export interface ListCacheEntriesArgs {
541
+ limit?: number
542
+ orderBy?: 'created_at' | 'accessed_at' | 'size_bytes' | 'duration_ms'
543
+ project?: string
544
+ }
545
+
546
+ export function listCacheEntries(db: Database, args: ListCacheEntriesArgs = {}): CacheEntryRow[] {
547
+ const limit = clampInt(args.limit ?? 100, 1, 500)
548
+ const orderBy = args.orderBy ?? 'created_at'
549
+ const allowed = new Set(['created_at', 'accessed_at', 'size_bytes', 'duration_ms'])
550
+ const order = allowed.has(orderBy) ? orderBy : 'created_at'
551
+ const where: string[] = []
552
+ const params: (string | number)[] = []
553
+ if (args.project) {
554
+ where.push('project = ?')
555
+ params.push(args.project)
556
+ }
557
+ const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
558
+ return db
559
+ .query(
560
+ `SELECT hash, project, task, command, exit_code AS exitCode,
561
+ duration_ms AS durationMs, size_bytes AS sizeBytes,
562
+ created_at AS createdAt, accessed_at AS accessedAt
563
+ FROM entries ${clause}
564
+ ORDER BY ${order} DESC
565
+ LIMIT ?`,
566
+ )
567
+ .all(...params, limit) as CacheEntryRow[]
568
+ }
569
+
570
+ // ---------------------------------------------------------------------------
571
+ // Cache breakdown — bytes per project
572
+ // ---------------------------------------------------------------------------
573
+
574
+ export interface CacheProjectRow {
575
+ project: string
576
+ entries: number
577
+ totalBytes: number
578
+ }
579
+
580
+ export function getCacheBreakdown(db: Database, limit = 20): CacheProjectRow[] {
581
+ return db
582
+ .query(
583
+ `SELECT project,
584
+ COUNT(*) AS entries,
585
+ COALESCE(SUM(size_bytes), 0) AS totalBytes
586
+ FROM entries
587
+ GROUP BY project
588
+ ORDER BY SUM(size_bytes) DESC
589
+ LIMIT ?`,
590
+ )
591
+ .all(clampInt(limit, 1, 100)) as CacheProjectRow[]
592
+ }
593
+
594
+ // ---------------------------------------------------------------------------
595
+ // Task detail — full history for one (project, task)
596
+ // ---------------------------------------------------------------------------
597
+
598
+ export interface TaskDetail {
599
+ project: string
600
+ task: string
601
+ aggregate: TaskHistoryRow | null
602
+ recent: RunSummaryRow[]
603
+ latestEntry: CacheEntryRow | null
604
+ }
605
+
606
+ export function getTaskDetail(db: Database, taskId: string): TaskDetail | null {
607
+ const [project, task] = taskId.split('#', 2) as [string, string]
608
+ const existsRow = db
609
+ .query('SELECT 1 FROM runs WHERE project = ? AND task = ? LIMIT 1')
610
+ .get(project, task) as { 1: number } | undefined
611
+ if (!existsRow) {
612
+ const entryProbe = db
613
+ .query('SELECT 1 FROM entries WHERE project = ? AND task = ? LIMIT 1')
614
+ .get(project, task)
615
+ if (!entryProbe) return null
616
+ }
617
+ const recent = listRuns(db, { project, task, limit: 100 })
618
+ const histRows = getHistory(db, { project, task, limit: 1 })
619
+ const entry = db
620
+ .query(
621
+ `SELECT hash, project, task, command, exit_code AS exitCode,
622
+ duration_ms AS durationMs, size_bytes AS sizeBytes,
623
+ created_at AS createdAt, accessed_at AS accessedAt
624
+ FROM entries WHERE project = ? AND task = ?
625
+ ORDER BY created_at DESC LIMIT 1`,
626
+ )
627
+ .get(project, task) as CacheEntryRow | undefined
628
+ return {
629
+ project,
630
+ task,
631
+ aggregate: histRows[0] ?? null,
632
+ recent,
633
+ latestEntry: entry ?? null,
634
+ }
635
+ }
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // Cache savings — how much time the cache saved you (rough estimate)
639
+ // ---------------------------------------------------------------------------
640
+
641
+ export interface CacheSavings {
642
+ hitsLast24h: number
643
+ estimatedTimeSavedMs: number
644
+ estimatedTimeSavedTotalMs: number
645
+ }
646
+
647
+ /**
648
+ * For each cache-hit run, attribute the avg non-hit duration of the
649
+ * same (project, task) as "time saved." Rough but useful — shows the
650
+ * payoff of caching at a glance.
651
+ */
652
+ export function getCacheSavings(db: Database): CacheSavings {
653
+ const since = Date.now() - 24 * 60 * 60 * 1000
654
+ const r24 = db
655
+ .query(
656
+ `SELECT COALESCE(SUM(avgDur), 0) AS saved, COUNT(*) AS hits FROM (
657
+ SELECT r.project, r.task,
658
+ (SELECT CAST(AVG(duration_ms) AS INTEGER) FROM runs s
659
+ WHERE s.project = r.project AND s.task = r.task
660
+ AND (s.cache_hit IS NULL OR s.cache_hit = 0)
661
+ AND s.status = 'success') AS avgDur
662
+ FROM runs r
663
+ WHERE r.started_at >= ?
664
+ AND (r.cache_hit = 1 OR r.status LIKE 'cache-hit%')
665
+ ) WHERE avgDur IS NOT NULL`,
666
+ )
667
+ .get(since) as { saved: number; hits: number }
668
+ const rAll = db
669
+ .query(
670
+ `SELECT COALESCE(SUM(avgDur), 0) AS saved FROM (
671
+ SELECT r.project, r.task,
672
+ (SELECT CAST(AVG(duration_ms) AS INTEGER) FROM runs s
673
+ WHERE s.project = r.project AND s.task = r.task
674
+ AND (s.cache_hit IS NULL OR s.cache_hit = 0)
675
+ AND s.status = 'success') AS avgDur
676
+ FROM runs r
677
+ WHERE (r.cache_hit = 1 OR r.status LIKE 'cache-hit%')
678
+ ) WHERE avgDur IS NOT NULL`,
679
+ )
680
+ .get() as { saved: number }
681
+ return {
682
+ hitsLast24h: r24.hits,
683
+ estimatedTimeSavedMs: r24.saved,
684
+ estimatedTimeSavedTotalMs: rAll.saved,
685
+ }
686
+ }
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // Cache key explain — latest entries row
690
+ // ---------------------------------------------------------------------------
691
+
692
+ export interface CacheKeyExplanation {
693
+ taskId: string
694
+ project: string
695
+ task: string
696
+ latestEntry: {
697
+ hash: string
698
+ command: string
699
+ exitCode: number
700
+ durationMs: number
701
+ sizeBytes: number
702
+ createdAt: number
703
+ } | null
704
+ note: string
705
+ }
706
+
707
+ export function explainCacheKey(db: Database, taskId: string): CacheKeyExplanation {
708
+ const [project, task] = taskId.split('#', 2) as [string, string]
709
+ const entry = db
710
+ .query(
711
+ `SELECT hash, command, exit_code AS exitCode, duration_ms AS durationMs,
712
+ size_bytes AS sizeBytes, created_at AS createdAt
713
+ FROM entries WHERE project = ? AND task = ? ORDER BY created_at DESC LIMIT 1`,
714
+ )
715
+ .get(project, task) as CacheKeyExplanation['latestEntry']
716
+ return {
717
+ taskId,
718
+ project,
719
+ task,
720
+ latestEntry: entry ?? null,
721
+ note: 'cache key components (files / env / runtime / upstream) require live config evaluation; this surface returns persisted entry metadata',
722
+ }
723
+ }
724
+
725
+ // ---------------------------------------------------------------------------
726
+ // Why did this rerun — compare two runs
727
+ // ---------------------------------------------------------------------------
728
+
729
+ export interface WhyDidThisRerun {
730
+ runId: string
731
+ taskId: string
732
+ found: boolean
733
+ thisRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number }
734
+ previousRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number } | null
735
+ hashChanged?: boolean | null
736
+ note: string
737
+ }
738
+
739
+ export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
740
+ const [project, task] = taskId.split('#', 2) as [string, string]
741
+ const this_ = db
742
+ .query(
743
+ `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
744
+ FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
745
+ )
746
+ .get(runId, project, task) as
747
+ | { hash: string; status: string; cacheHit: number | null; startedAt: number }
748
+ | undefined
749
+ if (!this_) {
750
+ return {
751
+ runId,
752
+ taskId,
753
+ found: false,
754
+ note: 'no row matching that runId + taskId',
755
+ }
756
+ }
757
+ const prev = db
758
+ .query(
759
+ `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
760
+ FROM runs WHERE project = ? AND task = ? AND started_at < ?
761
+ ORDER BY started_at DESC LIMIT 1`,
762
+ )
763
+ .get(project, task, this_.startedAt) as
764
+ | { hash: string; status: string; cacheHit: number | null; startedAt: number }
765
+ | undefined
766
+ return {
767
+ runId,
768
+ taskId,
769
+ found: true,
770
+ thisRun: { ...this_, cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit) },
771
+ previousRun: prev
772
+ ? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) }
773
+ : null,
774
+ hashChanged: prev ? prev.hash !== this_.hash : null,
775
+ note:
776
+ prev && prev.hash !== this_.hash
777
+ ? 'cache key changed between the previous run and this one (inputs differ)'
778
+ : prev
779
+ ? 'cache key unchanged — re-run with the same key (likely --no-cache or unrelated)'
780
+ : 'no prior run for this (project, task)',
781
+ }
782
+ }
783
+
784
+ // ---------------------------------------------------------------------------
785
+ // Cache-key diff — the input-fingerprint moat
786
+ // ---------------------------------------------------------------------------
787
+
788
+ /** One changed/added/removed cache-key component between two runs of a task. */
789
+ export interface InputDiffEntry {
790
+ kind: string
791
+ name: string
792
+ change: 'added' | 'removed' | 'changed'
793
+ /** The component's hash in the previous run (null when `added`). */
794
+ before: string | null
795
+ /** The component's hash in this run (null when `removed`). */
796
+ after: string | null
797
+ }
798
+
799
+ export interface CacheKeyDiff {
800
+ runId: string
801
+ taskId: string
802
+ found: boolean
803
+ previousRunId: string | null
804
+ /** Only changed / added / removed components. Unchanged ones are counted. */
805
+ entries: InputDiffEntry[]
806
+ unchangedCount: number
807
+ note: string
808
+ }
809
+
810
+ interface EntryInputRow {
811
+ kind: string
812
+ name: string
813
+ hash: string
814
+ }
815
+
816
+ function loadEntryInputs(db: Database, entryHash: string): Map<string, EntryInputRow> {
817
+ const rows = db
818
+ .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
819
+ .all(entryHash) as EntryInputRow[]
820
+ return new Map(rows.map((r) => [`${r.kind}\0${r.name}`, r]))
821
+ }
822
+
823
+ /**
824
+ * The moat: name the exact cache-key components (files / env / runtime /
825
+ * upstream …) that differ between this run of a task and its immediately-
826
+ * previous run. Resolves each run to its task hash via `runs.hash`, then
827
+ * full-outer-joins the two runs' `entry_inputs` rows (keyed by the entry
828
+ * hash) over `(kind, name)`:
829
+ *
830
+ * - present in both with a different hash → `changed`
831
+ * - only in this run → `added`
832
+ * - only in the previous run → `removed`
833
+ * - equal → counted as unchanged
834
+ *
835
+ * Pure SQL + an app-side set join — no config re-evaluation, no re-hash.
836
+ * Always returns a value (never throws); `found:false` when the run/task pair
837
+ * has no row, `entries:[]` for the first run of a task.
838
+ */
839
+ export function cacheKeyDiff(db: Database, runId: string, taskId: string): CacheKeyDiff {
840
+ const [project, task] = taskId.split('#', 2) as [string, string]
841
+ const this_ = db
842
+ .query(
843
+ 'SELECT hash, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
844
+ )
845
+ .get(runId, project, task) as { hash: string; startedAt: number } | undefined
846
+ if (!this_) {
847
+ return {
848
+ runId,
849
+ taskId,
850
+ found: false,
851
+ previousRunId: null,
852
+ entries: [],
853
+ unchangedCount: 0,
854
+ note: 'no row matching that runId + taskId',
855
+ }
856
+ }
857
+
858
+ const prev = db
859
+ .query(
860
+ `SELECT run_id AS runId, hash FROM runs
861
+ WHERE project = ? AND task = ? AND started_at < ?
862
+ ORDER BY started_at DESC LIMIT 1`,
863
+ )
864
+ .get(project, task, this_.startedAt) as { runId: string | null; hash: string } | undefined
865
+
866
+ if (!prev) {
867
+ return {
868
+ runId,
869
+ taskId,
870
+ found: true,
871
+ previousRunId: null,
872
+ entries: [],
873
+ unchangedCount: 0,
874
+ note: 'no prior run for this (project, task) — nothing to diff',
875
+ }
876
+ }
877
+
878
+ if (prev.hash === this_.hash) {
879
+ return {
880
+ runId,
881
+ taskId,
882
+ found: true,
883
+ previousRunId: prev.runId,
884
+ entries: [],
885
+ unchangedCount: 0,
886
+ note: 'cache key unchanged between the previous run and this one (same inputs)',
887
+ }
888
+ }
889
+
890
+ const cur = loadEntryInputs(db, this_.hash)
891
+ const old = loadEntryInputs(db, prev.hash)
892
+
893
+ // Input fingerprints are pruned with their entry (ON DELETE CASCADE); if
894
+ // either side's rows are gone, we can name the hash change but not the
895
+ // component-level diff.
896
+ if (cur.size === 0 || old.size === 0) {
897
+ return {
898
+ runId,
899
+ taskId,
900
+ found: true,
901
+ previousRunId: prev.runId,
902
+ entries: [],
903
+ unchangedCount: 0,
904
+ note: 'cache key changed but input fingerprints are unavailable (entry pruned); only the hash change is known',
905
+ }
906
+ }
907
+
908
+ const entries: InputDiffEntry[] = []
909
+ let unchangedCount = 0
910
+ const keys = new Set<string>([...cur.keys(), ...old.keys()])
911
+ for (const key of keys) {
912
+ const a = cur.get(key)
913
+ const b = old.get(key)
914
+ if (a && b) {
915
+ if (a.hash === b.hash) unchangedCount++
916
+ else
917
+ entries.push({
918
+ kind: a.kind,
919
+ name: a.name,
920
+ change: 'changed',
921
+ before: b.hash,
922
+ after: a.hash,
923
+ })
924
+ } else if (a) {
925
+ entries.push({ kind: a.kind, name: a.name, change: 'added', before: null, after: a.hash })
926
+ } else if (b) {
927
+ entries.push({ kind: b.kind, name: b.name, change: 'removed', before: b.hash, after: null })
928
+ }
929
+ }
930
+ entries.sort((x, y) => x.kind.localeCompare(y.kind) || x.name.localeCompare(y.name))
931
+
932
+ return {
933
+ runId,
934
+ taskId,
935
+ found: true,
936
+ previousRunId: prev.runId,
937
+ entries,
938
+ unchangedCount,
939
+ note:
940
+ entries.length > 0
941
+ ? `${entries.length} cache-key component(s) changed since the previous run`
942
+ : 'cache key changed but no component-level difference was recorded',
943
+ }
944
+ }
945
+
946
+ // ---------------------------------------------------------------------------
947
+ // Run comparison — diff a run against the immediately-previous invocation
948
+ // ---------------------------------------------------------------------------
949
+
950
+ /** One task's outcome on one side of a comparison. */
951
+ export interface CompareTaskSide {
952
+ status: string
953
+ durationMs: number
954
+ hash: string
955
+ cacheHit: boolean | null
956
+ exitCode: number
957
+ }
958
+
959
+ /** A per-task diff row. `a` = this run, `b` = the previous run. */
960
+ export interface CompareTaskRow {
961
+ taskId: string
962
+ project: string
963
+ task: string
964
+ a: CompareTaskSide | null
965
+ b: CompareTaskSide | null
966
+ /** Cache key differs (or the task is only on one side). */
967
+ hashChanged: boolean
968
+ /** a.durationMs − b.durationMs; null when either side is absent. */
969
+ durationDeltaMs: number | null
970
+ /** Outcome status differs (or the task is only on one side). */
971
+ statusChanged: boolean
972
+ }
973
+
974
+ export interface CompareRuns {
975
+ runId: string
976
+ previousRunId: string | null
977
+ startedAt: number | null
978
+ prevStartedAt: number | null
979
+ found: boolean
980
+ summary: {
981
+ aTotalMs: number
982
+ bTotalMs: number
983
+ totalDeltaMs: number
984
+ tasksChanged: number
985
+ tasksOnlyInA: number
986
+ tasksOnlyInB: number
987
+ }
988
+ tasks: CompareTaskRow[]
989
+ note: string
990
+ }
991
+
992
+ function sideOf(row: RunSummaryRow): CompareTaskSide {
993
+ return {
994
+ status: row.status,
995
+ durationMs: row.durationMs,
996
+ hash: row.hash,
997
+ cacheHit: row.cacheHit,
998
+ exitCode: row.exitCode,
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * Compare a run to the immediately-previous invocation (the one with the
1004
+ * largest `started_at` strictly before this run's start). For every task in
1005
+ * either invocation, emit a diff row. Tasks present on only one side carry a
1006
+ * null on the missing side and count as changed.
1007
+ */
1008
+ export function compareRuns(db: Database, runId: string): CompareRuns {
1009
+ const aRun = getRun(db, runId)
1010
+ if (!aRun) {
1011
+ return {
1012
+ runId,
1013
+ previousRunId: null,
1014
+ startedAt: null,
1015
+ prevStartedAt: null,
1016
+ found: false,
1017
+ summary: {
1018
+ aTotalMs: 0,
1019
+ bTotalMs: 0,
1020
+ totalDeltaMs: 0,
1021
+ tasksChanged: 0,
1022
+ tasksOnlyInA: 0,
1023
+ tasksOnlyInB: 0,
1024
+ },
1025
+ tasks: [],
1026
+ note: 'no run matching that runId',
1027
+ }
1028
+ }
1029
+ const prev = db
1030
+ .query(
1031
+ `SELECT run_id AS runId, MIN(started_at) AS startedAt
1032
+ FROM runs
1033
+ WHERE run_id IS NOT NULL AND run_id != ? AND started_at < ?
1034
+ GROUP BY run_id
1035
+ ORDER BY MIN(started_at) DESC
1036
+ LIMIT 1`,
1037
+ )
1038
+ .get(runId, aRun.startedAt) as { runId: string; startedAt: number } | undefined
1039
+
1040
+ const bRun = prev ? getRun(db, prev.runId) : null
1041
+
1042
+ // Key by project#task; a run can list a task once per invocation.
1043
+ const byKeyA = new Map(aRun.tasks.map((t) => [`${t.project}#${t.task}`, t]))
1044
+ const byKeyB = new Map((bRun?.tasks ?? []).map((t) => [`${t.project}#${t.task}`, t]))
1045
+ const keys = [...new Set([...byKeyA.keys(), ...byKeyB.keys()])].sort()
1046
+
1047
+ let aTotalMs = 0
1048
+ let bTotalMs = 0
1049
+ let tasksChanged = 0
1050
+ let tasksOnlyInA = 0
1051
+ let tasksOnlyInB = 0
1052
+
1053
+ const tasks: CompareTaskRow[] = keys.map((key) => {
1054
+ const ra = byKeyA.get(key)
1055
+ const rb = byKeyB.get(key)
1056
+ const a = ra ? sideOf(ra) : null
1057
+ const b = rb ? sideOf(rb) : null
1058
+ if (a) aTotalMs += a.durationMs
1059
+ if (b) bTotalMs += b.durationMs
1060
+ const hashChanged = a !== null && b !== null ? a.hash !== b.hash : true
1061
+ const statusChanged = a !== null && b !== null ? a.status !== b.status : true
1062
+ const durationDeltaMs = a !== null && b !== null ? a.durationMs - b.durationMs : null
1063
+ if (!b) tasksOnlyInA++
1064
+ if (!a) tasksOnlyInB++
1065
+ if (hashChanged || statusChanged) tasksChanged++
1066
+ const [project, task] = key.split('#', 2) as [string, string]
1067
+ return { taskId: key, project, task, a, b, hashChanged, durationDeltaMs, statusChanged }
1068
+ })
1069
+
1070
+ return {
1071
+ runId,
1072
+ previousRunId: prev?.runId ?? null,
1073
+ startedAt: aRun.startedAt,
1074
+ prevStartedAt: prev?.startedAt ?? null,
1075
+ found: prev != null,
1076
+ summary: {
1077
+ aTotalMs,
1078
+ bTotalMs,
1079
+ totalDeltaMs: aTotalMs - bTotalMs,
1080
+ tasksChanged,
1081
+ tasksOnlyInA,
1082
+ tasksOnlyInB,
1083
+ },
1084
+ tasks,
1085
+ note: prev
1086
+ ? 'compared against the immediately-previous invocation'
1087
+ : 'no previous invocation to compare against',
1088
+ }
1089
+ }
1090
+
1091
+ // ---------------------------------------------------------------------------
1092
+ // helpers
1093
+ // ---------------------------------------------------------------------------
1094
+
1095
+ function clampInt(n: number, min: number, max: number): number {
1096
+ if (!Number.isFinite(n)) return min
1097
+ return Math.min(max, Math.max(min, Math.floor(n)))
1098
+ }
1099
+
1100
+ function pickPercentile(sorted: number[], q: number): number | undefined {
1101
+ if (sorted.length === 0) return undefined
1102
+ const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length))
1103
+ return sorted[idx]
1104
+ }
1105
+
1106
+ // ---------------------------------------------------------------------------
1107
+ // Project-level rollups — where the time and storage actually sit
1108
+ // ---------------------------------------------------------------------------
1109
+
1110
+ export interface ProjectRollup {
1111
+ project: string
1112
+ taskCount: number
1113
+ runs: number
1114
+ failures: number
1115
+ hits: number
1116
+ hitRate: number
1117
+ totalDurationMs: number
1118
+ avgDurationMs: number
1119
+ cacheBytes: number
1120
+ cacheEntries: number
1121
+ lastRunAt: number | undefined
1122
+ estimatedTimeSavedMs: number
1123
+ }
1124
+
1125
+ export function listProjects(db: Database, limit = 100): ProjectRollup[] {
1126
+ const rows = db
1127
+ .query(
1128
+ `SELECT project,
1129
+ COUNT(DISTINCT task) AS taskCount,
1130
+ COUNT(*) AS runs,
1131
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
1132
+ SUM(CASE WHEN cache_hit = 1 OR status LIKE 'cache-hit%' THEN 1 ELSE 0 END) AS hits,
1133
+ SUM(duration_ms) AS totalDurationMs,
1134
+ CAST(AVG(duration_ms) AS INTEGER) AS avgDurationMs,
1135
+ MAX(ended_at) AS lastRunAt
1136
+ FROM runs GROUP BY project ORDER BY SUM(duration_ms) DESC LIMIT ?`,
1137
+ )
1138
+ .all(clampInt(limit, 1, 500)) as Array<{
1139
+ project: string
1140
+ taskCount: number
1141
+ runs: number
1142
+ failures: number
1143
+ hits: number
1144
+ totalDurationMs: number | null
1145
+ avgDurationMs: number | null
1146
+ lastRunAt: number | null
1147
+ }>
1148
+ return rows.map((r) => {
1149
+ const ent = db
1150
+ .query(
1151
+ 'SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b FROM entries WHERE project = ?',
1152
+ )
1153
+ .get(r.project) as { n: number; b: number }
1154
+ const saved = db
1155
+ .query(
1156
+ `SELECT COALESCE(SUM(avg), 0) AS saved FROM (
1157
+ SELECT (SELECT CAST(AVG(duration_ms) AS INTEGER) FROM runs s
1158
+ WHERE s.project = r.project AND s.task = r.task
1159
+ AND (s.cache_hit IS NULL OR s.cache_hit = 0)
1160
+ AND s.status = 'success') AS avg
1161
+ FROM runs r WHERE r.project = ?
1162
+ AND (r.cache_hit = 1 OR r.status LIKE 'cache-hit%')
1163
+ ) WHERE avg IS NOT NULL`,
1164
+ )
1165
+ .get(r.project) as { saved: number }
1166
+ return {
1167
+ project: r.project,
1168
+ taskCount: r.taskCount,
1169
+ runs: r.runs,
1170
+ failures: r.failures,
1171
+ hits: r.hits,
1172
+ hitRate: r.runs > 0 ? r.hits / r.runs : 0,
1173
+ totalDurationMs: r.totalDurationMs ?? 0,
1174
+ avgDurationMs: r.avgDurationMs ?? 0,
1175
+ cacheBytes: ent.b,
1176
+ cacheEntries: ent.n,
1177
+ lastRunAt: r.lastRunAt ?? undefined,
1178
+ estimatedTimeSavedMs: saved.saved,
1179
+ }
1180
+ })
1181
+ }
1182
+
1183
+ // ---------------------------------------------------------------------------
1184
+ // Trends — bucketed time-series for charts
1185
+ // ---------------------------------------------------------------------------
1186
+
1187
+ export type TrendBucket = 'hour' | 'day'
1188
+
1189
+ export interface TrendPoint {
1190
+ /** Epoch ms at the start of the bucket. */
1191
+ t: number
1192
+ runs: number
1193
+ hits: number
1194
+ /** `status = 'cache-hit'` in the bucket (local restores). */
1195
+ hitsLocal: number
1196
+ /** `status = 'cache-hit-remote'` in the bucket (remote pulls). */
1197
+ hitsRemote: number
1198
+ failures: number
1199
+ /** Sum of duration_ms in the bucket. */
1200
+ totalDurationMs: number
1201
+ }
1202
+
1203
+ /**
1204
+ * Bucketed run counts + failure/hit/duration over time. Default range = last
1205
+ * 24h for hour buckets, last 30d for day buckets — picked to match the chart
1206
+ * defaults clients use.
1207
+ */
1208
+ export function getRunTrends(
1209
+ db: Database,
1210
+ args: { bucket?: TrendBucket; from?: number; to?: number } = {},
1211
+ ): TrendPoint[] {
1212
+ const bucket: TrendBucket = args.bucket ?? 'hour'
1213
+ const to = args.to ?? Date.now()
1214
+ const defaultRangeMs = bucket === 'hour' ? 24 * 60 * 60 * 1000 : 30 * 24 * 60 * 60 * 1000
1215
+ const from = args.from ?? to - defaultRangeMs
1216
+ const bucketMs = bucket === 'hour' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000
1217
+ // Floor each timestamp to its bucket boundary in SQL so partial buckets line
1218
+ // up cleanly. `started_at` is already epoch-ms.
1219
+ const rows = db
1220
+ .query(
1221
+ `SELECT (started_at / ?) * ? AS t,
1222
+ COUNT(*) AS runs,
1223
+ SUM(CASE WHEN cache_hit = 1 OR status LIKE 'cache-hit%' THEN 1 ELSE 0 END) AS hits,
1224
+ SUM(CASE WHEN status = 'cache-hit' THEN 1 ELSE 0 END) AS hitsLocal,
1225
+ SUM(CASE WHEN status = 'cache-hit-remote' THEN 1 ELSE 0 END) AS hitsRemote,
1226
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
1227
+ SUM(duration_ms) AS totalDurationMs
1228
+ FROM runs
1229
+ WHERE started_at >= ? AND started_at <= ?
1230
+ GROUP BY t ORDER BY t ASC`,
1231
+ )
1232
+ .all(bucketMs, bucketMs, from, to) as TrendPoint[]
1233
+ // Densify — emit zeros for empty buckets so the chart line stays continuous.
1234
+ const start = Math.floor(from / bucketMs) * bucketMs
1235
+ const end = Math.floor(to / bucketMs) * bucketMs
1236
+ const byT = new Map(rows.map((r) => [r.t, r]))
1237
+ const out: TrendPoint[] = []
1238
+ for (let t = start; t <= end; t += bucketMs) {
1239
+ out.push(
1240
+ byT.get(t) ?? {
1241
+ t,
1242
+ runs: 0,
1243
+ hits: 0,
1244
+ hitsLocal: 0,
1245
+ hitsRemote: 0,
1246
+ failures: 0,
1247
+ totalDurationMs: 0,
1248
+ },
1249
+ )
1250
+ }
1251
+ return out
1252
+ }
1253
+
1254
+ // ---------------------------------------------------------------------------
1255
+ // Heatmap — runs per (hour-of-day, day-of-week)
1256
+ // ---------------------------------------------------------------------------
1257
+
1258
+ export interface HeatmapCell {
1259
+ /** 0 = Sun … 6 = Sat */
1260
+ dayOfWeek: number
1261
+ /** 0 … 23, local time */
1262
+ hourOfDay: number
1263
+ runs: number
1264
+ totalDurationMs: number
1265
+ }
1266
+
1267
+ /** When do builds happen? Surfaces a 7×24 grid for the last `days` days. */
1268
+ export function getRunHeatmap(db: Database, days = 30): HeatmapCell[] {
1269
+ const since = Date.now() - days * 24 * 60 * 60 * 1000
1270
+ // Pull raw rows; bucket in JS (timezone math is ugly in pure SQLite, and
1271
+ // `days * 24 * runs/day` rows is a few thousand at most).
1272
+ const rows = db
1273
+ .query('SELECT started_at, duration_ms FROM runs WHERE started_at >= ?')
1274
+ .all(since) as { started_at: number; duration_ms: number }[]
1275
+ const grid: HeatmapCell[] = []
1276
+ for (let d = 0; d < 7; d++)
1277
+ for (let h = 0; h < 24; h++)
1278
+ grid.push({ dayOfWeek: d, hourOfDay: h, runs: 0, totalDurationMs: 0 })
1279
+ for (const r of rows) {
1280
+ const date = new Date(r.started_at)
1281
+ const cell = grid[date.getDay() * 24 + date.getHours()]!
1282
+ cell.runs++
1283
+ cell.totalDurationMs += r.duration_ms
1284
+ }
1285
+ return grid
1286
+ }
1287
+
1288
+ // ---------------------------------------------------------------------------
1289
+ // Flakiness — tasks that fail unpredictably or whose p99/p50 gap is wide
1290
+ // ---------------------------------------------------------------------------
1291
+
1292
+ export interface FlakyTask {
1293
+ id: string
1294
+ project: string
1295
+ task: string
1296
+ runs: number
1297
+ failures: number
1298
+ failureRate: number
1299
+ /**
1300
+ * Runs where the task needed MORE than one attempt (`exec.retries` /
1301
+ * `--retry` re-ran it and it eventually passed). This is the DIRECT
1302
+ * flaky signal — a task that failed then passed under identical inputs
1303
+ * is nondeterministic by definition, no cross-run inference needed.
1304
+ */
1305
+ withinRunRetries: number
1306
+ /** The worst attempt count seen in any single run (undefined if never retried). */
1307
+ maxAttempts: number | undefined
1308
+ /** True when `withinRunRetries > 0` — flakiness is CONFIRMED, not inferred. */
1309
+ flakyConfirmed: boolean
1310
+ /** p99 / p50 ratio for successful non-hit runs; >3 flags wide tail. */
1311
+ durationTailRatio: number | undefined
1312
+ p50DurationMs: number | undefined
1313
+ p99DurationMs: number | undefined
1314
+ }
1315
+
1316
+ export function getFlakiestTasks(db: Database, limit = 25): FlakyTask[] {
1317
+ // A within-run retry is a confirmed flake even with few runs, so surface
1318
+ // such a task regardless of run count; cross-run failure variance still
1319
+ // needs 3+ runs to be meaningful (else a single red build reads as flaky).
1320
+ const pairs = db
1321
+ .query(
1322
+ `SELECT project, task, COUNT(*) AS runs,
1323
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
1324
+ SUM(CASE WHEN attempts > 1 THEN 1 ELSE 0 END) AS within_run_retries,
1325
+ MAX(attempts) AS max_attempts
1326
+ FROM runs GROUP BY project, task
1327
+ HAVING runs >= 3 OR within_run_retries > 0`,
1328
+ )
1329
+ .all() as {
1330
+ project: string
1331
+ task: string
1332
+ runs: number
1333
+ failures: number
1334
+ within_run_retries: number
1335
+ max_attempts: number | null
1336
+ }[]
1337
+ return pairs
1338
+ .map((p) => {
1339
+ const durs = db
1340
+ .query(
1341
+ `SELECT duration_ms FROM runs
1342
+ WHERE project = ? AND task = ?
1343
+ AND (cache_hit IS NULL OR cache_hit = 0) AND status = 'success'
1344
+ ORDER BY started_at DESC LIMIT 50`,
1345
+ )
1346
+ .all(p.project, p.task) as { duration_ms: number }[]
1347
+ const sorted = durs.map((r) => r.duration_ms).sort((a, b) => a - b)
1348
+ const p50 = pickPercentile(sorted, 0.5)
1349
+ const p99 = pickPercentile(sorted, 0.99)
1350
+ const ratio = p50 && p50 > 0 && p99 !== undefined ? p99 / p50 : undefined
1351
+ return {
1352
+ id: `${p.project}#${p.task}`,
1353
+ project: p.project,
1354
+ task: p.task,
1355
+ runs: p.runs,
1356
+ failures: p.failures,
1357
+ failureRate: p.runs > 0 ? p.failures / p.runs : 0,
1358
+ withinRunRetries: p.within_run_retries,
1359
+ maxAttempts: p.max_attempts ?? undefined,
1360
+ flakyConfirmed: p.within_run_retries > 0,
1361
+ durationTailRatio: ratio,
1362
+ p50DurationMs: p50,
1363
+ p99DurationMs: p99,
1364
+ } satisfies FlakyTask
1365
+ })
1366
+ .filter(
1367
+ (r) =>
1368
+ r.flakyConfirmed ||
1369
+ r.failureRate > 0 ||
1370
+ (r.durationTailRatio !== undefined && r.durationTailRatio > 2),
1371
+ )
1372
+ .sort((a, b) => {
1373
+ // Confirmed-flaky tasks (a real within-run retry) outrank every merely
1374
+ // inferred one; within each tier, failure rate dominates and the
1375
+ // duration tail breaks ties.
1376
+ const score = (r: FlakyTask) =>
1377
+ (r.flakyConfirmed ? 100 : 0) + r.failureRate * 10 + (r.durationTailRatio ?? 1)
1378
+ return score(b) - score(a)
1379
+ })
1380
+ .slice(0, clampInt(limit, 1, 200))
1381
+ }
1382
+
1383
+ // ---------------------------------------------------------------------------
1384
+ // Bottlenecks — "if you sped up X, you'd save Y per week"
1385
+ // ---------------------------------------------------------------------------
1386
+
1387
+ export interface BottleneckRow {
1388
+ id: string
1389
+ project: string
1390
+ task: string
1391
+ /** Runs in the last `lookbackDays`. */
1392
+ runsRecent: number
1393
+ /** Total non-hit success duration over the lookback. */
1394
+ totalDurationMs: number
1395
+ avgDurationMs: number
1396
+ /** Runs/day extrapolated from the lookback. */
1397
+ runsPerDay: number
1398
+ /** Time you'd save per week if you cut avg duration by 25%. */
1399
+ weeklySavingsAt25PctCutMs: number
1400
+ }
1401
+
1402
+ /** Highest-leverage targets ranked by extrapolated weekly burn. */
1403
+ export function getBottlenecks(db: Database, lookbackDays = 14, limit = 15): BottleneckRow[] {
1404
+ const since = Date.now() - lookbackDays * 24 * 60 * 60 * 1000
1405
+ const rows = db
1406
+ .query(
1407
+ `SELECT project, task,
1408
+ COUNT(*) AS runsRecent,
1409
+ SUM(duration_ms) AS totalDurationMs,
1410
+ CAST(AVG(duration_ms) AS INTEGER) AS avgDurationMs
1411
+ FROM runs
1412
+ WHERE started_at >= ?
1413
+ AND (cache_hit IS NULL OR cache_hit = 0) AND status = 'success'
1414
+ GROUP BY project, task
1415
+ ORDER BY SUM(duration_ms) DESC
1416
+ LIMIT ?`,
1417
+ )
1418
+ .all(since, clampInt(limit, 1, 100)) as Array<{
1419
+ project: string
1420
+ task: string
1421
+ runsRecent: number
1422
+ totalDurationMs: number
1423
+ avgDurationMs: number
1424
+ }>
1425
+ return rows.map((r) => {
1426
+ const runsPerDay = r.runsRecent / Math.max(1, lookbackDays)
1427
+ const weeklySavings = Math.round(runsPerDay * 7 * r.avgDurationMs * 0.25)
1428
+ return {
1429
+ id: `${r.project}#${r.task}`,
1430
+ project: r.project,
1431
+ task: r.task,
1432
+ runsRecent: r.runsRecent,
1433
+ totalDurationMs: r.totalDurationMs,
1434
+ avgDurationMs: r.avgDurationMs,
1435
+ runsPerDay,
1436
+ weeklySavingsAt25PctCutMs: weeklySavings,
1437
+ } satisfies BottleneckRow
1438
+ })
1439
+ }
1440
+
1441
+ // ---------------------------------------------------------------------------
1442
+ // Parallelism factor — how many workers you actually utilized
1443
+ // ---------------------------------------------------------------------------
1444
+
1445
+ export interface ParallelismPoint {
1446
+ runId: string
1447
+ startedAt: number
1448
+ /** Total task CPU time. */
1449
+ cpuSumMs: number
1450
+ /** Wallclock from first task start to last task end. */
1451
+ wallMs: number
1452
+ /** cpuSumMs / wallMs — effective parallelism (1 = serial). */
1453
+ factor: number
1454
+ taskCount: number
1455
+ }
1456
+
1457
+ /** Per-invocation parallelism, recent first. */
1458
+ export function getParallelismHistory(db: Database, limit = 50): ParallelismPoint[] {
1459
+ // Filter out trivially-short invocations (wall &lt; 50 ms): the cpu/wall
1460
+ // ratio is dominated by measurement noise there and produces 0.5×/2×
1461
+ // junk that pollutes the chart's average.
1462
+ const rows = db
1463
+ .query(
1464
+ `SELECT run_id AS runId,
1465
+ MIN(started_at) AS startedAt,
1466
+ MIN(started_at) AS minStart,
1467
+ MAX(ended_at) AS maxEnd,
1468
+ SUM(COALESCE(cpu_ms, duration_ms)) AS cpuSumMs,
1469
+ COUNT(*) AS taskCount
1470
+ FROM runs
1471
+ WHERE run_id IS NOT NULL
1472
+ GROUP BY run_id
1473
+ HAVING taskCount > 1 AND (MAX(ended_at) - MIN(started_at)) >= 50
1474
+ ORDER BY MAX(started_at) DESC
1475
+ LIMIT ?`,
1476
+ )
1477
+ .all(clampInt(limit, 1, 500)) as Array<{
1478
+ runId: string
1479
+ startedAt: number
1480
+ minStart: number
1481
+ maxEnd: number
1482
+ cpuSumMs: number | null
1483
+ taskCount: number
1484
+ }>
1485
+ return rows.map((r) => {
1486
+ const wallMs = Math.max(1, r.maxEnd - r.minStart)
1487
+ const cpuSumMs = r.cpuSumMs ?? 0
1488
+ return {
1489
+ runId: r.runId,
1490
+ startedAt: r.startedAt,
1491
+ cpuSumMs,
1492
+ wallMs,
1493
+ factor: cpuSumMs / wallMs,
1494
+ taskCount: r.taskCount,
1495
+ } satisfies ParallelismPoint
1496
+ })
1497
+ }
1498
+
1499
+ // ---------------------------------------------------------------------------
1500
+ // Cache storage growth — bytes over time, with prunable hint
1501
+ // ---------------------------------------------------------------------------
1502
+
1503
+ export interface StoragePoint {
1504
+ /** Epoch ms at bucket start. */
1505
+ t: number
1506
+ /** Bytes added in the bucket. */
1507
+ bytesAdded: number
1508
+ entriesAdded: number
1509
+ }
1510
+
1511
+ /**
1512
+ * Daily storage growth from the entries table. NOTE: this reflects the rows
1513
+ * still in entries (prune evicts; we can't reconstruct pruned bytes). For most
1514
+ * workspaces it's the right "what's the cache doing" view.
1515
+ */
1516
+ export function getStorageGrowth(db: Database, days = 30): StoragePoint[] {
1517
+ const since = Date.now() - days * 24 * 60 * 60 * 1000
1518
+ const bucketMs = 24 * 60 * 60 * 1000
1519
+ const rows = db
1520
+ .query(
1521
+ `SELECT (created_at / ?) * ? AS t,
1522
+ COALESCE(SUM(size_bytes), 0) AS bytesAdded,
1523
+ COUNT(*) AS entriesAdded
1524
+ FROM entries WHERE created_at >= ?
1525
+ GROUP BY t ORDER BY t ASC`,
1526
+ )
1527
+ .all(bucketMs, bucketMs, since) as StoragePoint[]
1528
+ const start = Math.floor(since / bucketMs) * bucketMs
1529
+ const end = Math.floor(Date.now() / bucketMs) * bucketMs
1530
+ const byT = new Map(rows.map((r) => [r.t, r]))
1531
+ const out: StoragePoint[] = []
1532
+ for (let t = start; t <= end; t += bucketMs) {
1533
+ out.push(byT.get(t) ?? { t, bytesAdded: 0, entriesAdded: 0 })
1534
+ }
1535
+ return out
1536
+ }
1537
+
1538
+ // ---------------------------------------------------------------------------
1539
+ // Stale / prunable entries — what to evict first
1540
+ // ---------------------------------------------------------------------------
1541
+
1542
+ export interface PrunableEntry {
1543
+ hash: string
1544
+ project: string
1545
+ task: string
1546
+ sizeBytes: number
1547
+ createdAt: number
1548
+ accessedAt: number
1549
+ /** Days since last access. */
1550
+ ageDays: number
1551
+ }
1552
+
1553
+ /** Entries unused for ≥ `minAgeDays`, ordered by size — best prune targets. */
1554
+ export function getPrunableEntries(db: Database, minAgeDays = 7, limit = 50): PrunableEntry[] {
1555
+ const since = Date.now() - minAgeDays * 24 * 60 * 60 * 1000
1556
+ const rows = db
1557
+ .query(
1558
+ `SELECT hash, project, task, size_bytes AS sizeBytes,
1559
+ created_at AS createdAt, accessed_at AS accessedAt
1560
+ FROM entries WHERE accessed_at <= ?
1561
+ ORDER BY size_bytes DESC LIMIT ?`,
1562
+ )
1563
+ .all(since, clampInt(limit, 1, 500)) as Array<{
1564
+ hash: string
1565
+ project: string
1566
+ task: string
1567
+ sizeBytes: number
1568
+ createdAt: number
1569
+ accessedAt: number
1570
+ }>
1571
+ const now = Date.now()
1572
+ return rows.map((r) => ({
1573
+ ...r,
1574
+ ageDays: Math.max(0, Math.floor((now - r.accessedAt) / (24 * 60 * 60 * 1000))),
1575
+ }))
1576
+ }