@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.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Run-history queries — pure functions over a `bun:sqlite` Database.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// aggregate over the runs / entries
|
|
6
|
-
//
|
|
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<
|
|
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
|
|
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?: {
|
|
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
|
|
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
|
-
| {
|
|
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: {
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
:
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
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
|
|
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:
|
|
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 < 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
|
-
}
|