@vzn/vx 0.0.16 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -89
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +88 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- 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/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- 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
|
|
@@ -287,244 +289,6 @@ export function getRun(db: Database, runId: string): RunDetail | null {
|
|
|
287
289
|
// Cache stats
|
|
288
290
|
// ---------------------------------------------------------------------------
|
|
289
291
|
|
|
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
292
|
export interface CacheEntryRow {
|
|
529
293
|
hash: string
|
|
530
294
|
project: string
|
|
@@ -537,154 +301,10 @@ export interface CacheEntryRow {
|
|
|
537
301
|
accessedAt: number
|
|
538
302
|
}
|
|
539
303
|
|
|
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
304
|
// ---------------------------------------------------------------------------
|
|
595
305
|
// Task detail — full history for one (project, task)
|
|
596
306
|
// ---------------------------------------------------------------------------
|
|
597
307
|
|
|
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
308
|
// ---------------------------------------------------------------------------
|
|
689
309
|
// Cache key explain — latest entries row
|
|
690
310
|
// ---------------------------------------------------------------------------
|
|
@@ -705,7 +325,7 @@ export interface CacheKeyExplanation {
|
|
|
705
325
|
}
|
|
706
326
|
|
|
707
327
|
export function explainCacheKey(db: Database, taskId: string): CacheKeyExplanation {
|
|
708
|
-
const [project, task] = taskId
|
|
328
|
+
const [project, task] = splitTaskId(taskId)
|
|
709
329
|
const entry = db
|
|
710
330
|
.query(
|
|
711
331
|
`SELECT hash, command, exit_code AS exitCode, duration_ms AS durationMs,
|
|
@@ -736,8 +356,18 @@ export interface WhyDidThisRerun {
|
|
|
736
356
|
note: string
|
|
737
357
|
}
|
|
738
358
|
|
|
359
|
+
/** The three endings an UNCHANGED cache key can have. */
|
|
360
|
+
function unchangedKeyNote(cacheHit: number | null): string {
|
|
361
|
+
if (cacheHit === null) {
|
|
362
|
+
return 'cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknown'
|
|
363
|
+
}
|
|
364
|
+
return cacheHit
|
|
365
|
+
? 'cache key unchanged — this run was served from cache, nothing re-ran'
|
|
366
|
+
: 'cache key unchanged — re-executed on the same key (--no-cache / --force, or unrelated)'
|
|
367
|
+
}
|
|
368
|
+
|
|
739
369
|
export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
|
|
740
|
-
const [project, task] = taskId
|
|
370
|
+
const [project, task] = splitTaskId(taskId)
|
|
741
371
|
const this_ = db
|
|
742
372
|
.query(
|
|
743
373
|
`SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
|
|
@@ -754,15 +384,21 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
754
384
|
note: 'no row matching that runId + taskId',
|
|
755
385
|
}
|
|
756
386
|
}
|
|
387
|
+
// The previous run to compare against is the previous run that RECORDED A
|
|
388
|
+
// KEY. A skipped or persistent row carries `hash = ''`, and comparing
|
|
389
|
+
// against it would answer "cache key unchanged" for two runs that never had
|
|
390
|
+
// a key — a statement about inputs, made from no evidence.
|
|
757
391
|
const prev = db
|
|
758
392
|
.query(
|
|
759
393
|
`SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
|
|
760
|
-
FROM runs WHERE project = ? AND task = ? AND started_at < ?
|
|
394
|
+
FROM runs WHERE project = ? AND task = ? AND started_at < ? AND ${KEYED_RUNS_SQL}
|
|
761
395
|
ORDER BY started_at DESC LIMIT 1`,
|
|
762
396
|
)
|
|
763
397
|
.get(project, task, this_.startedAt) as
|
|
764
398
|
| { hash: string; status: string; cacheHit: number | null; startedAt: number }
|
|
765
399
|
| undefined
|
|
400
|
+
// …and this run must have one too, or there is nothing to compare.
|
|
401
|
+
const noKey = this_.hash === ''
|
|
766
402
|
return {
|
|
767
403
|
runId,
|
|
768
404
|
taskId,
|
|
@@ -771,12 +407,19 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
771
407
|
previousRun: prev
|
|
772
408
|
? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) }
|
|
773
409
|
: null,
|
|
774
|
-
hashChanged: prev ? prev.hash !== this_.hash : null,
|
|
775
|
-
note:
|
|
776
|
-
|
|
410
|
+
hashChanged: prev && !noKey ? prev.hash !== this_.hash : null,
|
|
411
|
+
note: noKey
|
|
412
|
+
? 'this task recorded no cache key (skipped, or a persistent task) — nothing to compare'
|
|
413
|
+
: prev && prev.hash !== this_.hash
|
|
777
414
|
? 'cache key changed between the previous run and this one (inputs differ)'
|
|
778
415
|
: prev
|
|
779
|
-
?
|
|
416
|
+
? // An unchanged key has two very different endings, and calling
|
|
417
|
+
// both a "re-run" answered the question wrong: a cache HIT did not
|
|
418
|
+
// re-run at all, so blaming `--no-cache` named a cause that cannot
|
|
419
|
+
// have applied. Only a run that EXECUTED on an unchanged key is
|
|
420
|
+
// the case this verb exists to explain. A row with no recorded
|
|
421
|
+
// cacheHit (older rows) is neither — say that, do not guess.
|
|
422
|
+
unchangedKeyNote(this_.cacheHit)
|
|
780
423
|
: 'no prior run for this (project, task)',
|
|
781
424
|
}
|
|
782
425
|
}
|
|
@@ -837,7 +480,7 @@ function loadEntryInputs(db: Database, entryHash: string): Map<string, EntryInpu
|
|
|
837
480
|
* has no row, `entries:[]` for the first run of a task.
|
|
838
481
|
*/
|
|
839
482
|
export function cacheKeyDiff(db: Database, runId: string, taskId: string): CacheKeyDiff {
|
|
840
|
-
const [project, task] = taskId
|
|
483
|
+
const [project, task] = splitTaskId(taskId)
|
|
841
484
|
const this_ = db
|
|
842
485
|
.query(
|
|
843
486
|
'SELECT hash, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
|
|
@@ -855,10 +498,25 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
855
498
|
}
|
|
856
499
|
}
|
|
857
500
|
|
|
501
|
+
// No key on this side → no diff to compute. `''` is the recorded-no-key
|
|
502
|
+
// sentinel (skipped / persistent); treating it as a key would resolve the
|
|
503
|
+
// `prev.hash === this_.hash` branch below and claim "same inputs".
|
|
504
|
+
if (this_.hash === '') {
|
|
505
|
+
return {
|
|
506
|
+
runId,
|
|
507
|
+
taskId,
|
|
508
|
+
found: true,
|
|
509
|
+
previousRunId: null,
|
|
510
|
+
entries: [],
|
|
511
|
+
unchangedCount: 0,
|
|
512
|
+
note: 'this task recorded no cache key (skipped, or a persistent task) — nothing to diff',
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
858
516
|
const prev = db
|
|
859
517
|
.query(
|
|
860
518
|
`SELECT run_id AS runId, hash FROM runs
|
|
861
|
-
WHERE project = ? AND task = ? AND started_at < ?
|
|
519
|
+
WHERE project = ? AND task = ? AND started_at < ? AND ${KEYED_RUNS_SQL}
|
|
862
520
|
ORDER BY started_at DESC LIMIT 1`,
|
|
863
521
|
)
|
|
864
522
|
.get(project, task, this_.startedAt) as { runId: string | null; hash: string } | undefined
|
|
@@ -948,980 +606,3 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
948
606
|
// ---------------------------------------------------------------------------
|
|
949
607
|
|
|
950
608
|
/** 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
|
-
}
|