@vzn/vx 0.0.649 → 0.0.651

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 (122) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +225 -51
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/fold-memo.ts +8 -1
  8. package/src/cache/git-inputs.ts +188 -39
  9. package/src/cache/index.ts +12 -0
  10. package/src/cache/inputs.ts +3 -0
  11. package/src/cache/layer.ts +11 -0
  12. package/src/cache/layered-cache.ts +4 -0
  13. package/src/cache/linked-inputs.ts +3 -0
  14. package/src/cache/lockfile-memo.ts +120 -0
  15. package/src/cache/output-index.ts +5 -2
  16. package/src/cache/remote-hits.ts +9 -1
  17. package/src/cache/remote-lane.ts +2 -1
  18. package/src/cache/run-history.ts +11 -1
  19. package/src/cache/run-notes.ts +123 -0
  20. package/src/cache/schema.ts +54 -1
  21. package/src/cli/cache.ts +26 -16
  22. package/src/cli/completions.ts +4 -2
  23. package/src/cli/docs.ts +10 -11
  24. package/src/cli/help.ts +12 -23
  25. package/src/cli/index.ts +4 -9
  26. package/src/cli/info.ts +15 -7
  27. package/src/cli/init.ts +23 -15
  28. package/src/cli/last.ts +224 -223
  29. package/src/cli/lock.ts +55 -5
  30. package/src/cli/plan-format.ts +100 -118
  31. package/src/cli/plugin-commands.ts +10 -3
  32. package/src/cli/plugin-templates.ts +2 -2
  33. package/src/cli/run.ts +46 -27
  34. package/src/cli/select.ts +43 -5
  35. package/src/cli/show.ts +38 -42
  36. package/src/cli/task-verb.ts +22 -0
  37. package/src/cli/upgrade.ts +3 -1
  38. package/src/cli/watch-fs.ts +6 -1
  39. package/src/cli/watch-judge.ts +2 -1
  40. package/src/cli/watch.ts +83 -44
  41. package/src/cli/why.ts +126 -92
  42. package/src/cli/workspace-config.ts +2 -1
  43. package/src/exec/executor.ts +2 -1
  44. package/src/exec/kill-tree.ts +58 -5
  45. package/src/exec/local-executor.ts +1 -1
  46. package/src/exec/pipes.ts +152 -0
  47. package/src/exec/runner.ts +199 -182
  48. package/src/exec/sandbox-binds.ts +1 -1
  49. package/src/exec/sandbox-runtime.ts +11 -2
  50. package/src/exec/spawn-off.ts +154 -0
  51. package/src/exec/warm-shell.ts +146 -0
  52. package/src/graph/critical-path.ts +40 -28
  53. package/src/graph/priorities.ts +98 -44
  54. package/src/graph/scheduler.ts +12 -2
  55. package/src/graph/task-graph.ts +27 -1
  56. package/src/handoff.ts +10 -7
  57. package/src/history/history.ts +39 -2
  58. package/src/history/index.ts +3 -0
  59. package/src/history/metrics.ts +9 -1
  60. package/src/index.ts +12 -3
  61. package/src/orchestrator/doctor.ts +3 -3
  62. package/src/orchestrator/execute-task.ts +16 -2
  63. package/src/orchestrator/graph-memo.ts +50 -1
  64. package/src/orchestrator/graph-stage.ts +6 -0
  65. package/src/orchestrator/hit-restore.ts +3 -0
  66. package/src/orchestrator/index.ts +3 -1
  67. package/src/orchestrator/live-usage.ts +98 -0
  68. package/src/orchestrator/locked-digests.ts +63 -0
  69. package/src/orchestrator/lockfile-claim.ts +176 -102
  70. package/src/orchestrator/miss-save.ts +31 -3
  71. package/src/orchestrator/persistent-task.ts +3 -1
  72. package/src/orchestrator/plan.ts +5 -1
  73. package/src/orchestrator/plugin-host.ts +61 -0
  74. package/src/orchestrator/plugin.ts +49 -4
  75. package/src/orchestrator/prepare.ts +18 -2
  76. package/src/orchestrator/projects.ts +4 -1
  77. package/src/orchestrator/run-context.ts +29 -10
  78. package/src/orchestrator/run-failures.ts +35 -114
  79. package/src/orchestrator/run-records.ts +10 -0
  80. package/src/orchestrator/run.ts +89 -26
  81. package/src/orchestrator/sandbox-request.ts +2 -2
  82. package/src/orchestrator/signals.ts +8 -5
  83. package/src/orchestrator/steps.ts +2 -2
  84. package/src/orchestrator/task-hash.ts +17 -3
  85. package/src/report/forecast.ts +12 -33
  86. package/src/report/framed-output.ts +388 -161
  87. package/src/report/index.ts +8 -2
  88. package/src/report/logger.ts +100 -36
  89. package/src/report/status-line.ts +81 -365
  90. package/src/report/summary.ts +104 -131
  91. package/src/ui/ansi.ts +48 -0
  92. package/src/ui/components.ts +506 -0
  93. package/src/ui/index.ts +79 -0
  94. package/src/ui/kit.ts +37 -0
  95. package/src/ui/live-region.ts +363 -0
  96. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  97. package/src/ui/terminal.ts +51 -0
  98. package/src/ui/theme.ts +224 -0
  99. package/src/ui/version-line.ts +28 -0
  100. package/src/util/config-frame.ts +65 -10
  101. package/src/util/edit-distance.ts +6 -1
  102. package/src/util/hidden-prompt.ts +8 -2
  103. package/src/util/index.ts +2 -15
  104. package/src/util/paths.ts +20 -2
  105. package/src/util/secret-mask.ts +39 -3
  106. package/src/util/secrets.ts +3 -0
  107. package/src/util/settle.ts +31 -0
  108. package/src/util/timing.ts +4 -16
  109. package/src/workspace/affected.ts +89 -30
  110. package/src/workspace/config-schema.ts +4 -2
  111. package/src/workspace/dependencies-lock.ts +191 -0
  112. package/src/workspace/filter.ts +28 -0
  113. package/src/workspace/index.ts +9 -0
  114. package/src/workspace/lockfile.ts +159 -6
  115. package/src/workspace/migration.ts +4 -3
  116. package/src/workspace/workspace.ts +3 -0
  117. package/src/orchestrator/run-steps.ts +0 -93
  118. package/src/util/colors.ts +0 -130
  119. package/src/util/report.ts +0 -44
  120. package/src/util/ui.ts +0 -258
  121. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  122. /package/src/{report → ui}/terminal-signals.ts +0 -0
package/README.md CHANGED
@@ -142,9 +142,8 @@ not a way to run the repo. The benchmark below uses native vx config.
142
142
  | Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) |
143
143
  | Install | **Single binary**; no Bun, Node optional | npm + Node | npm + Node |
144
144
 
145
- Every gap, and when another tool is the better pick:
146
- [comparison](packages/vx/docs/comparison.md) ·
147
- [vx, Turborepo, Nx, Bazel](https://vznjs.github.io/vx/compare/).
145
+ Every gap: [comparison](packages/vx/docs/comparison.md) ·
146
+ every tool on one suite of cases: [conformance](https://vznjs.github.io/vx/conformance/).
148
147
 
149
148
  ## Fast, without trading correctness
150
149
 
@@ -152,23 +151,9 @@ Every gap, and when another tool is the better pick:
152
151
  - **No source-file reads to hash:** on a clean tree, keys come from git's index; each project's `package.json` and the lockfile are still read.
153
152
  - **One pass:** one git walk per run; each stat and lookup happens once.
154
153
 
155
- <!-- bench:start — generated by private/vx-bench/update-site.ts from results.json; do not hand-edit -->
156
-
157
- | Time added over the ideal run, 1,601 projects, 9,603 tasks | vx | Turborepo | Nx | Vite Task |
158
- | ---------------------------------------------------------- | ---------- | ---------- | ---------- | --------- |
159
- | Nothing changed | **799 ms** | 927 ms | 24.15 s | 12.07 s |
160
- | Nothing changed, outputs restored | **1.28 s** | 2.01 s | 24.45 s | 11.86 s |
161
- | Cold build | **7.57 s** | 9.63 s | 3 min 6 s | 9.01 s |
162
- | Cold build: CPU the runner burns | 36.64 s | 1 min 12 s | 5 min 34 s | 25.32 s |
163
-
164
- Time each tool adds over the ideal run; lower is better.
165
-
166
- Benchmark workload: a synthetic monorepo of 1,601 projects and 9,603 tasks in 30 dependency levels, five core libraries a quarter of the projects use; build 300 ms, test and typecheck 150 ms, lint 75 ms, publish 30 ms; real repos with uneven task times will differ.
167
- Run 2026-10-25 on linux x64, 4 cores: vx at commit 9abdfb52c, Turborepo 2.11.10, Nx 24.0.1, Vite Task (vite-plus) 1.4.0.
168
-
169
- Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
170
-
171
- <!-- bench:end -->
154
+ The head-to-head against Turborepo, Nx and Vite Task, each in its own
155
+ native config, is re-measured on every change to vx and published on the
156
+ site: [benchmarks](https://vznjs.github.io/vx/benchmarks/).
172
157
 
173
158
  ## Status
174
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.649",
3
+ "version": "0.0.651",
4
4
  "description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
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.649",
25
- "@vzn/vx-linux-arm64": "0.0.649",
26
- "@vzn/vx-darwin-x64": "0.0.649",
27
- "@vzn/vx-darwin-arm64": "0.0.649"
24
+ "@vzn/vx-linux-x64": "0.0.651",
25
+ "@vzn/vx-linux-arm64": "0.0.651",
26
+ "@vzn/vx-darwin-x64": "0.0.651",
27
+ "@vzn/vx-darwin-arm64": "0.0.651"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/schemas/last.json CHANGED
@@ -242,7 +242,10 @@
242
242
  "peakRssBytes",
243
243
  "peakCores",
244
244
  "avgRssBytes",
245
+ "queuedMs",
245
246
  "placement",
247
+ "outputBytes",
248
+ "blocks",
246
249
  "wallclockStartNs",
247
250
  "wallclockEndNs",
248
251
  "blockedBy",
@@ -288,10 +291,22 @@
288
291
  "type": ["integer", "null"],
289
292
  "description": "mean resident memory over the samples; null when nothing sampled"
290
293
  },
294
+ "queuedMs": {
295
+ "type": ["integer", "null"],
296
+ "description": "milliseconds it waited, ready, for a slot; null when it never queued"
297
+ },
291
298
  "placement": {
292
299
  "type": ["string", "null"],
293
300
  "description": "where an executor ran it (small#1); null when here"
294
301
  },
302
+ "outputBytes": {
303
+ "type": ["integer", "null"],
304
+ "description": "bytes of its outputs: what its save stored, or its hit restored"
305
+ },
306
+ "blocks": {
307
+ "type": ["integer", "null"],
308
+ "description": "how many tasks were downstream of it"
309
+ },
295
310
  "wallclockStartNs": {
296
311
  "type": ["string", "null"],
297
312
  "description": "decimal nanoseconds from the run's start"
package/src/bin.ts CHANGED
@@ -21,32 +21,14 @@ async function main(): Promise<void> {
21
21
  // `vx --version` needs one leaf module; the dispatcher and the util
22
22
  // barrel it pulled in were 20 ms of its 28 (2026-10-01).
23
23
  if (argv.length === 1 && (argv[0] === '--version' || argv[0] === 'version')) {
24
- // `Bun.stdout`, not `process.stdout`: the Node stream's first touch is
25
- // ~7 ms, half of this verb (2026-10-02). A reader gone (EPIPE) is no
26
- // failure of the line.
27
24
  // Handed off, it says which vx ran and which one handed it the call.
28
25
  const from = process.env['VX_HANDED_OFF'] || undefined
29
26
  const where = from === undefined ? '' : ` (${vxPath()}, handed off from ${from})`
30
- // Painted as every other line; piped, the same words a script reads.
31
- // Decided here, not by util's `detectColors`: the util barrel was the
32
- // 20 ms above, and `node:tty` 17 ms; fd 1 as a character device is the
33
- // TTY answer. A CI's log is not painted for this one line.
34
- const [{ VERSION }, { fstatSync }] = await Promise.all([
27
+ const [{ VERSION }, { printVersion }] = await Promise.all([
35
28
  import('./version.js'),
36
- import('node:fs'),
29
+ import('./ui/version-line.js'),
37
30
  ])
38
- const env = process.env
39
- const on = env['NO_COLOR']
40
- ? false
41
- : env['FORCE_COLOR'] !== undefined
42
- ? env['FORCE_COLOR'] !== '0' && env['FORCE_COLOR'] !== 'false'
43
- : env['TERM'] !== 'dumb' && fstatSync(1).isCharacterDevice()
44
- const sgr = (code: string, text: string): string =>
45
- on ? `\x1b[${code}m${text}\x1b[0m` : text
46
- await Bun.write(
47
- Bun.stdout,
48
- `${sgr('1', 'vx')} ${sgr('38;2;6;182;212', VERSION)}${where === '' ? '' : sgr('2', where)}\n`,
49
- ).catch(() => {})
31
+ await printVersion(VERSION, where)
50
32
  settled = true
51
33
  return
52
34
  }
@@ -64,8 +46,9 @@ async function main(): Promise<void> {
64
46
  // is set, and `exitOnceFlushed` exits after both streams have ended.
65
47
  process.exitCode = code
66
48
  } catch (err) {
49
+ const ui = await import('./ui/index.js')
50
+ const { refusalNotice } = ui
67
51
  const {
68
- detectColors,
69
52
  errorCode,
70
53
  errorDocument,
71
54
  fsRefusalHint,
@@ -74,17 +57,12 @@ async function main(): Promise<void> {
74
57
  isUserError,
75
58
  maskedLine,
76
59
  OUT_OF_FDS_HINT,
77
- refusalNotice,
78
- ui,
79
60
  wantsJson,
80
61
  } = await import('./util/index.js')
81
62
  // A plugin's failure quotes what it was told (a remote's reply, a
82
63
  // header), and a config's own throw is its author's text: a secret in
83
64
  // either reached stderr whole (L-11).
84
- const say = (line: string): void =>
85
- void process.stderr.write(
86
- `${ui.tagged('error', maskedLine(line), detectColors(process.stderr))}\n`,
87
- )
65
+ const say = (line: string): void => ui.say('error', maskedLine(line))
88
66
  // UserError (workspace not found, cycle, config invalid, ...) —
89
67
  // print the message only; the stack is noise the user can't act
90
68
  // on. Everything else gets the full stack so internal bugs are
@@ -100,7 +78,7 @@ async function main(): Promise<void> {
100
78
  named === null ? err.message : err.message.slice(named[0].length),
101
79
  err.parts,
102
80
  )
103
- process.stderr.write(maskedLine(ui.render([block], detectColors(process.stderr))))
81
+ ui.data('err', maskedLine(ui.render([block], ui.colorsOf('err'))))
104
82
  } else if (isOutOfFds(err)) {
105
83
  say(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
106
84
  } else if (isFsRefusal(err)) {
@@ -114,7 +92,7 @@ async function main(): Promise<void> {
114
92
  // stdout got nothing to parse, and only prose on stderr to branch on.
115
93
  if (wantsJson(argv)) {
116
94
  const message = err instanceof Error ? err.message : String(err)
117
- process.stdout.write(errorDocument(errorCode(err), maskedLine(message)))
95
+ ui.data('out', errorDocument(errorCode(err), maskedLine(message)))
118
96
  }
119
97
  // Same reason as the success path above: a large stderr is truncated by
120
98
  // `process.exit` too, and an error message cut in half is the one a
@@ -174,7 +152,7 @@ function withCauses(err: unknown): string {
174
152
  process.on('beforeExit', () => {
175
153
  if (settled) return
176
154
  settled = true
177
- ;(require('./util/index.js') as typeof import('./util/index.js')).ui.say(
155
+ ;(require('./ui/index.js') as typeof import('./ui/index.js')).say(
178
156
  'error',
179
157
  'vx: the run stopped before it finished: something it awaited can never settle (a plugin hook?)',
180
158
  )
@@ -117,8 +117,8 @@ import { FileHashStore } from './file-hashes.js'
117
117
  import { DIR_MODE, LINK_MODE, OutputIndex } from './output-index.js'
118
118
  import { RunHistory } from './run-history.js'
119
119
  import { CACHE_VERSION, foldKey } from './key-fold.js'
120
- import { FoldMemo } from './fold-memo.js'
121
- import { RemoteHits } from './remote-hits.js'
120
+ import { type FoldMemo, IndexFoldMemo } from './fold-memo.js'
121
+ import { IndexRemoteHits, type RemoteHits } from './remote-hits.js'
122
122
  import { behindLink } from './linked-inputs.js'
123
123
 
124
124
  /**
@@ -130,6 +130,9 @@ import { behindLink } from './linked-inputs.js'
130
130
  * this, so the file time trails the last use by at most this much, and
131
131
  * the policy reads the file time plus this as the last use.
132
132
  */
133
+ // About 1.3 ms of inserts per turn: a task's exit waits at most that.
134
+ const HELD_SLICE = 250
135
+
133
136
  const ORPHAN_GRACE_MS = 60 * 60 * 1000
134
137
 
135
138
  /**
@@ -164,13 +167,8 @@ const VX_CACHE_NAMES = new Set([
164
167
  'cache.db-wal',
165
168
  'cache.db-shm',
166
169
  'cache.db-journal',
167
- // run-failures.ts, run-steps.ts, orchestrator/lockfile-claim.ts. Run
168
- // history lives in cache.db and the config bundle always in
169
- // `.vx/cache/compiled`, so a `runs` or `compiled` here is the user's.
170
- 'failures',
171
- 'steps',
172
- 'warnings',
173
- 'lockfile-claims',
170
+ // Every record vx keeps lives in cache.db, and the config bundle always
171
+ // in `.vx/cache/compiled`, so any directory here is the user's.
174
172
  ])
175
173
 
176
174
  /**
@@ -357,7 +355,13 @@ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
357
355
  // v49: runs.cpu_share — the share of a core each runnable thread got
358
356
  // where the task ran, so a crowded run's CPU reads as need. The
359
357
  // cache KEY is unchanged.
360
- export const SCHEMA_VERSION = 'v49'
358
+ // v50: steps, warnings, failures and lockfile_memos — what a run said
359
+ // and a lockfile claimant parsed, files under the cache dir until
360
+ // now (if it can be in the DB, it is). The cache KEY is unchanged.
361
+ // v51: runs.queued_ms, output_bytes, blocks, flaky_passes and
362
+ // flaky_failures — the rest of what a results row shows, so `vx
363
+ // last --sql` reads it. The cache KEY is unchanged.
364
+ export const SCHEMA_VERSION = 'v51'
361
365
 
362
366
  /**
363
367
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -944,11 +948,22 @@ export class Cache implements CacheLayer {
944
948
  private adoptStats = 0
945
949
  /** The artifact directory's names, listed once past `ADOPT_STATS`; this process's writes kept in it. */
946
950
  private artifactNames: Set<string> | undefined
951
+ private heldBlobSizes: Array<[string, number]> | undefined
952
+ private heldTimer: ReturnType<typeof setTimeout> | undefined
953
+ /** Saves' index writes waiting for this turn's one transaction (`indexInBatch`). */
954
+ private indexQueue: Array<{ write: () => void; settle: (err?: unknown) => void }> = []
955
+ private heldRest: () => Array<[string, number]> = () => []
947
956
  private readonly insertEntryInput: ReturnType<Database['prepare']>
948
957
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
949
958
  private readonly files: FileHashStore
950
959
  private readonly outputs: OutputIndex
951
- private readonly folds: FoldMemo
960
+ /**
961
+ * Each entry `save()` wrote: its files' bytes, summed as the rows went in,
962
+ * for the miss row's `outputs` (a read back was one statement a miss;
963
+ * principle 9). Taken once, by `outputBytes`.
964
+ */
965
+ private readonly savedBytes = new Map<string, number>()
966
+ private readonly folds: IndexFoldMemo
952
967
  private readonly history: RunHistory
953
968
  /** What the remotes said they hold, across runs (remote-hits.ts). */
954
969
  readonly remoteHits: RemoteHits
@@ -1172,7 +1187,7 @@ export class Cache implements CacheLayer {
1172
1187
  // keeps it so).
1173
1188
  if (found !== undefined) {
1174
1189
  this.db.exec(
1175
- 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits;',
1190
+ 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits; DROP TABLE IF EXISTS main.steps; DROP TABLE IF EXISTS main.warnings; DROP TABLE IF EXISTS main.failures; DROP TABLE IF EXISTS main.lockfile_memos;',
1176
1191
  )
1177
1192
  }
1178
1193
  // The tables land under the same write lock as the stamp: made
@@ -1237,12 +1252,13 @@ export class Cache implements CacheLayer {
1237
1252
  }
1238
1253
  }
1239
1254
  }
1240
- if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
1241
- // An index that held its entries itself would shadow the store's:
1242
- // SQLite resolves an unqualified name in `main` first. Its history
1243
- // and memos stay; its entries' artifact files are orphans `prune`
1244
- // reaps, and its inline artifacts go with their table.
1245
- const holds = readable(
1255
+ // An index that held its entries itself (no store yet: a repository
1256
+ // before its first commit) hands them to the store below.
1257
+ const holds =
1258
+ storeDir !== undefined &&
1259
+ mode === 'open' &&
1260
+ this.writeBlocked === null &&
1261
+ readable(
1246
1262
  () =>
1247
1263
  this.db
1248
1264
  .prepare(
@@ -1250,16 +1266,6 @@ export class Cache implements CacheLayer {
1250
1266
  )
1251
1267
  .get() != null,
1252
1268
  )
1253
- if (holds) {
1254
- this.db
1255
- .transaction(() => {
1256
- for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES]) {
1257
- this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
1258
- }
1259
- })
1260
- .immediate()
1261
- }
1262
- }
1263
1269
  this.storeDir = storeDir
1264
1270
  this.artifactDir = storeDir ?? cacheDir
1265
1271
  if (storeDir !== undefined) {
@@ -1273,6 +1279,7 @@ export class Cache implements CacheLayer {
1273
1279
  if (mode === 'open' && this.writeBlocked === null && (foreign || storeDir !== recorded)) {
1274
1280
  readable(() => this.matchArtifactLayout(storeDir === undefined ? 'main' : 'store'))
1275
1281
  }
1282
+ if (holds) this.carryEntries(cacheDir, storeDir!)
1276
1283
  if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
1277
1284
  if (storeDir === undefined)
1278
1285
  this.db.prepare("DELETE FROM schema_meta WHERE key = 'store_dir'").run()
@@ -1336,9 +1343,9 @@ export class Cache implements CacheLayer {
1336
1343
  // the schema above is the one place every table is declared.
1337
1344
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
1338
1345
  this.outputs = new OutputIndex(this.db, this.writeBlocked === null && !this.inspecting)
1339
- this.folds = new FoldMemo(this.db, this.write && !this.inspecting)
1346
+ this.folds = new IndexFoldMemo(this.db, this.write && !this.inspecting)
1340
1347
  this.history = new RunHistory(this.db, (v) => this.digestValue(v))
1341
- this.remoteHits = new RemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
1348
+ this.remoteHits = new IndexRemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
1342
1349
 
1343
1350
  // A CACHE_VERSION bump keeps the index but moves every key, so the run
1344
1351
  // after an upgrade misses everything. Roadmap 3.3: that is announced,
@@ -1398,6 +1405,68 @@ export class Cache implements CacheLayer {
1398
1405
  }
1399
1406
  }
1400
1407
 
1408
+ /**
1409
+ * The entries an index held itself, moved into the store it now opens:
1410
+ * the same keys over the same bytes, so the first run after a
1411
+ * repository's first commit replays what the run before it saved. Then
1412
+ * dropped from the index, where an unqualified name would shadow the
1413
+ * store's. An artifact file that cannot be renamed there (another
1414
+ * device) is left for `prune`, and its entry is a miss.
1415
+ */
1416
+ private carryEntries(cacheDir: string, storeDir: string): void {
1417
+ const has = (schema: 'main' | 'store', table: string): boolean =>
1418
+ this.db
1419
+ .prepare(`SELECT 1 FROM ${schema}.sqlite_master WHERE type = 'table' AND name = ?`)
1420
+ .get(table) != null
1421
+ const layout = (schema: 'main' | 'store'): string | undefined =>
1422
+ has(schema, 'artifacts_meta')
1423
+ ? (
1424
+ this.db
1425
+ .prepare(`SELECT value FROM ${schema}.artifacts_meta WHERE key = 'layout'`)
1426
+ .get() as { value: string } | null
1427
+ )?.value
1428
+ : undefined
1429
+ if (storeDir !== cacheDir) {
1430
+ let names: string[] = []
1431
+ try {
1432
+ names = readdirSync(cacheDir)
1433
+ } catch {
1434
+ // No directory: nothing to carry.
1435
+ }
1436
+ for (const name of names) {
1437
+ if (!name.endsWith('.tar.zst') && !name.endsWith('.tree')) continue
1438
+ const to = path.join(storeDir, name)
1439
+ if (existsSync(to)) continue
1440
+ try {
1441
+ renameSync(path.join(cacheDir, name), to)
1442
+ } catch {
1443
+ // Another device: the artifact stays an orphan here.
1444
+ }
1445
+ }
1446
+ }
1447
+ this.db
1448
+ .transaction(() => {
1449
+ // `entries` first: the other tables' rows reference it.
1450
+ for (const t of STORE_TABLES.toReversed()) {
1451
+ if (t !== 'store_meta' && has('main', t) && has('store', t)) {
1452
+ this.db.exec(`INSERT OR IGNORE INTO store.${t} SELECT * FROM main.${t}`)
1453
+ }
1454
+ }
1455
+ if (
1456
+ has('main', 'artifacts') &&
1457
+ has('store', 'artifacts') &&
1458
+ layout('main') === ARTIFACT_LAYOUT &&
1459
+ layout('store') === ARTIFACT_LAYOUT
1460
+ ) {
1461
+ this.db.exec('INSERT OR IGNORE INTO store.artifacts SELECT * FROM main.artifacts')
1462
+ }
1463
+ for (const t of [...STORE_TABLES, ...ARTIFACT_TABLES]) {
1464
+ this.db.exec(`DROP TABLE IF EXISTS main.${t}`)
1465
+ }
1466
+ })
1467
+ .immediate()
1468
+ }
1469
+
1401
1470
  /**
1402
1471
  * Attach the shared store as `store`, with the pragmas the index takes:
1403
1472
  * WAL for both, so a workspace's run and another's share the store as
@@ -1582,7 +1651,32 @@ export class Cache implements CacheLayer {
1582
1651
  }
1583
1652
  /** `BlobSizeMemo`: remember blob sizes, honouring the local WRITE axis. */
1584
1653
  rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
1585
- this.guard(() => this.files.rememberBlobSizes(sizes))
1654
+ if (this.heldBlobSizes !== undefined) for (const e of sizes) this.heldBlobSizes.push(e)
1655
+ else this.guard(() => this.files.rememberBlobSizes(sizes))
1656
+ }
1657
+ /**
1658
+ * Hold the blob sizes remembered from now on until `storeHeldBlobSizes`
1659
+ * or `close`: a run learns them before its first task, where storing
1660
+ * 40,030 cost a cold 9,603-task run 217 ms (2026-11-01).
1661
+ */
1662
+ holdBlobSizes(): void {
1663
+ this.heldBlobSizes ??= []
1664
+ }
1665
+ /**
1666
+ * Store the held blob sizes `HELD_SLICE` per event-loop turn from the
1667
+ * next turn on, while the run's tasks run; `close` stores the rest.
1668
+ */
1669
+ storeHeldBlobSizes(): void {
1670
+ const held = this.heldBlobSizes
1671
+ this.heldBlobSizes = undefined
1672
+ if (held === undefined || held.length === 0) return
1673
+ let at = 0
1674
+ const step = (): void => {
1675
+ this.guard(() => this.files.rememberBlobSizes(new Map(held.slice(at, (at += HELD_SLICE)))))
1676
+ this.heldTimer = at < held.length ? setTimeout(step, 0) : undefined
1677
+ }
1678
+ this.heldRest = () => held.slice(at)
1679
+ this.heldTimer = setTimeout(step, 0)
1586
1680
  }
1587
1681
  /** `BlobSizeMemo`: the paths an index with this digest distrusts, when known. */
1588
1682
  blobVerdict(digest: string): string[] | undefined {
@@ -1757,7 +1851,7 @@ export class Cache implements CacheLayer {
1757
1851
  }
1758
1852
 
1759
1853
  /** An adoption's verdict: an artifact that fails the checks is a miss. */
1760
- private async adopted(indexing: Promise<void>): Promise<boolean> {
1854
+ private async adopted(indexing: Promise<unknown>): Promise<boolean> {
1761
1855
  try {
1762
1856
  await indexing
1763
1857
  return true
@@ -1955,6 +2049,11 @@ export class Cache implements CacheLayer {
1955
2049
  outputsPath(hash: string): string {
1956
2050
  return this.tarPath(hash)
1957
2051
  }
2052
+ outputBytes(hash: string): number | undefined {
2053
+ const bytes = this.savedBytes.get(hash)
2054
+ this.savedBytes.delete(hash)
2055
+ return bytes
2056
+ }
1958
2057
  artifactSize(hash: string): number | undefined {
1959
2058
  return this.guard(
1960
2059
  () =>
@@ -2048,7 +2147,15 @@ export class Cache implements CacheLayer {
2048
2147
  * uploaded under a fresh sum.
2049
2148
  */
2050
2149
  private treePlan(hash: string, tree: string, rows: readonly OutputFileRow[]): ArtifactPlan {
2051
- const plan = planOfTree(tree, this.stdoutText(hash))
2150
+ let plan: ArtifactPlan
2151
+ try {
2152
+ plan = planOfTree(tree, this.stdoutText(hash))
2153
+ } catch (err) {
2154
+ // A sidecar cut short at rest, as a file is: named as the damage it is.
2155
+ if (err instanceof SyntaxError)
2156
+ throw new CorruptArtifactError(hash, 'its sidecar does not parse')
2157
+ throw err
2158
+ }
2052
2159
  const size = new Map(rows.map((r) => [treeName(r.path), r.size]))
2053
2160
  for (const i of plan.inputs) {
2054
2161
  const want = size.get(i.name)
@@ -2490,7 +2597,16 @@ export class Cache implements CacheLayer {
2490
2597
  if (!this.write) return
2491
2598
  // A cache that reads saves only on a miss. One that does not (`--force`)
2492
2599
  // re-executes a key it may hold: that entry is kept as it is.
2493
- if (!this.read && this.guard(() => this.storedHere(args.hash))) return
2600
+ // The kept entry's bytes are this run's too (same key): the row's
2601
+ // `outputs` counts them as a hit would.
2602
+ if (!this.read && this.guard(() => this.storedHere(args.hash))) {
2603
+ const rows = this.loadOutputFilesBatch([args.hash]).get(args.hash) ?? []
2604
+ this.savedBytes.set(
2605
+ args.hash,
2606
+ rows.reduce((n, r) => n + r.size, 0),
2607
+ )
2608
+ return
2609
+ }
2494
2610
  // The cache directory is the constructor's (`mkdirSync`, with a
2495
2611
  // refusal recorded for `assertWritable`); re-creating it here and
2496
2612
  // again in `writeArtifactAndIndex` cost every save two thread-pool
@@ -2507,7 +2623,7 @@ export class Cache implements CacheLayer {
2507
2623
  throw err
2508
2624
  })
2509
2625
  endPack()
2510
- await this.guard(() =>
2626
+ const bytes = await this.guard(() =>
2511
2627
  this.writeArtifactAndIndex(
2512
2628
  args.hash,
2513
2629
  'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
@@ -2520,6 +2636,7 @@ export class Cache implements CacheLayer {
2520
2636
  'tar' in packed ? packed.tar : undefined,
2521
2637
  ),
2522
2638
  )
2639
+ this.savedBytes.set(args.hash, bytes)
2523
2640
  }
2524
2641
 
2525
2642
  /**
@@ -2829,7 +2946,7 @@ export class Cache implements CacheLayer {
2829
2946
  * scanned as is rather than decoded back out of the bytes just encoded.
2830
2947
  */
2831
2948
  tar?: Uint8Array,
2832
- ): Promise<void> {
2949
+ ): Promise<number> {
2833
2950
  // Validate BEFORE anything touches the final path. `ingest()` feeds
2834
2951
  // us a temp of network bytes; a truncated/garbage body that went live first
2835
2952
  // would leave a corrupt `<hash>.tar.zst` behind (with no SQL row,
@@ -3051,6 +3168,7 @@ export class Cache implements CacheLayer {
3051
3168
  const upsertStdout = this.upsertStdout
3052
3169
  const deleteStdout = this.deleteStdout
3053
3170
  const outputs = this.outputs
3171
+ let outputBytes = 0
3054
3172
  const insertEntryInput = this.insertEntryInput
3055
3173
  const inputComponents = meta.inputComponents
3056
3174
  // The artifact goes live INSIDE the write transaction, taken
@@ -3142,7 +3260,7 @@ export class Cache implements CacheLayer {
3142
3260
  hash,
3143
3261
  stdoutText.startsWith('\uFEFF') ? utf8.encode(stdoutText) : stdoutText,
3144
3262
  )
3145
- outputs.replaceFileRows(hash, outputFileRows)
3263
+ outputBytes = outputs.replaceFileRows(hash, outputFileRows)
3146
3264
  // INSERT OR IGNORE: identical inputs derive this same hash, so a
3147
3265
  // re-save's row is identical — keep the first, skip the rest.
3148
3266
  if (inputComponents !== undefined && inputComponents.length > 0) {
@@ -3154,7 +3272,7 @@ export class Cache implements CacheLayer {
3154
3272
  // Built inside the try: on a database a stopping run closed while a
3155
3273
  // download streamed in, building it throws, and the temp or tree
3156
3274
  // the body went to stayed behind.
3157
- this.db.transaction(index).immediate()
3275
+ await this.indexInBatch(index)
3158
3276
  } catch (err) {
3159
3277
  endTx()
3160
3278
  const left = renamed ?? tree ?? tmpPath
@@ -3164,6 +3282,57 @@ export class Cache implements CacheLayer {
3164
3282
  }
3165
3283
  endTx()
3166
3284
  for (const a of aside) await rm(a, { recursive: true, force: true })
3285
+ return outputBytes
3286
+ }
3287
+
3288
+ /**
3289
+ * Run `write` in a write transaction shared with every other save's that
3290
+ * arrives in this turn: settles once it has committed, or rejects with
3291
+ * its own error (its savepoint rolled back) or the commit's. A cold run's
3292
+ * saves land a few per turn, and a transaction each spent half its index
3293
+ * time on BEGIN and COMMIT on the main thread, the run's busiest
3294
+ * (2026-11-03: 81 of 164 ms over 500 saves).
3295
+ */
3296
+ private indexInBatch(write: () => void): Promise<void> {
3297
+ return new Promise((resolve, reject) => {
3298
+ this.indexQueue.push({
3299
+ write,
3300
+ settle: (err) => (err === undefined ? resolve() : reject(err)),
3301
+ })
3302
+ if (this.indexQueue.length === 1) setImmediate(() => this.flushIndex())
3303
+ })
3304
+ }
3305
+
3306
+ private flushIndex(): void {
3307
+ const batch = this.indexQueue
3308
+ if (batch.length === 0) return
3309
+ this.indexQueue = []
3310
+ const failed = new Map<number, unknown>()
3311
+ try {
3312
+ this.db
3313
+ .transaction(() => {
3314
+ for (let i = 0; i < batch.length; i++) {
3315
+ // An error SQLite answers by rolling the whole transaction
3316
+ // back (a full disk, a busy lock) leaves none to go on in: the
3317
+ // rest are not run, and fail with it.
3318
+ if (failed.size > 0 && !this.db.inTransaction) {
3319
+ failed.set(i, failed.values().next().value)
3320
+ continue
3321
+ }
3322
+ try {
3323
+ // Nested: a savepoint, so one write's failure undoes only its own rows.
3324
+ this.db.transaction(batch[i]!.write)()
3325
+ } catch (err) {
3326
+ failed.set(i, err)
3327
+ }
3328
+ }
3329
+ })
3330
+ .immediate()
3331
+ } catch (err) {
3332
+ for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i) ?? err)
3333
+ return
3334
+ }
3335
+ for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i))
3167
3336
  }
3168
3337
 
3169
3338
  private tempTreePath(hash: string): string {
@@ -3210,17 +3379,6 @@ export class Cache implements CacheLayer {
3210
3379
  this.db.prepare(`UPDATE entries SET accessed_at = ? WHERE hash ${test}`).run(now, ...params)
3211
3380
  }
3212
3381
 
3213
- /**
3214
- * Raw `bun:sqlite` Database handle. Exposed for subsystems that
3215
- * need to issue their own queries (LocalHistoryProvider's CTE,
3216
- * future analytics consumers) without us proxying every method.
3217
- * Callers must NOT close the handle directly — `Cache.close()` owns
3218
- * the lifetime.
3219
- */
3220
- dbHandle(): Database {
3221
- return this.db
3222
- }
3223
-
3224
3382
  // --- run history: delegated to `RunHistory` (see run-history.ts) ---
3225
3383
  // A run over a directory it cannot write records no history:
3226
3384
  // `readOnlyNotice` said so once.
@@ -3528,6 +3686,12 @@ export class Cache implements CacheLayer {
3528
3686
 
3529
3687
  close(): void {
3530
3688
  try {
3689
+ this.flushIndex()
3690
+ clearTimeout(this.heldTimer)
3691
+ const held = this.heldBlobSizes ?? (this.heldTimer !== undefined ? this.heldRest() : [])
3692
+ this.heldBlobSizes = undefined
3693
+ this.heldTimer = undefined
3694
+ if (held.length > 0) this.files.rememberBlobSizes(new Map(held))
3531
3695
  this.flushAccessed()
3532
3696
  this.outputs.flushOutputDirs()
3533
3697
  this.folds.flush()
@@ -3564,7 +3728,17 @@ export class Cache implements CacheLayer {
3564
3728
  }
3565
3729
 
3566
3730
  /** SQLite's file-control opcode (sqlite3.h). */
3567
- const SQLITE_FCNTL_PERSIST_WAL = 10
3731
+ export const SQLITE_FCNTL_PERSIST_WAL = 10
3732
+
3733
+ /**
3734
+ * The cache's `bun:sqlite` handle, for core's own readers (`vx last`, `vx
3735
+ * why`, the history provider). A function, not a member: the façade hands
3736
+ * `Cache` to plugins and no plugin touches cache.db (2026-10-29). Callers
3737
+ * never close it — `Cache.close()` owns the lifetime.
3738
+ */
3739
+ export function indexDb(cache: Cache): Database {
3740
+ return cache['db']
3741
+ }
3568
3742
 
3569
3743
  /**
3570
3744
  * Close for real. A plain `close()` leaves the connection open while any
@@ -3577,7 +3751,7 @@ const SQLITE_FCNTL_PERSIST_WAL = 10
3577
3751
  * `vx run` exited 1 with that stack after its tasks had passed (M-44). There
3578
3752
  * the deferred close is the one that closes.
3579
3753
  */
3580
- function closeDb(db: Database): void {
3754
+ export function closeDb(db: Database): void {
3581
3755
  try {
3582
3756
  db.close(true)
3583
3757
  } catch {
@@ -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