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