@vzn/vx 0.0.504 → 0.0.506
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 +0 -2
- package/package.json +5 -5
- package/src/cache/cache.ts +8 -3
- package/src/cache/layer.ts +11 -0
- package/src/cache/run-history.ts +12 -7
- package/src/cache/schema.ts +8 -1
- package/src/cli/info.ts +7 -1
- package/src/cli/last.ts +30 -4
- package/src/cli/why.ts +12 -4
- package/src/orchestrator/doctor.ts +3 -0
- package/src/orchestrator/metrics.ts +34 -5
- package/src/orchestrator/run-artifacts.ts +5 -0
- package/src/orchestrator/run-records.ts +12 -0
- package/src/orchestrator/run-report.ts +4 -1
- package/src/orchestrator/telemetry.ts +26 -0
package/README.md
CHANGED
|
@@ -149,8 +149,6 @@ Declared in `vx.workspace.ts`; without any, vx runs and caches locally.
|
|
|
149
149
|
| [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
|
|
150
150
|
| [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
|
|
151
151
|
|
|
152
|
-
Only `@vzn/vx` is on npm today; the plugins' first publish is pending.
|
|
153
|
-
|
|
154
152
|
Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
|
|
155
153
|
[architecture](packages/vx/docs/architecture.md).
|
|
156
154
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.506",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.506",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.506",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.506",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.506"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -231,7 +231,11 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
|
|
|
231
231
|
// v30: blob_verdicts — the blob-size check's verdict by a hash of the
|
|
232
232
|
// index file (A-60), so a warm run reads one row. The cache KEY is
|
|
233
233
|
// unchanged.
|
|
234
|
-
|
|
234
|
+
// v31: runs.restored and invocations' up_to_date / restored_local /
|
|
235
|
+
// restored_remote counts — a hit that restored outputs apart from
|
|
236
|
+
// one that found them in place, for every reader after the run.
|
|
237
|
+
// The cache KEY is unchanged.
|
|
238
|
+
export const SCHEMA_VERSION = 'v31'
|
|
235
239
|
|
|
236
240
|
/** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
|
|
237
241
|
const SELECT_ENTRY =
|
|
@@ -1744,16 +1748,17 @@ export class Cache implements CacheLayer {
|
|
|
1744
1748
|
const since = Date.now() - 24 * 60 * 60 * 1000
|
|
1745
1749
|
const runs = this.db
|
|
1746
1750
|
.prepare(
|
|
1747
|
-
`SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN status IN ('cache-hit', 'cache-hit-remote') THEN 1 ELSE 0 END), 0) AS hits FROM runs WHERE started_at >= ? AND ${EXECUTED_RUNS_SQL}${
|
|
1751
|
+
`SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN status IN ('cache-hit', 'cache-hit-remote') THEN 1 ELSE 0 END), 0) AS hits, COALESCE(SUM(restored = 1), 0) AS restored FROM runs WHERE started_at >= ? AND ${EXECUTED_RUNS_SQL}${
|
|
1748
1752
|
scoped ? ' AND project = ?' : ''
|
|
1749
1753
|
}`,
|
|
1750
1754
|
)
|
|
1751
|
-
.get(since, ...scopeParams) as { total: number; hits: number }
|
|
1755
|
+
.get(since, ...scopeParams) as { total: number; hits: number; restored: number }
|
|
1752
1756
|
return {
|
|
1753
1757
|
entryCount: aggregate.n,
|
|
1754
1758
|
totalBytes: aggregate.bytes,
|
|
1755
1759
|
runCountLast24h: runs.total,
|
|
1756
1760
|
hitCountLast24h: runs.hits,
|
|
1761
|
+
restoredCountLast24h: runs.restored,
|
|
1757
1762
|
}
|
|
1758
1763
|
}
|
|
1759
1764
|
|
package/src/cache/layer.ts
CHANGED
|
@@ -192,6 +192,11 @@ export interface RunRecord {
|
|
|
192
192
|
wallclockStartNs?: bigint // hrtime span relative to run t=0
|
|
193
193
|
wallclockEndNs?: bigint
|
|
194
194
|
cacheHit?: boolean // convenience for flamegraph color; derivable from status
|
|
195
|
+
/**
|
|
196
|
+
* On a hit: whether outputs were written (`true`) or the disk already
|
|
197
|
+
* matched the entry (`false`, up-to-date). Absent on other statuses (v31).
|
|
198
|
+
*/
|
|
199
|
+
restored?: boolean
|
|
195
200
|
attempts?: number // >1 when the task retried; the direct within-run flaky signal
|
|
196
201
|
/**
|
|
197
202
|
* Whether the task declared a `cache` block. An uncached task executes
|
|
@@ -235,6 +240,10 @@ export interface InvocationRecord {
|
|
|
235
240
|
hitCount: number
|
|
236
241
|
hitLocalCount: number
|
|
237
242
|
hitRemoteCount: number
|
|
243
|
+
/** Hits that restored nothing, and hits that restored outputs per layer (v31); they sum to `hitCount`. */
|
|
244
|
+
upToDateCount: number
|
|
245
|
+
restoredLocalCount: number
|
|
246
|
+
restoredRemoteCount: number
|
|
238
247
|
exitOk: boolean
|
|
239
248
|
commitSha: string | null
|
|
240
249
|
branch: string | null
|
|
@@ -269,6 +278,8 @@ export interface CacheStats {
|
|
|
269
278
|
totalBytes: number
|
|
270
279
|
runCountLast24h: number
|
|
271
280
|
hitCountLast24h: number
|
|
281
|
+
/** Of those hits, the ones that restored outputs; the rest found the disk up to date. */
|
|
282
|
+
restoredCountLast24h: number
|
|
272
283
|
}
|
|
273
284
|
|
|
274
285
|
export interface CacheStatsOptions {
|
package/src/cache/run-history.ts
CHANGED
|
@@ -13,11 +13,11 @@ const INSERT_RUNS = `
|
|
|
13
13
|
started_at, ended_at,
|
|
14
14
|
run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
|
|
15
15
|
cache_hit, attempts, cached,
|
|
16
|
-
blocked_by, timed_out, sandbox_violations, not_ready
|
|
16
|
+
blocked_by, timed_out, sandbox_violations, not_ready, restored
|
|
17
17
|
)
|
|
18
18
|
VALUES `
|
|
19
|
-
/** One `runs` row's placeholders: the
|
|
20
|
-
const RUN_TUPLE = `(${Array(
|
|
19
|
+
/** One `runs` row's placeholders: the 22 columns `bindRun` fills. */
|
|
20
|
+
const RUN_TUPLE = `(${Array(22).fill('?').join(', ')})`
|
|
21
21
|
|
|
22
22
|
/** An INSERT of `n` `runs` rows. */
|
|
23
23
|
function insertRunsSql(n: number): string {
|
|
@@ -25,7 +25,7 @@ function insertRunsSql(n: number): string {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Rows per INSERT: 40 ×
|
|
28
|
+
* Rows per INSERT: 40 × 22 variables stays under 999, the oldest default
|
|
29
29
|
* ceiling. A statement per row was 8.7 ms of 1,000 rows, 40 at a time 6.4
|
|
30
30
|
* (2026-10-03).
|
|
31
31
|
*/
|
|
@@ -48,11 +48,12 @@ export class RunHistory {
|
|
|
48
48
|
run_id, command, requested_tasks, cache_policy, concurrency, flow,
|
|
49
49
|
started_at, ended_at, total_duration_ms,
|
|
50
50
|
task_count, failed_count, hit_count, hit_local_count, hit_remote_count,
|
|
51
|
+
up_to_date_count, restored_local_count, restored_remote_count,
|
|
51
52
|
exit_ok,
|
|
52
53
|
commit_sha, branch, dirty, ci, ci_provider,
|
|
53
54
|
host, os, arch, vx_version, tags
|
|
54
55
|
)
|
|
55
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
56
57
|
ON CONFLICT(run_id) DO NOTHING
|
|
57
58
|
`,
|
|
58
59
|
)
|
|
@@ -119,7 +120,7 @@ export class RunHistory {
|
|
|
119
120
|
|
|
120
121
|
/**
|
|
121
122
|
* Bind a RunRecord to the positional parameters expected by the
|
|
122
|
-
* `insertRun` prepared statement (
|
|
123
|
+
* `insertRun` prepared statement (22 columns). Shared between the
|
|
123
124
|
* single and batched record paths.
|
|
124
125
|
*/
|
|
125
126
|
function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
|
|
@@ -150,12 +151,13 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
|
|
|
150
151
|
run.timedOut === true ? 1 : null,
|
|
151
152
|
run.sandboxViolations ?? null,
|
|
152
153
|
run.notReady ?? null,
|
|
154
|
+
run.restored === undefined ? null : run.restored ? 1 : 0,
|
|
153
155
|
]
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
/**
|
|
157
159
|
* Bind an InvocationRecord to the positional parameters of the
|
|
158
|
-
* `insertInvocation` prepared statement (
|
|
160
|
+
* `insertInvocation` prepared statement (28 columns). Booleans map to
|
|
159
161
|
* 0/1; null-or-bool columns (`dirty`) keep null distinct from 0.
|
|
160
162
|
*/
|
|
161
163
|
function bindInvocation(inv: InvocationRecord): SQLQueryBindings[] {
|
|
@@ -174,6 +176,9 @@ function bindInvocation(inv: InvocationRecord): SQLQueryBindings[] {
|
|
|
174
176
|
inv.hitCount,
|
|
175
177
|
inv.hitLocalCount,
|
|
176
178
|
inv.hitRemoteCount,
|
|
179
|
+
inv.upToDateCount,
|
|
180
|
+
inv.restoredLocalCount,
|
|
181
|
+
inv.restoredRemoteCount,
|
|
177
182
|
inv.exitOk ? 1 : 0,
|
|
178
183
|
inv.commitSha,
|
|
179
184
|
inv.branch,
|
package/src/cache/schema.ts
CHANGED
|
@@ -93,7 +93,10 @@ export function createTables(db: Database): void {
|
|
|
93
93
|
blocked_by TEXT,
|
|
94
94
|
timed_out INTEGER,
|
|
95
95
|
sandbox_violations INTEGER,
|
|
96
|
-
not_ready TEXT
|
|
96
|
+
not_ready TEXT,
|
|
97
|
+
-- v31: on a hit, 1 when outputs were restored, 0 when the disk
|
|
98
|
+
-- already matched (up-to-date); NULL on every other status.
|
|
99
|
+
restored INTEGER
|
|
97
100
|
);
|
|
98
101
|
-- Two whole-table indexes only, and both APPEND: every row of a run carries the
|
|
99
102
|
-- same run_id and a started_at newer than everything before it, so
|
|
@@ -214,6 +217,10 @@ export function createTables(db: Database): void {
|
|
|
214
217
|
hit_count INTEGER NOT NULL,
|
|
215
218
|
hit_local_count INTEGER NOT NULL,
|
|
216
219
|
hit_remote_count INTEGER NOT NULL,
|
|
220
|
+
-- v31: the hits by what they did to the disk; they sum to hit_count.
|
|
221
|
+
up_to_date_count INTEGER NOT NULL DEFAULT 0,
|
|
222
|
+
restored_local_count INTEGER NOT NULL DEFAULT 0,
|
|
223
|
+
restored_remote_count INTEGER NOT NULL DEFAULT 0,
|
|
217
224
|
exit_ok INTEGER NOT NULL,
|
|
218
225
|
commit_sha TEXT,
|
|
219
226
|
branch TEXT,
|
package/src/cli/info.ts
CHANGED
|
@@ -104,7 +104,13 @@ export function renderInfo(f: InfoFacts): string {
|
|
|
104
104
|
: []),
|
|
105
105
|
// Task runs, not invocations (which `vx last` calls runs): the count
|
|
106
106
|
// the hits are a share of.
|
|
107
|
-
[
|
|
107
|
+
[
|
|
108
|
+
'task runs (24h)',
|
|
109
|
+
`${f.runs24h} (${f.hits24h} cache hits` +
|
|
110
|
+
(f.hits24h > 0
|
|
111
|
+
? `: ${f.hits24h - f.restored24h} up-to-date, ${f.restored24h} restored)`
|
|
112
|
+
: ')'),
|
|
113
|
+
],
|
|
108
114
|
['flaky tasks', describeFlakyTasks(f.flakyTasks)],
|
|
109
115
|
['sandbox', describeSandbox(f.sandbox)],
|
|
110
116
|
['vx-lock.json', f.lockfile ? 'yes' : 'no'],
|
package/src/cli/last.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
getRun,
|
|
15
15
|
type InvocationDetail,
|
|
16
16
|
listInvocations,
|
|
17
|
+
outcomeWord,
|
|
17
18
|
type RunSummaryRow,
|
|
18
19
|
resolveRunId,
|
|
19
20
|
shortRunId,
|
|
@@ -184,7 +185,14 @@ export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
|
|
|
184
185
|
// by design; a reader must not take its row for a miss.
|
|
185
186
|
// A failure's row reads as the frame did — `failed (exit 137)` —
|
|
186
187
|
// and above 128 names the signal the number stands for (260).
|
|
187
|
-
|
|
188
|
+
// A hit reads as the run's own summary said it: up-to-date (nothing
|
|
189
|
+
// restored) or restored-local / restored-remote.
|
|
190
|
+
const status =
|
|
191
|
+
t.status === 'failed'
|
|
192
|
+
? `failed (exit ${t.exitCode})`
|
|
193
|
+
: t.cacheHit === true && t.restored !== null
|
|
194
|
+
? outcomeWord({ status: t.status as 'cache-hit', restored: t.restored })
|
|
195
|
+
: t.status
|
|
188
196
|
lines.push(
|
|
189
197
|
` ${status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
|
|
190
198
|
`${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}` +
|
|
@@ -199,6 +207,25 @@ export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
|
|
|
199
207
|
return lines
|
|
200
208
|
}
|
|
201
209
|
|
|
210
|
+
/**
|
|
211
|
+
* A run's hits by what they did to the disk, as its summary counted them:
|
|
212
|
+
* `5 hits (3 up-to-date, 2 restored: 1 local, 1 remote)`; the layers only
|
|
213
|
+
* when something was restored.
|
|
214
|
+
*/
|
|
215
|
+
function hitsLine(
|
|
216
|
+
inv: Pick<
|
|
217
|
+
InvocationDetail,
|
|
218
|
+
'hitCount' | 'upToDateCount' | 'restoredLocalCount' | 'restoredRemoteCount'
|
|
219
|
+
>,
|
|
220
|
+
): string {
|
|
221
|
+
const hits = `${inv.hitCount} hit${inv.hitCount === 1 ? '' : 's'}`
|
|
222
|
+
if (inv.hitCount === 0) return hits
|
|
223
|
+
const restored = inv.restoredLocalCount + inv.restoredRemoteCount
|
|
224
|
+
const layers =
|
|
225
|
+
restored > 0 ? `: ${inv.restoredLocalCount} local, ${inv.restoredRemoteCount} remote` : ''
|
|
226
|
+
return `${hits} (${inv.upToDateCount} up-to-date, ${restored} restored${layers})`
|
|
227
|
+
}
|
|
228
|
+
|
|
202
229
|
export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
203
230
|
const parsed = parseLastArgs(args)
|
|
204
231
|
if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
|
|
@@ -232,7 +259,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
232
259
|
const verdict = inv.exitOk ? 'ok ' : 'FAILED'
|
|
233
260
|
process.stdout.write(
|
|
234
261
|
`${verdict} ${fmtWhen(inv.startedAt)} ${shortRunId(db, inv.runId)} ` +
|
|
235
|
-
`${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${inv
|
|
262
|
+
`${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}` +
|
|
236
263
|
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''} · ${fmtMs(inv.totalDurationMs)} $ ${inv.command}\n`,
|
|
237
264
|
)
|
|
238
265
|
}
|
|
@@ -273,8 +300,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
273
300
|
`${inv.ci ? ` · CI${inv.ciProvider !== null ? ` (${inv.ciProvider})` : ''}` : ''}`,
|
|
274
301
|
)
|
|
275
302
|
lines.push(
|
|
276
|
-
` ${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${inv
|
|
277
|
-
` (${inv.hitLocalCount} local, ${inv.hitRemoteCount} remote)` +
|
|
303
|
+
` ${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}` +
|
|
278
304
|
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
|
|
279
305
|
)
|
|
280
306
|
lines.push(...formatTaskRows(detail?.tasks ?? []))
|
package/src/cli/why.ts
CHANGED
|
@@ -171,6 +171,11 @@ async function rootSpelled(root: string, target: string): Promise<string> {
|
|
|
171
171
|
return typeof name === 'string' && name !== '' ? `${name}#${target.slice(3)}` : target
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/** ` · up-to-date` or ` · restored` on a hit, as the run's summary said it; nothing otherwise. */
|
|
175
|
+
function restoredWord(r: { restored: boolean | null }): string {
|
|
176
|
+
return r.restored === null ? '' : r.restored ? ' · restored' : ' · up-to-date'
|
|
177
|
+
}
|
|
178
|
+
|
|
174
179
|
export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
175
180
|
const parsed = parseWhyArgs(args)
|
|
176
181
|
if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`)
|
|
@@ -223,18 +228,21 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
223
228
|
const t = why.thisRun!
|
|
224
229
|
lines.push(`${taskId} — run ${runId}`)
|
|
225
230
|
// A hit's status (`cache-hit`, `cache-hit-remote`) already names it and
|
|
226
|
-
// its tier
|
|
227
|
-
//
|
|
231
|
+
// its tier, and `up-to-date` / `restored` says what it did to the disk;
|
|
232
|
+
// only an executed run needs the word. A skipped task never ran and has
|
|
233
|
+
// no key: it read `skipped · executed · key ` (item 898).
|
|
228
234
|
const ran = t.cacheHit === false && t.status !== 'skipped'
|
|
229
235
|
lines.push(
|
|
230
|
-
` this run ${fmtWhen(t.startedAt)} · ${t.status}` +
|
|
236
|
+
` this run ${fmtWhen(t.startedAt)} · ${t.status}${restoredWord(t)}` +
|
|
231
237
|
`${ran ? ' · executed' : ''}${t.hash === '' ? ' · no key' : ` · key ${t.hash}`}`,
|
|
232
238
|
)
|
|
233
239
|
if (why.previousRun == null) {
|
|
234
240
|
lines.push(' previous (none — first recorded run of this task)')
|
|
235
241
|
} else {
|
|
236
242
|
const p = why.previousRun
|
|
237
|
-
lines.push(
|
|
243
|
+
lines.push(
|
|
244
|
+
` previous ${fmtWhen(p.startedAt)} · ${p.status}${restoredWord(p)} · key ${p.hash}`,
|
|
245
|
+
)
|
|
238
246
|
// The key moved and the diff says by what: the verdict names it, so
|
|
239
247
|
// an env-only change reads as one line, not "inputs differ" above a
|
|
240
248
|
// table to scan.
|
|
@@ -88,6 +88,8 @@ export interface InfoFacts {
|
|
|
88
88
|
orphans: { artifacts: number; bytes: number }
|
|
89
89
|
runs24h: number
|
|
90
90
|
hits24h: number
|
|
91
|
+
/** Of `hits24h`, the hits that restored outputs; the rest were up to date. */
|
|
92
|
+
restored24h: number
|
|
91
93
|
/** Tasks the retained history shows both passing and failing on unchanged inputs. */
|
|
92
94
|
flakyTasks: FlakyTask[]
|
|
93
95
|
lockfile: boolean
|
|
@@ -243,6 +245,7 @@ async function collectWorkspaceInfo(
|
|
|
243
245
|
orphans: { artifacts: orphans.orphans, bytes: orphans.orphanBytes },
|
|
244
246
|
runs24h: stats.runCountLast24h,
|
|
245
247
|
hits24h: stats.hitCountLast24h,
|
|
248
|
+
restored24h: stats.restoredCountLast24h,
|
|
246
249
|
// Same inputs, both outcomes — the history's definition of flaky, over
|
|
247
250
|
// the 30 days it keeps. A run names its own findings in its footer;
|
|
248
251
|
// this is the workspace's standing list.
|
|
@@ -32,6 +32,8 @@ export interface RunSummaryRow {
|
|
|
32
32
|
startedAt: number
|
|
33
33
|
endedAt: number
|
|
34
34
|
cacheHit: boolean | null
|
|
35
|
+
/** On a hit: true when outputs were restored, false when up-to-date (v31); null otherwise. */
|
|
36
|
+
restored: boolean | null
|
|
35
37
|
/** Whether the task declared a `cache` block; null on rows older than the column. */
|
|
36
38
|
cached: boolean | null
|
|
37
39
|
hash: string
|
|
@@ -76,9 +78,10 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
76
78
|
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
|
|
77
79
|
type RawRow = Omit<
|
|
78
80
|
RunSummaryRow,
|
|
79
|
-
'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs' | 'timedOut'
|
|
81
|
+
'cacheHit' | 'restored' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs' | 'timedOut'
|
|
80
82
|
> & {
|
|
81
83
|
cacheHit: number | null
|
|
84
|
+
restored: number | null
|
|
82
85
|
cached: number | null
|
|
83
86
|
// NOT bigint: `bun:sqlite` hands these back as JS numbers (the handle
|
|
84
87
|
// does not set `safeIntegers`), so the annotation claimed a precision
|
|
@@ -93,7 +96,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
93
96
|
.query(
|
|
94
97
|
`SELECT run_id AS runId, project, task, status, exit_code AS exitCode,
|
|
95
98
|
duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
|
|
96
|
-
cache_hit AS cacheHit, cached, hash,
|
|
99
|
+
cache_hit AS cacheHit, restored, cached, hash,
|
|
97
100
|
cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
|
|
98
101
|
wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
|
|
99
102
|
blocked_by AS blockedBy, timed_out AS timedOut,
|
|
@@ -104,6 +107,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
104
107
|
return rows.map((r) => ({
|
|
105
108
|
...r,
|
|
106
109
|
cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit),
|
|
110
|
+
restored: r.restored === null ? null : Boolean(r.restored),
|
|
107
111
|
cached: r.cached === null ? null : Boolean(r.cached),
|
|
108
112
|
timedOut: r.timedOut === null ? null : Boolean(r.timedOut),
|
|
109
113
|
wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
|
|
@@ -134,6 +138,10 @@ export interface InvocationDetail {
|
|
|
134
138
|
hitCount: number
|
|
135
139
|
hitLocalCount: number
|
|
136
140
|
hitRemoteCount: number
|
|
141
|
+
/** The hits by what they did to the disk (v31); they sum to `hitCount`. */
|
|
142
|
+
upToDateCount: number
|
|
143
|
+
restoredLocalCount: number
|
|
144
|
+
restoredRemoteCount: number
|
|
137
145
|
exitOk: boolean
|
|
138
146
|
commitSha: string | null
|
|
139
147
|
branch: string | null
|
|
@@ -162,6 +170,9 @@ interface InvocationRawRow {
|
|
|
162
170
|
hitCount: number
|
|
163
171
|
hitLocalCount: number
|
|
164
172
|
hitRemoteCount: number
|
|
173
|
+
upToDateCount: number
|
|
174
|
+
restoredLocalCount: number
|
|
175
|
+
restoredRemoteCount: number
|
|
165
176
|
exitOk: number
|
|
166
177
|
commitSha: string | null
|
|
167
178
|
branch: string | null
|
|
@@ -181,6 +192,8 @@ const INVOCATION_COLUMNS = `
|
|
|
181
192
|
started_at AS startedAt, ended_at AS endedAt, total_duration_ms AS totalDurationMs,
|
|
182
193
|
task_count AS taskCount, failed_count AS failedCount, hit_count AS hitCount,
|
|
183
194
|
hit_local_count AS hitLocalCount, hit_remote_count AS hitRemoteCount,
|
|
195
|
+
up_to_date_count AS upToDateCount, restored_local_count AS restoredLocalCount,
|
|
196
|
+
restored_remote_count AS restoredRemoteCount,
|
|
184
197
|
exit_ok AS exitOk,
|
|
185
198
|
commit_sha AS commitSha, branch, dirty, ci, ci_provider AS ciProvider,
|
|
186
199
|
host, os, arch, vx_version AS vxVersion, tags`
|
|
@@ -215,6 +228,9 @@ function mapInvocation(r: InvocationRawRow): InvocationDetail {
|
|
|
215
228
|
hitCount: r.hitCount,
|
|
216
229
|
hitLocalCount: r.hitLocalCount,
|
|
217
230
|
hitRemoteCount: r.hitRemoteCount,
|
|
231
|
+
upToDateCount: r.upToDateCount,
|
|
232
|
+
restoredLocalCount: r.restoredLocalCount,
|
|
233
|
+
restoredRemoteCount: r.restoredRemoteCount,
|
|
218
234
|
exitOk: Boolean(r.exitOk),
|
|
219
235
|
commitSha: r.commitSha,
|
|
220
236
|
branch: r.branch,
|
|
@@ -340,11 +356,19 @@ export interface WhyDidThisRerun {
|
|
|
340
356
|
hash: string
|
|
341
357
|
status: string
|
|
342
358
|
cacheHit: boolean | null
|
|
359
|
+
/** On a hit: outputs restored (true) or up-to-date (false); null otherwise. */
|
|
360
|
+
restored: boolean | null
|
|
343
361
|
/** Whether the task declared a `cache` block; null on rows older than the column. */
|
|
344
362
|
cached: boolean | null
|
|
345
363
|
startedAt: number
|
|
346
364
|
}
|
|
347
|
-
previousRun?: {
|
|
365
|
+
previousRun?: {
|
|
366
|
+
hash: string
|
|
367
|
+
status: string
|
|
368
|
+
cacheHit: boolean | null
|
|
369
|
+
restored: boolean | null
|
|
370
|
+
startedAt: number
|
|
371
|
+
} | null
|
|
348
372
|
hashChanged?: boolean | null
|
|
349
373
|
note: string
|
|
350
374
|
}
|
|
@@ -427,7 +451,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
427
451
|
const [project, task] = splitTaskId(taskId)
|
|
428
452
|
const this_ = db
|
|
429
453
|
.query(
|
|
430
|
-
`SELECT id, hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
|
|
454
|
+
`SELECT id, hash, status, cache_hit AS cacheHit, restored, cached, started_at AS startedAt
|
|
431
455
|
FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
|
|
432
456
|
)
|
|
433
457
|
.get(runId, project, task) as
|
|
@@ -436,6 +460,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
436
460
|
hash: string
|
|
437
461
|
status: string
|
|
438
462
|
cacheHit: number | null
|
|
463
|
+
restored: number | null
|
|
439
464
|
cached: number | null
|
|
440
465
|
startedAt: number
|
|
441
466
|
}
|
|
@@ -454,7 +479,8 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
454
479
|
// a key — a statement about inputs, made from no evidence.
|
|
455
480
|
const prev = db
|
|
456
481
|
.query(
|
|
457
|
-
`SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt,
|
|
482
|
+
`SELECT hash, status, cache_hit AS cacheHit, restored, started_at AS startedAt,
|
|
483
|
+
run_id AS runId
|
|
458
484
|
FROM runs WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
|
|
459
485
|
ORDER BY id DESC LIMIT 1`,
|
|
460
486
|
)
|
|
@@ -463,6 +489,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
463
489
|
hash: string
|
|
464
490
|
status: string
|
|
465
491
|
cacheHit: number | null
|
|
492
|
+
restored: number | null
|
|
466
493
|
startedAt: number
|
|
467
494
|
runId: string | null
|
|
468
495
|
}
|
|
@@ -477,6 +504,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
477
504
|
hash: this_.hash,
|
|
478
505
|
status: this_.status,
|
|
479
506
|
cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit),
|
|
507
|
+
restored: this_.restored === null ? null : Boolean(this_.restored),
|
|
480
508
|
cached: this_.cached === null ? null : Boolean(this_.cached),
|
|
481
509
|
startedAt: this_.startedAt,
|
|
482
510
|
},
|
|
@@ -485,6 +513,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
485
513
|
hash: prev.hash,
|
|
486
514
|
status: prev.status,
|
|
487
515
|
cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit),
|
|
516
|
+
restored: prev.restored === null ? null : Boolean(prev.restored),
|
|
488
517
|
startedAt: prev.startedAt,
|
|
489
518
|
}
|
|
490
519
|
: null,
|
|
@@ -17,6 +17,7 @@ import path from 'node:path'
|
|
|
17
17
|
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
18
18
|
import { tallyOutcomes, type Tally } from './tally.js'
|
|
19
19
|
import type { FlakyFinding } from './failure-mode.js'
|
|
20
|
+
import { isCacheHit } from './telemetry.js'
|
|
20
21
|
|
|
21
22
|
export interface SummarizeArgs {
|
|
22
23
|
/** Empty string → default path; otherwise the explicit file path. */
|
|
@@ -45,6 +46,7 @@ export interface SummaryTaskJson {
|
|
|
45
46
|
exitCode: number
|
|
46
47
|
durationMs: number
|
|
47
48
|
hash: string | null
|
|
49
|
+
restored?: boolean
|
|
48
50
|
noCache?: true
|
|
49
51
|
flaky?: { passes: number; failures: number; attempts: number }
|
|
50
52
|
cpuMs?: number
|
|
@@ -83,6 +85,9 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
|
|
|
83
85
|
exitCode: o.exitCode,
|
|
84
86
|
durationMs: o.durationMs,
|
|
85
87
|
hash: o.hash ?? null,
|
|
88
|
+
// On a hit: whether it restored outputs or found them up to date, as
|
|
89
|
+
// `summary`'s restoredLocal / restoredRemote / upToDate count it.
|
|
90
|
+
...(isCacheHit(o.status) ? { restored: o.restored === true } : {}),
|
|
86
91
|
// A task with no `cache` block executes every run by design; without
|
|
87
92
|
// the flag a consumer computing a hit rate cannot tell a miss from a
|
|
88
93
|
// task that could never hit (its `hash` is still set — dependents fold
|
|
@@ -57,6 +57,9 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
57
57
|
let failedCount = 0
|
|
58
58
|
let hitLocalCount = 0
|
|
59
59
|
let hitRemoteCount = 0
|
|
60
|
+
let upToDateCount = 0
|
|
61
|
+
let restoredLocalCount = 0
|
|
62
|
+
let restoredRemoteCount = 0
|
|
60
63
|
for (const o of outcomes) {
|
|
61
64
|
if (isGroupTask(o.node)) continue
|
|
62
65
|
// aborted (killed by a shutdown signal) isn't a real run.
|
|
@@ -91,6 +94,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
91
94
|
...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
|
|
92
95
|
...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
|
|
93
96
|
cacheHit: isCacheHit(o.status),
|
|
97
|
+
...(isCacheHit(o.status) ? { restored: o.restored === true } : {}),
|
|
94
98
|
...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
|
|
95
99
|
cached: o.node.config.cache !== undefined,
|
|
96
100
|
...(o.blockedBy !== undefined ? { blockedBy: o.blockedBy } : {}),
|
|
@@ -101,6 +105,11 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
101
105
|
if (o.status === 'failed') failedCount++
|
|
102
106
|
if (o.status === 'cache-hit') hitLocalCount++
|
|
103
107
|
if (o.status === 'cache-hit-remote') hitRemoteCount++
|
|
108
|
+
if (isCacheHit(o.status)) {
|
|
109
|
+
if (o.restored !== true) upToDateCount++
|
|
110
|
+
else if (o.status === 'cache-hit') restoredLocalCount++
|
|
111
|
+
else restoredRemoteCount++
|
|
112
|
+
}
|
|
104
113
|
}
|
|
105
114
|
const invocation: InvocationRecord = {
|
|
106
115
|
runId,
|
|
@@ -117,6 +126,9 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
117
126
|
hitCount: hitLocalCount + hitRemoteCount,
|
|
118
127
|
hitLocalCount,
|
|
119
128
|
hitRemoteCount,
|
|
129
|
+
upToDateCount,
|
|
130
|
+
restoredLocalCount,
|
|
131
|
+
restoredRemoteCount,
|
|
120
132
|
exitOk: input.ok,
|
|
121
133
|
commitSha: input.git.commitSha,
|
|
122
134
|
branch: input.git.branch,
|
|
@@ -139,7 +139,10 @@ export function formatRunReportMarkdown(result: RunResult): string {
|
|
|
139
139
|
`**${t.total} task${t.total === 1 ? '' : 's'}**`,
|
|
140
140
|
`${t.successful} success`,
|
|
141
141
|
`${t.failed} failed`,
|
|
142
|
-
|
|
142
|
+
// What the hits did to the disk, as the run's summary splits them.
|
|
143
|
+
cached > 0
|
|
144
|
+
? `${cached} cached (${t.upToDate} up-to-date, ${t.restoredLocal + t.restoredRemote} restored)`
|
|
145
|
+
: '0 cached',
|
|
143
146
|
]
|
|
144
147
|
if (t.skipped > 0) parts.push(`${t.skipped} skipped`)
|
|
145
148
|
if (t.aborted > 0) parts.push(`${t.aborted} aborted`)
|
|
@@ -180,6 +180,12 @@ export interface TaskTelemetry {
|
|
|
180
180
|
sandboxViolations?: number
|
|
181
181
|
/** On a failed persistent task: why it never became ready. `exitCode` is the child's own when it exited. Additive. */
|
|
182
182
|
notReady?: 'timeout' | 'exited' | 'spawn'
|
|
183
|
+
/**
|
|
184
|
+
* On a cache hit: whether outputs were written this run (`true`) or the
|
|
185
|
+
* disk already matched the entry and nothing was restored (`false`, an
|
|
186
|
+
* up-to-date hit). Absent on every other status. Additive.
|
|
187
|
+
*/
|
|
188
|
+
restored?: boolean
|
|
183
189
|
/** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
|
|
184
190
|
wallclockStartNs?: string
|
|
185
191
|
wallclockEndNs?: string
|
|
@@ -245,8 +251,17 @@ export interface RunSummaryRecord {
|
|
|
245
251
|
*/
|
|
246
252
|
abortedCount: number
|
|
247
253
|
hitCount: number
|
|
254
|
+
/** Hits by the layer that answered, up-to-date ones included. */
|
|
248
255
|
hitLocalCount: number
|
|
249
256
|
hitRemoteCount: number
|
|
257
|
+
/**
|
|
258
|
+
* Hits by what they did to the disk, as the run's summary counts them:
|
|
259
|
+
* nothing restored (up-to-date, from either layer), or outputs restored
|
|
260
|
+
* from the local or the remote layer. The three sum to `hitCount`.
|
|
261
|
+
*/
|
|
262
|
+
upToDateCount: number
|
|
263
|
+
restoredLocalCount: number
|
|
264
|
+
restoredRemoteCount: number
|
|
250
265
|
exitOk: boolean
|
|
251
266
|
tasks: readonly TaskTelemetry[]
|
|
252
267
|
}
|
|
@@ -276,10 +291,17 @@ export function assembleRunSummary(
|
|
|
276
291
|
let failedCount = 0
|
|
277
292
|
let hitLocalCount = 0
|
|
278
293
|
let hitRemoteCount = 0
|
|
294
|
+
let upToDateCount = 0
|
|
295
|
+
let restoredLocalCount = 0
|
|
296
|
+
let restoredRemoteCount = 0
|
|
279
297
|
for (const t of tasks) {
|
|
280
298
|
if (t.status === 'failed') failedCount++
|
|
281
299
|
if (t.cacheSource === 'local') hitLocalCount++
|
|
282
300
|
else if (t.cacheSource === 'remote') hitRemoteCount++
|
|
301
|
+
else continue
|
|
302
|
+
if (t.restored !== true) upToDateCount++
|
|
303
|
+
else if (t.cacheSource === 'local') restoredLocalCount++
|
|
304
|
+
else restoredRemoteCount++
|
|
283
305
|
}
|
|
284
306
|
return {
|
|
285
307
|
v: TELEMETRY_SCHEMA_VERSION,
|
|
@@ -293,6 +315,9 @@ export function assembleRunSummary(
|
|
|
293
315
|
hitCount: hitLocalCount + hitRemoteCount,
|
|
294
316
|
hitLocalCount,
|
|
295
317
|
hitRemoteCount,
|
|
318
|
+
upToDateCount,
|
|
319
|
+
restoredLocalCount,
|
|
320
|
+
restoredRemoteCount,
|
|
296
321
|
exitOk: timing.exitOk,
|
|
297
322
|
tasks,
|
|
298
323
|
}
|
|
@@ -558,6 +583,7 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
|
558
583
|
if (o.timedOut === true) t.timedOut = true
|
|
559
584
|
if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
|
|
560
585
|
if (o.notReady !== undefined) t.notReady = o.notReady
|
|
586
|
+
if (isCacheHit(o.status)) t.restored = o.restored === true
|
|
561
587
|
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
562
588
|
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
563
589
|
return t
|