@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -1,18 +1,20 @@
1
- // Metrics query module — pure functions over a `bun:sqlite` Database.
1
+ // Run-history queries — pure functions over a `bun:sqlite` Database.
2
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.
3
+ // `vx why` and `vx last` read through them, and so can any out-of-process
4
+ // surface (an MCP server plugin, a dashboard). One canonical home for every
5
+ // aggregate over the runs / entries tables. The schema itself is owned by
6
+ // src/cache/cache.ts.
10
7
  //
11
8
  // Pure SQL + JSON-safe return shapes. No Cache lifecycle here; the
12
9
  // caller opens and closes. bigints are serialized as decimal strings
13
10
  // for JSON compatibility (matches the WireEvent timeUnixNano rule).
14
11
 
15
12
  import type { Database } from 'bun:sqlite'
13
+ // The keyed-run predicate lives beside the schema in cache/cache.ts, so a
14
+ // rule written once cannot drift.
15
+ import { KEYED_RUNS_SQL } from '../cache/index.js'
16
+ import { splitTaskId } from '../graph/index.js'
17
+ import { clampInt } from '../util/index.js'
16
18
 
17
19
  // ---------------------------------------------------------------------------
18
20
  // Run listing + detail
@@ -28,6 +30,8 @@ export interface RunSummaryRow {
28
30
  startedAt: number
29
31
  endedAt: number
30
32
  cacheHit: boolean | null
33
+ /** Whether the task declared a `cache` block; null on rows older than the column. */
34
+ cached: boolean | null
31
35
  hash: string
32
36
  cpuMs: number | null
33
37
  peakRssBytes: number | null
@@ -63,8 +67,12 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
63
67
  params.push(args.runId)
64
68
  }
65
69
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
66
- type RawRow = Omit<RunSummaryRow, 'cacheHit' | 'wallclockStartNs' | 'wallclockEndNs'> & {
70
+ type RawRow = Omit<
71
+ RunSummaryRow,
72
+ 'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs'
73
+ > & {
67
74
  cacheHit: number | null
75
+ cached: number | null
68
76
  wallclockStartNs: bigint | null
69
77
  wallclockEndNs: bigint | null
70
78
  }
@@ -72,7 +80,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
72
80
  .query(
73
81
  `SELECT run_id AS runId, project, task, status, exit_code AS exitCode,
74
82
  duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
75
- cache_hit AS cacheHit, hash,
83
+ cache_hit AS cacheHit, cached, hash,
76
84
  cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
77
85
  wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs
78
86
  FROM runs ${clause} ORDER BY started_at DESC LIMIT ?`,
@@ -81,6 +89,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
81
89
  return rows.map((r) => ({
82
90
  ...r,
83
91
  cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit),
92
+ cached: r.cached === null ? null : Boolean(r.cached),
84
93
  wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
85
94
  wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(),
86
95
  }))
@@ -287,244 +296,6 @@ export function getRun(db: Database, runId: string): RunDetail | null {
287
296
  // Cache stats
288
297
  // ---------------------------------------------------------------------------
289
298
 
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
299
  export interface CacheEntryRow {
529
300
  hash: string
530
301
  project: string
@@ -537,154 +308,10 @@ export interface CacheEntryRow {
537
308
  accessedAt: number
538
309
  }
539
310
 
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
311
  // ---------------------------------------------------------------------------
595
312
  // Task detail — full history for one (project, task)
596
313
  // ---------------------------------------------------------------------------
597
314
 
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
315
  // ---------------------------------------------------------------------------
689
316
  // Cache key explain — latest entries row
690
317
  // ---------------------------------------------------------------------------
@@ -705,7 +332,7 @@ export interface CacheKeyExplanation {
705
332
  }
706
333
 
707
334
  export function explainCacheKey(db: Database, taskId: string): CacheKeyExplanation {
708
- const [project, task] = taskId.split('#', 2) as [string, string]
335
+ const [project, task] = splitTaskId(taskId)
709
336
  const entry = db
710
337
  .query(
711
338
  `SELECT hash, command, exit_code AS exitCode, duration_ms AS durationMs,
@@ -730,21 +357,44 @@ export interface WhyDidThisRerun {
730
357
  runId: string
731
358
  taskId: string
732
359
  found: boolean
733
- thisRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number }
360
+ thisRun?: {
361
+ hash: string
362
+ status: string
363
+ cacheHit: boolean | null
364
+ /** Whether the task declared a `cache` block; null on rows older than the column. */
365
+ cached: boolean | null
366
+ startedAt: number
367
+ }
734
368
  previousRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number } | null
735
369
  hashChanged?: boolean | null
736
370
  note: string
737
371
  }
738
372
 
373
+ /** The three endings an UNCHANGED cache key can have. */
374
+ function unchangedKeyNote(cacheHit: number | null): string {
375
+ if (cacheHit === null) {
376
+ return 'cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknown'
377
+ }
378
+ return cacheHit
379
+ ? 'cache key unchanged — this run was served from cache, nothing re-ran'
380
+ : 'cache key unchanged — re-executed on the same key (--no-cache / --force, or unrelated)'
381
+ }
382
+
739
383
  export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
740
- const [project, task] = taskId.split('#', 2) as [string, string]
384
+ const [project, task] = splitTaskId(taskId)
741
385
  const this_ = db
742
386
  .query(
743
- `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
387
+ `SELECT hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
744
388
  FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
745
389
  )
746
390
  .get(runId, project, task) as
747
- | { hash: string; status: string; cacheHit: number | null; startedAt: number }
391
+ | {
392
+ hash: string
393
+ status: string
394
+ cacheHit: number | null
395
+ cached: number | null
396
+ startedAt: number
397
+ }
748
398
  | undefined
749
399
  if (!this_) {
750
400
  return {
@@ -754,30 +404,51 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
754
404
  note: 'no row matching that runId + taskId',
755
405
  }
756
406
  }
407
+ // The previous run to compare against is the previous run that RECORDED A
408
+ // KEY. A skipped or persistent row carries `hash = ''`, and comparing
409
+ // against it would answer "cache key unchanged" for two runs that never had
410
+ // a key — a statement about inputs, made from no evidence.
757
411
  const prev = db
758
412
  .query(
759
413
  `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
760
- FROM runs WHERE project = ? AND task = ? AND started_at < ?
414
+ FROM runs WHERE project = ? AND task = ? AND started_at < ? AND ${KEYED_RUNS_SQL}
761
415
  ORDER BY started_at DESC LIMIT 1`,
762
416
  )
763
417
  .get(project, task, this_.startedAt) as
764
418
  | { hash: string; status: string; cacheHit: number | null; startedAt: number }
765
419
  | undefined
420
+ // …and this run must have one too, or there is nothing to compare.
421
+ const noKey = this_.hash === ''
766
422
  return {
767
423
  runId,
768
424
  taskId,
769
425
  found: true,
770
- thisRun: { ...this_, cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit) },
426
+ thisRun: {
427
+ ...this_,
428
+ cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit),
429
+ cached: this_.cached === null ? null : Boolean(this_.cached),
430
+ },
771
431
  previousRun: prev
772
432
  ? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) }
773
433
  : 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)',
434
+ hashChanged: prev && !noKey ? prev.hash !== this_.hash : null,
435
+ note: noKey
436
+ ? 'this task recorded no cache key (skipped, or a persistent task) — nothing to compare'
437
+ : this_.cached === 0
438
+ ? // Not a cache decision at all: the task runs every time by design,
439
+ // and its key is derived only so dependents can fold it.
440
+ 'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
441
+ : prev && prev.hash !== this_.hash
442
+ ? 'cache key changed between the previous run and this one (inputs differ)'
443
+ : prev
444
+ ? // An unchanged key has two very different endings, and calling
445
+ // both a "re-run" answered the question wrong: a cache HIT did not
446
+ // re-run at all, so blaming `--no-cache` named a cause that cannot
447
+ // have applied. Only a run that EXECUTED on an unchanged key is
448
+ // the case this verb exists to explain. A row with no recorded
449
+ // cacheHit (older rows) is neither — say that, do not guess.
450
+ unchangedKeyNote(this_.cacheHit)
451
+ : 'no prior run for this (project, task)',
781
452
  }
782
453
  }
783
454
 
@@ -837,12 +508,14 @@ function loadEntryInputs(db: Database, entryHash: string): Map<string, EntryInpu
837
508
  * has no row, `entries:[]` for the first run of a task.
838
509
  */
839
510
  export function cacheKeyDiff(db: Database, runId: string, taskId: string): CacheKeyDiff {
840
- const [project, task] = taskId.split('#', 2) as [string, string]
511
+ const [project, task] = splitTaskId(taskId)
841
512
  const this_ = db
842
513
  .query(
843
- 'SELECT hash, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
514
+ 'SELECT hash, cached, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
844
515
  )
845
- .get(runId, project, task) as { hash: string; startedAt: number } | undefined
516
+ .get(runId, project, task) as
517
+ | { hash: string; cached: number | null; startedAt: number }
518
+ | undefined
846
519
  if (!this_) {
847
520
  return {
848
521
  runId,
@@ -855,10 +528,25 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
855
528
  }
856
529
  }
857
530
 
531
+ // No key on this side → no diff to compute. `''` is the recorded-no-key
532
+ // sentinel (skipped / persistent); treating it as a key would resolve the
533
+ // `prev.hash === this_.hash` branch below and claim "same inputs".
534
+ if (this_.hash === '') {
535
+ return {
536
+ runId,
537
+ taskId,
538
+ found: true,
539
+ previousRunId: null,
540
+ entries: [],
541
+ unchangedCount: 0,
542
+ note: 'this task recorded no cache key (skipped, or a persistent task) — nothing to diff',
543
+ }
544
+ }
545
+
858
546
  const prev = db
859
547
  .query(
860
548
  `SELECT run_id AS runId, hash FROM runs
861
- WHERE project = ? AND task = ? AND started_at < ?
549
+ WHERE project = ? AND task = ? AND started_at < ? AND ${KEYED_RUNS_SQL}
862
550
  ORDER BY started_at DESC LIMIT 1`,
863
551
  )
864
552
  .get(project, task, this_.startedAt) as { runId: string | null; hash: string } | undefined
@@ -901,7 +589,10 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
901
589
  previousRunId: prev.runId,
902
590
  entries: [],
903
591
  unchangedCount: 0,
904
- note: 'cache key changed but input fingerprints are unavailable (entry pruned); only the hash change is known',
592
+ note:
593
+ this_.cached === 0
594
+ ? 'this task declares no `cache` block — it runs every time; its key moved, but no input fingerprints exist for an uncached task'
595
+ : 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
905
596
  }
906
597
  }
907
598
 
@@ -948,980 +639,3 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
948
639
  // ---------------------------------------------------------------------------
949
640
 
950
641
  /** 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
- // Regressions — "which tasks just started failing across branches?"
1385
- // ---------------------------------------------------------------------------
1386
-
1387
- /**
1388
- * A task that is currently failing on one or more branches and USED to pass —
1389
- * a regression, distinct from a flaky task (nondeterministic) or a task that
1390
- * has always been broken. "Across branches" is the key signal: a task failing
1391
- * on several branches at once points at a real break in that task or in shared
1392
- * code, not one developer's work-in-progress branch.
1393
- */
1394
- export interface RegressedTask {
1395
- id: string
1396
- project: string
1397
- task: string
1398
- /** Distinct branches whose MOST-RECENT run in the window failed. */
1399
- branchesFailing: number
1400
- /** Distinct branches the task ran on in the window. */
1401
- branchesTotal: number
1402
- /** The currently-failing branch names (capped). */
1403
- branches: string[]
1404
- /**
1405
- * True if the task has any prior successful run — it regressed, rather than
1406
- * being perpetually broken. A regression is the more urgent signal.
1407
- */
1408
- regressed: boolean
1409
- /** Earliest failed run in the window (ms epoch) — ≈ when it started failing. */
1410
- firstFailedAt: number
1411
- /** Most-recent run in the window (ms epoch). */
1412
- lastRunAt: number
1413
- /** Failed runs in the window. */
1414
- failures: number
1415
- /** Total runs in the window. */
1416
- runs: number
1417
- }
1418
-
1419
- export interface RegressionArgs {
1420
- /** Look-back window in days. Default 7. */
1421
- sinceDays?: number
1422
- /** Minimum distinct currently-failing branches to surface. Default 2
1423
- * ("across branches"); pass 1 to include single-branch regressions. */
1424
- minBranches?: number
1425
- limit?: number
1426
- }
1427
-
1428
- const PASS_STATUSES = "('success', 'cache-hit', 'cache-hit-remote')"
1429
- const BRANCH_CAP = 12
1430
-
1431
- export function getRegressions(db: Database, args: RegressionArgs = {}): RegressedTask[] {
1432
- const sinceDays = args.sinceDays ?? 7
1433
- const minBranches = Math.max(1, args.minBranches ?? 2)
1434
- const limit = clampInt(args.limit ?? 25, 1, 200)
1435
- const since = Date.now() - sinceDays * 86_400_000
1436
-
1437
- // The most-recent non-skipped run per (task, branch) in the window: its
1438
- // status is that task's CURRENT state on that branch. Skipped/aborted runs
1439
- // never finished on their own terms, so they're excluded from the state.
1440
- const latest = db
1441
- .query(
1442
- `WITH windowed AS (
1443
- SELECT r.project AS project, r.task AS task, inv.branch AS branch,
1444
- r.status AS status,
1445
- ROW_NUMBER() OVER (
1446
- PARTITION BY r.project, r.task, inv.branch
1447
- ORDER BY r.started_at DESC, r.run_id DESC
1448
- ) AS rn
1449
- FROM runs r JOIN invocations inv ON r.run_id = inv.run_id
1450
- WHERE inv.branch IS NOT NULL
1451
- AND r.started_at >= ?
1452
- AND r.status IN ('success', 'failed', 'cache-hit', 'cache-hit-remote')
1453
- )
1454
- SELECT project, task, branch, status FROM windowed WHERE rn = 1`,
1455
- )
1456
- .all(since) as { project: string; task: string; branch: string; status: string }[]
1457
-
1458
- // Aggregate the latest-per-branch rows into per-task failing/total branch
1459
- // sets. `failing` = the task's most recent run on that branch failed.
1460
- const byTask = new Map<
1461
- string,
1462
- { project: string; task: string; failing: string[]; total: Set<string> }
1463
- >()
1464
- for (const r of latest) {
1465
- const id = `${r.project}#${r.task}`
1466
- let agg = byTask.get(id)
1467
- if (agg === undefined) {
1468
- agg = { project: r.project, task: r.task, failing: [], total: new Set() }
1469
- byTask.set(id, agg)
1470
- }
1471
- agg.total.add(r.branch)
1472
- if (r.status === 'failed') agg.failing.push(r.branch)
1473
- }
1474
-
1475
- const out: RegressedTask[] = []
1476
- for (const [id, agg] of byTask) {
1477
- if (agg.failing.length < minBranches) continue
1478
- // Per-task follow-ups (the getFlakiestTasks pattern) — the regressed set
1479
- // is small, so a couple of point queries each is cheap.
1480
- const win = db
1481
- .query(
1482
- `SELECT COUNT(*) AS runs,
1483
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
1484
- MIN(CASE WHEN status = 'failed' THEN started_at END) AS first_failed,
1485
- MAX(started_at) AS last_run
1486
- FROM runs WHERE project = ? AND task = ? AND started_at >= ?`,
1487
- )
1488
- .get(agg.project, agg.task, since) as {
1489
- runs: number
1490
- failures: number | null
1491
- first_failed: number | null
1492
- last_run: number | null
1493
- }
1494
- const everPassed =
1495
- db
1496
- .query(
1497
- `SELECT 1 FROM runs WHERE project = ? AND task = ? AND status IN ${PASS_STATUSES} LIMIT 1`,
1498
- )
1499
- .get(agg.project, agg.task) !== null
1500
- out.push({
1501
- id,
1502
- project: agg.project,
1503
- task: agg.task,
1504
- branchesFailing: agg.failing.length,
1505
- branchesTotal: agg.total.size,
1506
- branches: agg.failing.sort().slice(0, BRANCH_CAP),
1507
- regressed: everPassed,
1508
- firstFailedAt: win.first_failed ?? 0,
1509
- lastRunAt: win.last_run ?? 0,
1510
- failures: win.failures ?? 0,
1511
- runs: win.runs,
1512
- })
1513
- }
1514
- // Regressions (used-to-pass) first, then most branches affected, then the
1515
- // most recently-started failures — the "act on this now" ordering.
1516
- return out
1517
- .sort(
1518
- (a, b) =>
1519
- Number(b.regressed) - Number(a.regressed) ||
1520
- b.branchesFailing - a.branchesFailing ||
1521
- b.firstFailedAt - a.firstFailedAt,
1522
- )
1523
- .slice(0, limit)
1524
- }
1525
-
1526
- // ---------------------------------------------------------------------------
1527
- // Period-over-period analysis — "how is CI trending vs the previous window,
1528
- // and which tasks moved the most?" Two adjacent equal-length windows.
1529
- // ---------------------------------------------------------------------------
1530
-
1531
- export interface PeriodStats {
1532
- /** Distinct runs (invocations) in the window. */
1533
- runs: number
1534
- /** Task executions recorded (rows). */
1535
- taskRuns: number
1536
- /** Executions that actually ran (not a cache hit). */
1537
- executed: number
1538
- /** Failed task executions. */
1539
- failures: number
1540
- /** Cache-hit task executions (local or remote). */
1541
- cacheHits: number
1542
- /** Sum of executed-task durations (ms). */
1543
- totalDurationMs: number
1544
- /** Mean duration of successful executed tasks (ms). */
1545
- avgDurationMs: number
1546
- p50DurationMs: number | undefined
1547
- p95DurationMs: number | undefined
1548
- /** failures / taskRuns. */
1549
- failureRate: number
1550
- /** cacheHits / taskRuns. */
1551
- cacheHitRate: number
1552
- }
1553
-
1554
- export interface TaskMover {
1555
- id: string
1556
- project: string
1557
- task: string
1558
- currentAvgMs: number
1559
- previousAvgMs: number
1560
- /** current − previous (positive = slower / regressed). */
1561
- deltaMs: number
1562
- /** (current − previous) / previous, as a fraction. */
1563
- deltaPct: number
1564
- currentRuns: number
1565
- previousRuns: number
1566
- }
1567
-
1568
- export interface PeriodComparison {
1569
- windowDays: number
1570
- current: { from: number; to: number; stats: PeriodStats }
1571
- previous: { from: number; to: number; stats: PeriodStats }
1572
- /**
1573
- * Tasks whose average executed duration moved the most between the two
1574
- * windows, by absolute impact (biggest ms shift first; positive = slower).
1575
- * Only tasks with >= minRuns successful executions in BOTH windows qualify,
1576
- * so a mover reflects a real trend, not one-off noise.
1577
- */
1578
- movers: TaskMover[]
1579
- }
1580
-
1581
- export interface PeriodComparisonArgs {
1582
- /** Length of each window in days. Default 7 (this week vs last week). */
1583
- windowDays?: number
1584
- /** End of the CURRENT window (ms). Default now — override for tests. */
1585
- endMs?: number
1586
- /** Min successful executions in EACH window for a mover to qualify. Default 3. */
1587
- minRuns?: number
1588
- /** Max movers returned. Default 8. */
1589
- limit?: number
1590
- /** Scope to one project — the project-detail "did MY project trend?" view. */
1591
- project?: string
1592
- /** Scope to one task within `project` — the task-detail trend. */
1593
- task?: string
1594
- }
1595
-
1596
- /** Optional per-project/task scoping shared by the two window queries. */
1597
- interface PeriodScope {
1598
- project?: string
1599
- task?: string
1600
- }
1601
-
1602
- function scopeSql(scope: PeriodScope): { sql: string; params: string[] } {
1603
- let sql = ''
1604
- const params: string[] = []
1605
- if (scope.project !== undefined) {
1606
- sql += ' AND project = ?'
1607
- params.push(scope.project)
1608
- }
1609
- if (scope.task !== undefined) {
1610
- sql += ' AND task = ?'
1611
- params.push(scope.task)
1612
- }
1613
- return { sql, params }
1614
- }
1615
-
1616
- function periodStats(db: Database, from: number, to: number, scope: PeriodScope): PeriodStats {
1617
- const { sql, params } = scopeSql(scope)
1618
- const agg = db
1619
- .query(
1620
- // COALESCE every SUM: over an empty window SQLite SUM() returns NULL, and
1621
- // the previous window is empty for any workspace younger than the window
1622
- // (a fresh serve, a quiet prior week) — a bare SUM would ship `null` where
1623
- // PeriodStats declares `number`, throwing on the client's `.toFixed()`.
1624
- `SELECT COUNT(*) AS taskRuns,
1625
- COUNT(DISTINCT run_id) AS runs,
1626
- COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failures,
1627
- COALESCE(SUM(CASE WHEN cache_hit = 1 THEN 1 ELSE 0 END), 0) AS cacheHits,
1628
- COALESCE(SUM(CASE WHEN cache_hit IS NULL OR cache_hit = 0 THEN 1 ELSE 0 END), 0) AS executed,
1629
- COALESCE(SUM(CASE WHEN cache_hit IS NULL OR cache_hit = 0 THEN duration_ms ELSE 0 END), 0) AS totalDurationMs
1630
- FROM runs WHERE started_at >= ? AND started_at < ?${sql}`,
1631
- )
1632
- .get(from, to, ...params) as {
1633
- taskRuns: number
1634
- runs: number
1635
- failures: number
1636
- cacheHits: number
1637
- executed: number
1638
- totalDurationMs: number
1639
- }
1640
- const durs = (
1641
- db
1642
- .query(
1643
- `SELECT duration_ms AS d FROM runs
1644
- WHERE started_at >= ? AND started_at < ?
1645
- AND (cache_hit IS NULL OR cache_hit = 0) AND status = 'success'${sql}
1646
- ORDER BY duration_ms`,
1647
- )
1648
- .all(from, to, ...params) as { d: number }[]
1649
- ).map((r) => r.d)
1650
- const taskRuns = agg.taskRuns
1651
- return {
1652
- runs: agg.runs,
1653
- taskRuns,
1654
- executed: agg.executed,
1655
- failures: agg.failures,
1656
- cacheHits: agg.cacheHits,
1657
- totalDurationMs: agg.totalDurationMs,
1658
- avgDurationMs: durs.length > 0 ? Math.round(durs.reduce((a, b) => a + b, 0) / durs.length) : 0,
1659
- p50DurationMs: pickPercentile(durs, 0.5),
1660
- p95DurationMs: pickPercentile(durs, 0.95),
1661
- failureRate: taskRuns > 0 ? agg.failures / taskRuns : 0,
1662
- cacheHitRate: taskRuns > 0 ? agg.cacheHits / taskRuns : 0,
1663
- }
1664
- }
1665
-
1666
- function avgByTask(
1667
- db: Database,
1668
- from: number,
1669
- to: number,
1670
- scope: PeriodScope,
1671
- ): Map<string, { avg: number; runs: number; project: string; task: string }> {
1672
- const { sql, params } = scopeSql(scope)
1673
- const rows = db
1674
- .query(
1675
- `SELECT project, task, AVG(duration_ms) AS avg, COUNT(*) AS runs
1676
- FROM runs
1677
- WHERE started_at >= ? AND started_at < ?
1678
- AND (cache_hit IS NULL OR cache_hit = 0) AND status = 'success'${sql}
1679
- GROUP BY project, task`,
1680
- )
1681
- .all(from, to, ...params) as { project: string; task: string; avg: number; runs: number }[]
1682
- return new Map(
1683
- rows.map((r) => [
1684
- `${r.project}#${r.task}`,
1685
- { avg: r.avg, runs: r.runs, project: r.project, task: r.task },
1686
- ]),
1687
- )
1688
- }
1689
-
1690
- export function getPeriodComparison(
1691
- db: Database,
1692
- args: PeriodComparisonArgs = {},
1693
- ): PeriodComparison {
1694
- const windowDays = Math.max(1, args.windowDays ?? 7)
1695
- const minRuns = Math.max(1, args.minRuns ?? 3)
1696
- const limit = clampInt(args.limit ?? 8, 1, 100)
1697
- const scope: PeriodScope = {
1698
- ...(args.project !== undefined ? { project: args.project } : {}),
1699
- ...(args.task !== undefined ? { task: args.task } : {}),
1700
- }
1701
- const to = args.endMs ?? Date.now()
1702
- const win = windowDays * 86_400_000
1703
- const curFrom = to - win
1704
- const prevTo = curFrom
1705
- const prevFrom = curFrom - win
1706
-
1707
- const cur = avgByTask(db, curFrom, to, scope)
1708
- const prev = avgByTask(db, prevFrom, prevTo, scope)
1709
- const movers: TaskMover[] = []
1710
- for (const [id, c] of cur) {
1711
- const p = prev.get(id)
1712
- if (p === undefined || c.runs < minRuns || p.runs < minRuns) continue
1713
- movers.push({
1714
- id,
1715
- project: c.project,
1716
- task: c.task,
1717
- currentAvgMs: Math.round(c.avg),
1718
- previousAvgMs: Math.round(p.avg),
1719
- deltaMs: Math.round(c.avg - p.avg),
1720
- deltaPct: p.avg > 0 ? (c.avg - p.avg) / p.avg : 0,
1721
- currentRuns: c.runs,
1722
- previousRuns: p.runs,
1723
- })
1724
- }
1725
- movers.sort((a, b) => Math.abs(b.deltaMs) - Math.abs(a.deltaMs))
1726
- return {
1727
- windowDays,
1728
- current: { from: curFrom, to, stats: periodStats(db, curFrom, to, scope) },
1729
- previous: { from: prevFrom, to: prevTo, stats: periodStats(db, prevFrom, prevTo, scope) },
1730
- movers: movers.slice(0, limit),
1731
- }
1732
- }
1733
-
1734
- // ---------------------------------------------------------------------------
1735
- // Bottlenecks — "if you sped up X, you'd save Y per week"
1736
- // ---------------------------------------------------------------------------
1737
-
1738
- export interface BottleneckRow {
1739
- id: string
1740
- project: string
1741
- task: string
1742
- /** Runs in the last `lookbackDays`. */
1743
- runsRecent: number
1744
- /** Total non-hit success duration over the lookback. */
1745
- totalDurationMs: number
1746
- avgDurationMs: number
1747
- /** Runs/day extrapolated from the lookback. */
1748
- runsPerDay: number
1749
- /** Time you'd save per week if you cut avg duration by 25%. */
1750
- weeklySavingsAt25PctCutMs: number
1751
- }
1752
-
1753
- /** Highest-leverage targets ranked by extrapolated weekly burn. */
1754
- export function getBottlenecks(db: Database, lookbackDays = 14, limit = 15): BottleneckRow[] {
1755
- const since = Date.now() - lookbackDays * 24 * 60 * 60 * 1000
1756
- const rows = db
1757
- .query(
1758
- `SELECT project, task,
1759
- COUNT(*) AS runsRecent,
1760
- SUM(duration_ms) AS totalDurationMs,
1761
- CAST(AVG(duration_ms) AS INTEGER) AS avgDurationMs
1762
- FROM runs
1763
- WHERE started_at >= ?
1764
- AND (cache_hit IS NULL OR cache_hit = 0) AND status = 'success'
1765
- GROUP BY project, task
1766
- ORDER BY SUM(duration_ms) DESC
1767
- LIMIT ?`,
1768
- )
1769
- .all(since, clampInt(limit, 1, 100)) as Array<{
1770
- project: string
1771
- task: string
1772
- runsRecent: number
1773
- totalDurationMs: number
1774
- avgDurationMs: number
1775
- }>
1776
- return rows.map((r) => {
1777
- const runsPerDay = r.runsRecent / Math.max(1, lookbackDays)
1778
- const weeklySavings = Math.round(runsPerDay * 7 * r.avgDurationMs * 0.25)
1779
- return {
1780
- id: `${r.project}#${r.task}`,
1781
- project: r.project,
1782
- task: r.task,
1783
- runsRecent: r.runsRecent,
1784
- totalDurationMs: r.totalDurationMs,
1785
- avgDurationMs: r.avgDurationMs,
1786
- runsPerDay,
1787
- weeklySavingsAt25PctCutMs: weeklySavings,
1788
- } satisfies BottleneckRow
1789
- })
1790
- }
1791
-
1792
- // ---------------------------------------------------------------------------
1793
- // Parallelism factor — how many workers you actually utilized
1794
- // ---------------------------------------------------------------------------
1795
-
1796
- export interface ParallelismPoint {
1797
- runId: string
1798
- startedAt: number
1799
- /** Total task CPU time. */
1800
- cpuSumMs: number
1801
- /** Wallclock from first task start to last task end. */
1802
- wallMs: number
1803
- /** cpuSumMs / wallMs — effective parallelism (1 = serial). */
1804
- factor: number
1805
- taskCount: number
1806
- }
1807
-
1808
- /** Per-invocation parallelism, recent first. */
1809
- export function getParallelismHistory(db: Database, limit = 50): ParallelismPoint[] {
1810
- // Filter out trivially-short invocations (wall &lt; 50 ms): the cpu/wall
1811
- // ratio is dominated by measurement noise there and produces 0.5×/2×
1812
- // junk that pollutes the chart's average.
1813
- const rows = db
1814
- .query(
1815
- `SELECT run_id AS runId,
1816
- MIN(started_at) AS startedAt,
1817
- MIN(started_at) AS minStart,
1818
- MAX(ended_at) AS maxEnd,
1819
- SUM(COALESCE(cpu_ms, duration_ms)) AS cpuSumMs,
1820
- COUNT(*) AS taskCount
1821
- FROM runs
1822
- WHERE run_id IS NOT NULL
1823
- GROUP BY run_id
1824
- HAVING taskCount > 1 AND (MAX(ended_at) - MIN(started_at)) >= 50
1825
- ORDER BY MAX(started_at) DESC
1826
- LIMIT ?`,
1827
- )
1828
- .all(clampInt(limit, 1, 500)) as Array<{
1829
- runId: string
1830
- startedAt: number
1831
- minStart: number
1832
- maxEnd: number
1833
- cpuSumMs: number | null
1834
- taskCount: number
1835
- }>
1836
- return rows.map((r) => {
1837
- const wallMs = Math.max(1, r.maxEnd - r.minStart)
1838
- const cpuSumMs = r.cpuSumMs ?? 0
1839
- return {
1840
- runId: r.runId,
1841
- startedAt: r.startedAt,
1842
- cpuSumMs,
1843
- wallMs,
1844
- factor: cpuSumMs / wallMs,
1845
- taskCount: r.taskCount,
1846
- } satisfies ParallelismPoint
1847
- })
1848
- }
1849
-
1850
- // ---------------------------------------------------------------------------
1851
- // Cache storage growth — bytes over time, with prunable hint
1852
- // ---------------------------------------------------------------------------
1853
-
1854
- export interface StoragePoint {
1855
- /** Epoch ms at bucket start. */
1856
- t: number
1857
- /** Bytes added in the bucket. */
1858
- bytesAdded: number
1859
- entriesAdded: number
1860
- }
1861
-
1862
- /**
1863
- * Daily storage growth from the entries table. NOTE: this reflects the rows
1864
- * still in entries (prune evicts; we can't reconstruct pruned bytes). For most
1865
- * workspaces it's the right "what's the cache doing" view.
1866
- */
1867
- export function getStorageGrowth(db: Database, days = 30): StoragePoint[] {
1868
- const since = Date.now() - days * 24 * 60 * 60 * 1000
1869
- const bucketMs = 24 * 60 * 60 * 1000
1870
- const rows = db
1871
- .query(
1872
- `SELECT (created_at / ?) * ? AS t,
1873
- COALESCE(SUM(size_bytes), 0) AS bytesAdded,
1874
- COUNT(*) AS entriesAdded
1875
- FROM entries WHERE created_at >= ?
1876
- GROUP BY t ORDER BY t ASC`,
1877
- )
1878
- .all(bucketMs, bucketMs, since) as StoragePoint[]
1879
- const start = Math.floor(since / bucketMs) * bucketMs
1880
- const end = Math.floor(Date.now() / bucketMs) * bucketMs
1881
- const byT = new Map(rows.map((r) => [r.t, r]))
1882
- const out: StoragePoint[] = []
1883
- for (let t = start; t <= end; t += bucketMs) {
1884
- out.push(byT.get(t) ?? { t, bytesAdded: 0, entriesAdded: 0 })
1885
- }
1886
- return out
1887
- }
1888
-
1889
- // ---------------------------------------------------------------------------
1890
- // Stale / prunable entries — what to evict first
1891
- // ---------------------------------------------------------------------------
1892
-
1893
- export interface PrunableEntry {
1894
- hash: string
1895
- project: string
1896
- task: string
1897
- sizeBytes: number
1898
- createdAt: number
1899
- accessedAt: number
1900
- /** Days since last access. */
1901
- ageDays: number
1902
- }
1903
-
1904
- /** Entries unused for ≥ `minAgeDays`, ordered by size — best prune targets. */
1905
- export function getPrunableEntries(db: Database, minAgeDays = 7, limit = 50): PrunableEntry[] {
1906
- const since = Date.now() - minAgeDays * 24 * 60 * 60 * 1000
1907
- const rows = db
1908
- .query(
1909
- `SELECT hash, project, task, size_bytes AS sizeBytes,
1910
- created_at AS createdAt, accessed_at AS accessedAt
1911
- FROM entries WHERE accessed_at <= ?
1912
- ORDER BY size_bytes DESC LIMIT ?`,
1913
- )
1914
- .all(since, clampInt(limit, 1, 500)) as Array<{
1915
- hash: string
1916
- project: string
1917
- task: string
1918
- sizeBytes: number
1919
- createdAt: number
1920
- accessedAt: number
1921
- }>
1922
- const now = Date.now()
1923
- return rows.map((r) => ({
1924
- ...r,
1925
- ageDays: Math.max(0, Math.floor((now - r.accessedAt) / (24 * 60 * 60 * 1000))),
1926
- }))
1927
- }