@vzn/vx 0.0.650 → 0.0.652

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.
Files changed (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -51,7 +51,13 @@ import {
51
51
  } from 'node:fs'
52
52
  import { chmod, lstat, mkdir, readdir, rm, stat, unlink, utimes, writeFile } from 'node:fs/promises'
53
53
  import path from 'node:path'
54
- import { createArtifactTables, createTables, inHashes, lazyStatement } from './schema.js'
54
+ import {
55
+ createArtifactTables,
56
+ createTables,
57
+ inHashes,
58
+ lazyStatement,
59
+ SQLITE_FCNTL_PERSIST_WAL,
60
+ } from './schema.js'
55
61
  import {
56
62
  UserError,
57
63
  DOT_VX_GITIGNORE,
@@ -130,6 +136,9 @@ import { behindLink } from './linked-inputs.js'
130
136
  * this, so the file time trails the last use by at most this much, and
131
137
  * the policy reads the file time plus this as the last use.
132
138
  */
139
+ // About 1.3 ms of inserts per turn: a task's exit waits at most that.
140
+ const HELD_SLICE = 250
141
+
133
142
  const ORPHAN_GRACE_MS = 60 * 60 * 1000
134
143
 
135
144
  /**
@@ -272,7 +281,7 @@ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
272
281
  // the memo simply stops answering wrongly, so an affected task's
273
282
  // key moves from a WRONG value to the right one: it misses once,
274
283
  // re-runs, re-caches. Self-healing, never a wrong hit.
275
- // v25: runs.cached — whether the task declared a `cache` block. An
284
+ // v25: runs.cached — whether the task declared `cache: true`. An
276
285
  // uncached task's row looked like any executed miss, so `vx why`
277
286
  // headlined "cache key changed (inputs differ)" for a task that
278
287
  // runs every time by design, and `vx last` could not mark it the
@@ -355,7 +364,10 @@ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
355
364
  // v50: steps, warnings, failures and lockfile_memos — what a run said
356
365
  // and a lockfile claimant parsed, files under the cache dir until
357
366
  // now (if it can be in the DB, it is). The cache KEY is unchanged.
358
- export const SCHEMA_VERSION = 'v50'
367
+ // v51: runs.queued_ms, output_bytes, blocks, flaky_passes and
368
+ // flaky_failures — the rest of what a results row shows, so `vx
369
+ // last --sql` reads it. The cache KEY is unchanged.
370
+ export const SCHEMA_VERSION = 'v51'
359
371
 
360
372
  /**
361
373
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -942,10 +954,21 @@ export class Cache implements CacheLayer {
942
954
  private adoptStats = 0
943
955
  /** The artifact directory's names, listed once past `ADOPT_STATS`; this process's writes kept in it. */
944
956
  private artifactNames: Set<string> | undefined
957
+ private heldBlobSizes: Array<[string, number]> | undefined
958
+ private heldTimer: ReturnType<typeof setTimeout> | undefined
959
+ /** Saves' index writes waiting for this turn's one transaction (`indexInBatch`). */
960
+ private indexQueue: Array<{ write: () => void; settle: (err?: unknown) => void }> = []
961
+ private heldRest: () => Array<[string, number]> = () => []
945
962
  private readonly insertEntryInput: ReturnType<Database['prepare']>
946
963
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
947
964
  private readonly files: FileHashStore
948
965
  private readonly outputs: OutputIndex
966
+ /**
967
+ * Each entry `save()` wrote: its files' bytes, summed as the rows went in,
968
+ * for the miss row's `outputs` (a read back was one statement a miss;
969
+ * principle 9). Taken once, by `outputBytes`.
970
+ */
971
+ private readonly savedBytes = new Map<string, number>()
949
972
  private readonly folds: IndexFoldMemo
950
973
  private readonly history: RunHistory
951
974
  /** What the remotes said they hold, across runs (remote-hits.ts). */
@@ -1235,12 +1258,13 @@ export class Cache implements CacheLayer {
1235
1258
  }
1236
1259
  }
1237
1260
  }
1238
- if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
1239
- // An index that held its entries itself would shadow the store's:
1240
- // SQLite resolves an unqualified name in `main` first. Its history
1241
- // and memos stay; its entries' artifact files are orphans `prune`
1242
- // reaps, and its inline artifacts go with their table.
1243
- const holds = readable(
1261
+ // An index that held its entries itself (no store yet: a repository
1262
+ // before its first commit) hands them to the store below.
1263
+ const holds =
1264
+ storeDir !== undefined &&
1265
+ mode === 'open' &&
1266
+ this.writeBlocked === null &&
1267
+ readable(
1244
1268
  () =>
1245
1269
  this.db
1246
1270
  .prepare(
@@ -1248,16 +1272,6 @@ export class Cache implements CacheLayer {
1248
1272
  )
1249
1273
  .get() != null,
1250
1274
  )
1251
- if (holds) {
1252
- this.db
1253
- .transaction(() => {
1254
- for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES]) {
1255
- this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
1256
- }
1257
- })
1258
- .immediate()
1259
- }
1260
- }
1261
1275
  this.storeDir = storeDir
1262
1276
  this.artifactDir = storeDir ?? cacheDir
1263
1277
  if (storeDir !== undefined) {
@@ -1271,6 +1285,7 @@ export class Cache implements CacheLayer {
1271
1285
  if (mode === 'open' && this.writeBlocked === null && (foreign || storeDir !== recorded)) {
1272
1286
  readable(() => this.matchArtifactLayout(storeDir === undefined ? 'main' : 'store'))
1273
1287
  }
1288
+ if (holds) this.carryEntries(cacheDir, storeDir!)
1274
1289
  if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
1275
1290
  if (storeDir === undefined)
1276
1291
  this.db.prepare("DELETE FROM schema_meta WHERE key = 'store_dir'").run()
@@ -1396,6 +1411,68 @@ export class Cache implements CacheLayer {
1396
1411
  }
1397
1412
  }
1398
1413
 
1414
+ /**
1415
+ * The entries an index held itself, moved into the store it now opens:
1416
+ * the same keys over the same bytes, so the first run after a
1417
+ * repository's first commit replays what the run before it saved. Then
1418
+ * dropped from the index, where an unqualified name would shadow the
1419
+ * store's. An artifact file that cannot be renamed there (another
1420
+ * device) is left for `prune`, and its entry is a miss.
1421
+ */
1422
+ private carryEntries(cacheDir: string, storeDir: string): void {
1423
+ const has = (schema: 'main' | 'store', table: string): boolean =>
1424
+ this.db
1425
+ .prepare(`SELECT 1 FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`)
1426
+ .get(table) != null
1427
+ const layout = (schema: 'main' | 'store'): string | undefined =>
1428
+ has(schema, 'artifacts_meta')
1429
+ ? (
1430
+ this.db
1431
+ .prepare(`SELECT value FROM ${schema}.artifacts_meta WHERE key = 'layout'`)
1432
+ .get() as { value: string } | null
1433
+ )?.value
1434
+ : undefined
1435
+ if (storeDir !== cacheDir) {
1436
+ let names: string[] = []
1437
+ try {
1438
+ names = readdirSync(cacheDir)
1439
+ } catch {
1440
+ // No directory: nothing to carry.
1441
+ }
1442
+ for (const name of names) {
1443
+ if (!name.endsWith('.tar.zst') && !name.endsWith('.tree')) continue
1444
+ const to = path.join(storeDir, name)
1445
+ if (existsSync(to)) continue
1446
+ try {
1447
+ renameSync(path.join(cacheDir, name), to)
1448
+ } catch {
1449
+ // Another device: the artifact stays an orphan here.
1450
+ }
1451
+ }
1452
+ }
1453
+ this.db
1454
+ .transaction(() => {
1455
+ // `entries` first: the other tables' rows reference it.
1456
+ for (const t of STORE_TABLES.toReversed()) {
1457
+ if (t !== 'store_meta' && has('main', t) && has('store', t)) {
1458
+ this.db.exec(`INSERT OR IGNORE INTO store.${t} SELECT * FROM main.${t}`)
1459
+ }
1460
+ }
1461
+ if (
1462
+ has('main', 'artifacts') &&
1463
+ has('store', 'artifacts') &&
1464
+ layout('main') === ARTIFACT_LAYOUT &&
1465
+ layout('store') === ARTIFACT_LAYOUT
1466
+ ) {
1467
+ this.db.exec('INSERT OR IGNORE INTO store.artifacts SELECT * FROM main.artifacts')
1468
+ }
1469
+ for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES]) {
1470
+ this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
1471
+ }
1472
+ })
1473
+ .immediate()
1474
+ }
1475
+
1399
1476
  /**
1400
1477
  * Attach the shared store as `store`, with the pragmas the index takes:
1401
1478
  * WAL for both, so a workspace's run and another's share the store as
@@ -1580,7 +1657,32 @@ export class Cache implements CacheLayer {
1580
1657
  }
1581
1658
  /** `BlobSizeMemo`: remember blob sizes, honouring the local WRITE axis. */
1582
1659
  rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
1583
- this.guard(() => this.files.rememberBlobSizes(sizes))
1660
+ if (this.heldBlobSizes !== undefined) for (const e of sizes) this.heldBlobSizes.push(e)
1661
+ else this.guard(() => this.files.rememberBlobSizes(sizes))
1662
+ }
1663
+ /**
1664
+ * Hold the blob sizes remembered from now on until `storeHeldBlobSizes`
1665
+ * or `close`: a run learns them before its first task, where storing
1666
+ * 40,030 cost a cold 9,603-task run 217 ms (2026-11-01).
1667
+ */
1668
+ holdBlobSizes(): void {
1669
+ this.heldBlobSizes ??= []
1670
+ }
1671
+ /**
1672
+ * Store the held blob sizes `HELD_SLICE` per event-loop turn from the
1673
+ * next turn on, while the run's tasks run; `close` stores the rest.
1674
+ */
1675
+ storeHeldBlobSizes(): void {
1676
+ const held = this.heldBlobSizes
1677
+ this.heldBlobSizes = undefined
1678
+ if (held === undefined || held.length === 0) return
1679
+ let at = 0
1680
+ const step = (): void => {
1681
+ this.guard(() => this.files.rememberBlobSizes(new Map(held.slice(at, (at += HELD_SLICE)))))
1682
+ this.heldTimer = at < held.length ? setTimeout(step, 0) : undefined
1683
+ }
1684
+ this.heldRest = () => held.slice(at)
1685
+ this.heldTimer = setTimeout(step, 0)
1584
1686
  }
1585
1687
  /** `BlobSizeMemo`: the paths an index with this digest distrusts, when known. */
1586
1688
  blobVerdict(digest: string): string[] | undefined {
@@ -1755,7 +1857,7 @@ export class Cache implements CacheLayer {
1755
1857
  }
1756
1858
 
1757
1859
  /** An adoption's verdict: an artifact that fails the checks is a miss. */
1758
- private async adopted(indexing: Promise<void>): Promise<boolean> {
1860
+ private async adopted(indexing: Promise<unknown>): Promise<boolean> {
1759
1861
  try {
1760
1862
  await indexing
1761
1863
  return true
@@ -1953,6 +2055,11 @@ export class Cache implements CacheLayer {
1953
2055
  outputsPath(hash: string): string {
1954
2056
  return this.tarPath(hash)
1955
2057
  }
2058
+ outputBytes(hash: string): number | undefined {
2059
+ const bytes = this.savedBytes.get(hash)
2060
+ this.savedBytes.delete(hash)
2061
+ return bytes
2062
+ }
1956
2063
  artifactSize(hash: string): number | undefined {
1957
2064
  return this.guard(
1958
2065
  () =>
@@ -2046,7 +2153,15 @@ export class Cache implements CacheLayer {
2046
2153
  * uploaded under a fresh sum.
2047
2154
  */
2048
2155
  private treePlan(hash: string, tree: string, rows: readonly OutputFileRow[]): ArtifactPlan {
2049
- const plan = planOfTree(tree, this.stdoutText(hash))
2156
+ let plan: ArtifactPlan
2157
+ try {
2158
+ plan = planOfTree(tree, this.stdoutText(hash))
2159
+ } catch (err) {
2160
+ // A sidecar cut short at rest, as a file is: named as the damage it is.
2161
+ if (err instanceof SyntaxError)
2162
+ throw new CorruptArtifactError(hash, 'its sidecar does not parse')
2163
+ throw err
2164
+ }
2050
2165
  const size = new Map(rows.map((r) => [treeName(r.path), r.size]))
2051
2166
  for (const i of plan.inputs) {
2052
2167
  const want = size.get(i.name)
@@ -2488,7 +2603,16 @@ export class Cache implements CacheLayer {
2488
2603
  if (!this.write) return
2489
2604
  // A cache that reads saves only on a miss. One that does not (`--force`)
2490
2605
  // re-executes a key it may hold: that entry is kept as it is.
2491
- if (!this.read && this.guard(() => this.storedHere(args.hash))) return
2606
+ // The kept entry's bytes are this run's too (same key): the row's
2607
+ // `outputs` counts them as a hit would.
2608
+ if (!this.read && this.guard(() => this.storedHere(args.hash))) {
2609
+ const rows = this.loadOutputFilesBatch([args.hash]).get(args.hash) ?? []
2610
+ this.savedBytes.set(
2611
+ args.hash,
2612
+ rows.reduce((n, r) => n + r.size, 0),
2613
+ )
2614
+ return
2615
+ }
2492
2616
  // The cache directory is the constructor's (`mkdirSync`, with a
2493
2617
  // refusal recorded for `assertWritable`); re-creating it here and
2494
2618
  // again in `writeArtifactAndIndex` cost every save two thread-pool
@@ -2505,7 +2629,7 @@ export class Cache implements CacheLayer {
2505
2629
  throw err
2506
2630
  })
2507
2631
  endPack()
2508
- await this.guard(() =>
2632
+ const bytes = await this.guard(() =>
2509
2633
  this.writeArtifactAndIndex(
2510
2634
  args.hash,
2511
2635
  'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
@@ -2518,6 +2642,7 @@ export class Cache implements CacheLayer {
2518
2642
  'tar' in packed ? packed.tar : undefined,
2519
2643
  ),
2520
2644
  )
2645
+ this.savedBytes.set(args.hash, bytes)
2521
2646
  }
2522
2647
 
2523
2648
  /**
@@ -2732,7 +2857,7 @@ export class Cache implements CacheLayer {
2732
2857
  if (plan.size > this.artifactCeiling) {
2733
2858
  throw new Error(
2734
2859
  `${args.entry.taskId} is not cached: its outputs pack to ${formatBytes(plan.size)}, past the ` +
2735
- `${formatBytes(this.artifactCeiling)} artifact ceiling a restore enforces — narrow cache.outputs.files`,
2860
+ `${formatBytes(this.artifactCeiling)} artifact ceiling a restore enforces — narrow outputs.files`,
2736
2861
  )
2737
2862
  }
2738
2863
  return plan
@@ -2827,7 +2952,7 @@ export class Cache implements CacheLayer {
2827
2952
  * scanned as is rather than decoded back out of the bytes just encoded.
2828
2953
  */
2829
2954
  tar?: Uint8Array,
2830
- ): Promise<void> {
2955
+ ): Promise<number> {
2831
2956
  // Validate BEFORE anything touches the final path. `ingest()` feeds
2832
2957
  // us a temp of network bytes; a truncated/garbage body that went live first
2833
2958
  // would leave a corrupt `<hash>.tar.zst` behind (with no SQL row,
@@ -3049,6 +3174,7 @@ export class Cache implements CacheLayer {
3049
3174
  const upsertStdout = this.upsertStdout
3050
3175
  const deleteStdout = this.deleteStdout
3051
3176
  const outputs = this.outputs
3177
+ let outputBytes = 0
3052
3178
  const insertEntryInput = this.insertEntryInput
3053
3179
  const inputComponents = meta.inputComponents
3054
3180
  // The artifact goes live INSIDE the write transaction, taken
@@ -3140,7 +3266,7 @@ export class Cache implements CacheLayer {
3140
3266
  hash,
3141
3267
  stdoutText.startsWith('\uFEFF') ? utf8.encode(stdoutText) : stdoutText,
3142
3268
  )
3143
- outputs.replaceFileRows(hash, outputFileRows)
3269
+ outputBytes = outputs.replaceFileRows(hash, outputFileRows)
3144
3270
  // INSERT OR IGNORE: identical inputs derive this same hash, so a
3145
3271
  // re-save's row is identical — keep the first, skip the rest.
3146
3272
  if (inputComponents !== undefined && inputComponents.length > 0) {
@@ -3152,7 +3278,7 @@ export class Cache implements CacheLayer {
3152
3278
  // Built inside the try: on a database a stopping run closed while a
3153
3279
  // download streamed in, building it throws, and the temp or tree
3154
3280
  // the body went to stayed behind.
3155
- this.db.transaction(index).immediate()
3281
+ await this.indexInBatch(index)
3156
3282
  } catch (err) {
3157
3283
  endTx()
3158
3284
  const left = renamed ?? tree ?? tmpPath
@@ -3162,6 +3288,57 @@ export class Cache implements CacheLayer {
3162
3288
  }
3163
3289
  endTx()
3164
3290
  for (const a of aside) await rm(a, { recursive: true, force: true })
3291
+ return outputBytes
3292
+ }
3293
+
3294
+ /**
3295
+ * Run `write` in a write transaction shared with every other save's that
3296
+ * arrives in this turn: settles once it has committed, or rejects with
3297
+ * its own error (its savepoint rolled back) or the commit's. A cold run's
3298
+ * saves land a few per turn, and a transaction each spent half its index
3299
+ * time on BEGIN and COMMIT on the main thread, the run's busiest
3300
+ * (2026-11-03: 81 of 164 ms over 500 saves).
3301
+ */
3302
+ private indexInBatch(write: () => void): Promise<void> {
3303
+ return new Promise((resolve, reject) => {
3304
+ this.indexQueue.push({
3305
+ write,
3306
+ settle: (err) => (err === undefined ? resolve() : reject(err)),
3307
+ })
3308
+ if (this.indexQueue.length === 1) setImmediate(() => this.flushIndex())
3309
+ })
3310
+ }
3311
+
3312
+ private flushIndex(): void {
3313
+ const batch = this.indexQueue
3314
+ if (batch.length === 0) return
3315
+ this.indexQueue = []
3316
+ const failed = new Map<number, unknown>()
3317
+ try {
3318
+ this.db
3319
+ .transaction(() => {
3320
+ for (let i = 0; i < batch.length; i++) {
3321
+ // An error SQLite answers by rolling the whole transaction
3322
+ // back (a full disk, a busy lock) leaves none to go on in: the
3323
+ // rest are not run, and fail with it.
3324
+ if (failed.size > 0 && !this.db.inTransaction) {
3325
+ failed.set(i, failed.values().next().value)
3326
+ continue
3327
+ }
3328
+ try {
3329
+ // Nested: a savepoint, so one write's failure undoes only its own rows.
3330
+ this.db.transaction(batch[i]!.write)()
3331
+ } catch (err) {
3332
+ failed.set(i, err)
3333
+ }
3334
+ }
3335
+ })
3336
+ .immediate()
3337
+ } catch (err) {
3338
+ for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i) ?? err)
3339
+ return
3340
+ }
3341
+ for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i))
3165
3342
  }
3166
3343
 
3167
3344
  private tempTreePath(hash: string): string {
@@ -3515,6 +3692,12 @@ export class Cache implements CacheLayer {
3515
3692
 
3516
3693
  close(): void {
3517
3694
  try {
3695
+ this.flushIndex()
3696
+ clearTimeout(this.heldTimer)
3697
+ const held = this.heldBlobSizes ?? (this.heldTimer !== undefined ? this.heldRest() : [])
3698
+ this.heldBlobSizes = undefined
3699
+ this.heldTimer = undefined
3700
+ if (held.length > 0) this.files.rememberBlobSizes(new Map(held))
3518
3701
  this.flushAccessed()
3519
3702
  this.outputs.flushOutputDirs()
3520
3703
  this.folds.flush()
@@ -3550,8 +3733,16 @@ export class Cache implements CacheLayer {
3550
3733
  }
3551
3734
  }
3552
3735
 
3553
- /** SQLite's file-control opcode (sqlite3.h). */
3554
- export const SQLITE_FCNTL_PERSIST_WAL = 10
3736
+ /**
3737
+ * The files a second connection writes a run's hits through
3738
+ * (history-stream.ts); undefined when the store keeps no history on disk.
3739
+ * A function, as `indexDb` is: no plugin writes the history.
3740
+ */
3741
+ export function historyFiles(cache: Cache): { index: string; store?: string } | undefined {
3742
+ if (cache['empty'] || cache['writeBlocked'] !== null || cache['inspecting']) return undefined
3743
+ if (cache.storeDir === undefined) return { index: cache['dbFile'] }
3744
+ return { index: cache['dbFile'], store: path.join(cache.storeDir, 'store.db') }
3745
+ }
3555
3746
 
3556
3747
  /**
3557
3748
  * The cache's `bun:sqlite` handle, for core's own readers (`vx last`, `vx
@@ -72,6 +72,9 @@ export class FileHashStore {
72
72
  /** Each of `oids` whose size a `rememberBlobSizes` stored: one `IN` query per 900. */
73
73
  knownBlobSizes(oids: readonly string[]): Map<string, number> {
74
74
  const out = new Map<string, number>()
75
+ // An empty memo (a cold cache) answers nothing: 40,030 lookups there
76
+ // cost a 9,603-task run 57 ms before its first task (2026-11-01).
77
+ if (this.db.query('SELECT 1 FROM blob_sizes LIMIT 1').get() === null) return out
75
78
  for (let i = 0; i < oids.length; i += 900) {
76
79
  const chunk = oids.slice(i, i + 900)
77
80
  const rows = this.db