@vzn/vx 0.0.503 → 0.0.505

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.503",
3
+ "version": "0.0.505",
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.503",
25
- "@vzn/vx-linux-arm64": "0.0.503",
26
- "@vzn/vx-darwin-x64": "0.0.503",
27
- "@vzn/vx-darwin-arm64": "0.0.503"
24
+ "@vzn/vx-linux-x64": "0.0.505",
25
+ "@vzn/vx-linux-arm64": "0.0.505",
26
+ "@vzn/vx-darwin-x64": "0.0.505",
27
+ "@vzn/vx-darwin-arm64": "0.0.505"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -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
- export const SCHEMA_VERSION = 'v30'
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
 
@@ -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 {
@@ -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 21 columns `bindRun` fills. */
20
- const RUN_TUPLE = `(${Array(21).fill('?').join(', ')})`
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 × 21 variables stays under 999, the oldest default
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 (21 columns). Shared between the
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 (25 columns). Booleans map to
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,
@@ -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
- ['task runs (24h)', `${f.runs24h} (${f.hits24h} cache hits)`],
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
- const status = t.status === 'failed' ? `failed (exit ${t.exitCode})` : t.status
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.hitCount} hit${inv.hitCount === 1 ? '' : 's'}` +
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.hitCount} hit${inv.hitCount === 1 ? '' : 's'}` +
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; only an executed run needs the word. A skipped task never
227
- // ran and has no key: it read `skipped · executed · key ` (item 898).
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(` previous ${fmtWhen(p.startedAt)} · ${p.status} · key ${p.hash}`)
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?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number } | null
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, run_id AS runId
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
- `${cached} cached`,
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