@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -55,6 +55,7 @@ import { createArtifactTables, createTables, inHashes, lazyStatement } from './s
55
55
  import {
56
56
  UserError,
57
57
  DOT_VX_GITIGNORE,
58
+ IGNORE_ALL,
58
59
  formatBytes,
59
60
  isDiskFull,
60
61
  isFsRefusal,
@@ -113,10 +114,11 @@ import {
113
114
  zstdEncoder,
114
115
  } from './zstd.js'
115
116
  import { FileHashStore } from './file-hashes.js'
116
- import { LINK_MODE, OutputIndex } from './output-index.js'
117
+ import { DIR_MODE, LINK_MODE, OutputIndex } from './output-index.js'
117
118
  import { RunHistory } from './run-history.js'
118
119
  import { CACHE_VERSION, foldKey } from './key-fold.js'
119
120
  import { FoldMemo } from './fold-memo.js'
121
+ import { RemoteHits } from './remote-hits.js'
120
122
  import { behindLink } from './linked-inputs.js'
121
123
 
122
124
  /**
@@ -143,7 +145,7 @@ const ADOPT_STATS = 64
143
145
  * close, ~30 ms of it. So `accessed_at` trails the last use by at most
144
146
  * this much, and the prune policy reads it plus this as the last use:
145
147
  * an entry used within the limit is never pruned, and one may stay up to
146
- * this long past it (owner, 2026-10-08).
148
+ * this long past it (2026-10-08).
147
149
  */
148
150
  const ACCESS_REFRESH_MS = 60 * 60 * 1000
149
151
 
@@ -162,8 +164,12 @@ const VX_CACHE_NAMES = new Set([
162
164
  'cache.db-wal',
163
165
  'cache.db-shm',
164
166
  'cache.db-journal',
165
- 'runs',
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.
166
170
  'failures',
171
+ 'steps',
172
+ 'warnings',
167
173
  'lockfile-claims',
168
174
  ])
169
175
 
@@ -217,20 +223,28 @@ function unreadableIndexError(dbFile: string, err: unknown): UserError {
217
223
  * SQLite reports a descriptor it could not get as `SQLITE_CANTOPEN`, never
218
224
  * `EMFILE`, so an open under a low `ulimit -n` reached the user as a bare
219
225
  * `vx: SQLiteError: unable to open database file` (D's lead, A-56). One
220
- * open of a file that exists says which it was.
226
+ * open of a file that exists says which it was. Any other `CANTOPEN` is
227
+ * the path itself.
221
228
  */
222
- function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
229
+ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
223
230
  const code = (err as { code?: unknown } | null)?.code
224
231
  if (typeof code !== 'string' || !code.startsWith('SQLITE_CANTOPEN')) return undefined
225
232
  try {
226
233
  closeSync(openSync(process.execPath, 'r'))
227
- return undefined
228
234
  } catch (probe) {
229
235
  if (!isOutOfFds(probe)) return undefined
230
236
  return new UserError(
231
237
  `the cache index ${dbFile} could not be opened (${(err as Error).message}) — ${OUT_OF_FDS_HINT}`,
238
+ 'VX_E_FDS',
232
239
  )
233
240
  }
241
+ // Any other: a directory, or a file this user may not open, where the
242
+ // index goes. It reached every verb as a stack (2026-10-28).
243
+ return new UserError(
244
+ `the cache index ${dbFile} could not be opened (${(err as Error).message}) — ` +
245
+ 'it is not a file this user can open; move it away and re-run (vx rebuilds the index)',
246
+ 'VX_E_FS',
247
+ )
234
248
  }
235
249
 
236
250
  // SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
@@ -324,7 +338,26 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
324
338
  // is unchanged.
325
339
  // v42: fold_memo — each task's last key by its fold up to the files and
326
340
  // a token of their git records. The cache KEY is unchanged.
327
- export const SCHEMA_VERSION = 'v42'
341
+ // v43: runs.peak_cores / avg_rss_bytes — what a sampled run burned at
342
+ // its peak and held on average. The cache KEY is unchanged.
343
+ // v44: runs.placement — where an executor ran the task. The cache KEY
344
+ // is unchanged.
345
+ // v45: a 'remote' task_durations row also carries the task's measured
346
+ // cores, memory and saturation, for a run with no local history.
347
+ // The cache KEY is unchanged.
348
+ // v46: runs.keyless_by — the task with no inputs a key folded, so
349
+ // `vx why` names it. The cache KEY is unchanged.
350
+ // v47: the store's remote_hits — what a remote's hasMany said it holds,
351
+ // hits only, so another process trusts it. The cache KEY is
352
+ // unchanged.
353
+ // v48: runs.def and task_durations.def — the task's definition
354
+ // (taskDefinition), so a task whose command changed under the same
355
+ // name reads none of the old one's timings. The cache KEY is
356
+ // unchanged.
357
+ // v49: runs.cpu_share — the share of a core each runnable thread got
358
+ // where the task ran, so a crowded run's CPU reads as need. The
359
+ // cache KEY is unchanged.
360
+ export const SCHEMA_VERSION = 'v49'
328
361
 
329
362
  /**
330
363
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -355,6 +388,7 @@ const STORE_TABLES = [
355
388
  'task_outcomes',
356
389
  'blob_sizes',
357
390
  'plugin_blobs',
391
+ 'remote_hits',
358
392
  'store_meta',
359
393
  'entries',
360
394
  ]
@@ -678,7 +712,7 @@ function ensurePrivateDir(dir: string): string | null {
678
712
  if ((st.mode & 0o022) !== 0) return `${dir} is writable by other users`
679
713
  // The user's own directory open to others (a `~/.vx` made at the
680
714
  // default umask, 0755) is vx's to close: refusing it left the store
681
- // unused on a stock macOS home (owner, 2026-10-06: "you should own it").
715
+ // unused on a stock macOS home (2026-10-06: "you should own it").
682
716
  if ((st.mode & 0o077) !== 0) {
683
717
  try {
684
718
  chmodSync(dir, 0o700)
@@ -697,12 +731,11 @@ function ensurePrivateDir(dir: string): string | null {
697
731
  // ignored directory is skipped by the walk entirely. Only written when
698
732
  // absent, so a user's own file wins.
699
733
  const utf8 = new TextEncoder()
700
- const IGNORE_ALL = '*\n'
701
734
 
702
735
  /**
703
736
  * The ignore file that keeps `cacheDir` out of git, and its text. The
704
737
  * default `.vx/cache` is named by `.vx/.gitignore` (`/cache`), committed
705
- * beside the workspace config and the lock (owner, 2026-10-14); any other
738
+ * beside the workspace config and the lock (2026-10-14); any other
706
739
  * cache directory holds a `*` one of its own. Written only when missing:
707
740
  * an existing file is the user's.
708
741
  */
@@ -783,15 +816,40 @@ function makeCacheDir(cacheDir: string): void {
783
816
  try {
784
817
  mkdirSync(cacheDir, { recursive: true })
785
818
  } catch (err) {
819
+ const where = 'set `cache.dir` in .vx/workspace.ts, or pass --cache-dir <path>'
820
+ const code = (err as NodeJS.ErrnoException).code
821
+ // A file at the path or at a parent of it: "make the workspace
822
+ // writable" sent the user to chmod a tree that was writable.
823
+ if (code === 'EEXIST' || code === 'ENOTDIR') {
824
+ const file = inTheWay(cacheDir)
825
+ throw new UserError(
826
+ `cannot create cache directory ${cacheDir}: ${file} is a file, not a directory — move it away, ${where}`,
827
+ 'VX_E_FS',
828
+ )
829
+ }
786
830
  // A read-only checkout with no cache yet: every verb opens the cache,
787
831
  // so this is the first thing any of them says there.
832
+ const fix = isDiskFull(err) ? 'free space on that disk' : 'make the workspace writable'
788
833
  throw new UserError(
789
- `cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; make ` +
790
- `the workspace writable, set \`cache.dir\` in .vx/workspace.ts, or pass --cache-dir <path>`,
834
+ `cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; ${fix}, ${where}`,
835
+ 'VX_E_FS',
791
836
  )
792
837
  }
793
838
  }
794
839
 
840
+ /** The nearest of `dir` and its parents that exists, as itself (a link is not followed). */
841
+ function inTheWay(dir: string): string {
842
+ for (let at = dir; ; at = path.dirname(at)) {
843
+ try {
844
+ lstatSync(at)
845
+ return at
846
+ } catch {
847
+ // quiet: a missing level is the walk's question, not a fault
848
+ if (path.dirname(at) === at) return dir
849
+ }
850
+ }
851
+ }
852
+
795
853
  function errorText(err: unknown): string {
796
854
  return err instanceof Error ? err.message : String(err)
797
855
  }
@@ -892,6 +950,8 @@ export class Cache implements CacheLayer {
892
950
  private readonly outputs: OutputIndex
893
951
  private readonly folds: FoldMemo
894
952
  private readonly history: RunHistory
953
+ /** What the remotes said they hold, across runs (remote-hits.ts). */
954
+ readonly remoteHits: RemoteHits
895
955
 
896
956
  /**
897
957
  * Local-layer read/write gates for the task ARTIFACT path only.
@@ -919,7 +979,7 @@ export class Cache implements CacheLayer {
919
979
  * Set when THIS open found an index written by another `SCHEMA_VERSION`
920
980
  * and dropped every table. The next open sees the current version and
921
981
  * reports null. vx prints nothing for it: the cache is vx's to keep
922
- * (owner, 2026-10-06).
982
+ * (2026-10-06).
923
983
  */
924
984
  readonly schemaReset: SchemaReset | null = null
925
985
 
@@ -1005,7 +1065,7 @@ export class Cache implements CacheLayer {
1005
1065
  try {
1006
1066
  this.db = new Database(memory ? ':memory:' : dbFile, { create: true })
1007
1067
  } catch (err) {
1008
- throw outOfFdsAtOpen(dbFile, err) ?? err
1068
+ throw cantOpenIndex(dbFile, err) ?? err
1009
1069
  }
1010
1070
  // busy_timeout makes concurrent writers wait for the lock instead of
1011
1071
  // failing immediately with SQLITE_BUSY. Two parallel `vx run`
@@ -1024,7 +1084,7 @@ export class Cache implements CacheLayer {
1024
1084
  return read()
1025
1085
  } catch (err) {
1026
1086
  if (!unreadableIndex(err)) {
1027
- const fds = outOfFdsAtOpen(dbFile, err)
1087
+ const fds = cantOpenIndex(dbFile, err)
1028
1088
  if (fds === undefined) throw err
1029
1089
  try {
1030
1090
  closeDb(this.db)
@@ -1087,8 +1147,8 @@ export class Cache implements CacheLayer {
1087
1147
  | undefined
1088
1148
  )?.value
1089
1149
  // Any other schema is reset by an opener that may write it: the index is
1090
- // an inventory the artifacts rebuild, whichever vx wrote it (owner,
1091
- // 2026-10-06; a newer one used to be refused, item 896). A reading verb
1150
+ // an inventory the artifacts rebuild, whichever vx wrote it
1151
+ // (2026-10-06; a newer one used to be refused, item 896). A reading verb
1092
1152
  // leaves it as it was.
1093
1153
  const refuseUnreadable = (found: string): void => {
1094
1154
  if (mode !== 'open') {
@@ -1112,7 +1172,7 @@ export class Cache implements CacheLayer {
1112
1172
  // keeps it so).
1113
1173
  if (found !== undefined) {
1114
1174
  this.db.exec(
1115
- '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;',
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;',
1116
1176
  )
1117
1177
  }
1118
1178
  // The tables land under the same write lock as the stamp: made
@@ -1155,8 +1215,10 @@ export class Cache implements CacheLayer {
1155
1215
  } | null
1156
1216
  )?.value,
1157
1217
  )
1218
+ // A dry prune names what the real one takes, so it reads the store a
1219
+ // run opens (today's), not the one the index last recorded.
1158
1220
  let storeDir =
1159
- mode !== 'open'
1221
+ mode === 'inspect'
1160
1222
  ? (recorded ?? (typeof storeRoot === 'string' ? storeRoot : undefined))
1161
1223
  : storeRoot === undefined
1162
1224
  ? recorded
@@ -1273,9 +1335,10 @@ export class Cache implements CacheLayer {
1273
1335
  // The slices: each owns its statements over this handle and its table(s);
1274
1336
  // the schema above is the one place every table is declared.
1275
1337
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
1276
- this.outputs = new OutputIndex(this.db)
1338
+ this.outputs = new OutputIndex(this.db, this.writeBlocked === null && !this.inspecting)
1277
1339
  this.folds = new FoldMemo(this.db, this.write && !this.inspecting)
1278
1340
  this.history = new RunHistory(this.db, (v) => this.digestValue(v))
1341
+ this.remoteHits = new RemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
1279
1342
 
1280
1343
  // A CACHE_VERSION bump keeps the index but moves every key, so the run
1281
1344
  // after an upgrade misses everything. Roadmap 3.3: that is announced,
@@ -1307,7 +1370,7 @@ export class Cache implements CacheLayer {
1307
1370
  */
1308
1371
  private guard<T>(op: () => T): T {
1309
1372
  const refuse = (err: unknown): never => {
1310
- throw unreadableIndex(err) ? unreadableIndexError(this.dbFile, err) : err
1373
+ throw unreadableIndex(err) ? unreadableIndexError(this.unreadableFile(), err) : err
1311
1374
  }
1312
1375
  try {
1313
1376
  const out = op()
@@ -1317,6 +1380,24 @@ export class Cache implements CacheLayer {
1317
1380
  }
1318
1381
  }
1319
1382
 
1383
+ /**
1384
+ * Which file a "not a database" answer came from: SQLite does not say,
1385
+ * and the entries live in the attached store, so naming the index alone
1386
+ * sent the user to remove a sound file. Asked only on that refusal.
1387
+ */
1388
+ private unreadableFile(): string {
1389
+ if (this.storeDir === undefined) return this.dbFile
1390
+ const storeFile = path.join(this.storeDir, 'store.db')
1391
+ try {
1392
+ const row = this.db.prepare('PRAGMA store.quick_check(1)').get() as {
1393
+ quick_check?: unknown
1394
+ } | null
1395
+ return row?.quick_check === 'ok' ? this.dbFile : storeFile
1396
+ } catch {
1397
+ return storeFile
1398
+ }
1399
+ }
1400
+
1320
1401
  /**
1321
1402
  * Attach the shared store as `store`, with the pragmas the index takes:
1322
1403
  * WAL for both, so a workspace's run and another's share the store as
@@ -1352,14 +1433,14 @@ export class Cache implements CacheLayer {
1352
1433
  }
1353
1434
  throw unreadableIndex(err)
1354
1435
  ? unreadableIndexError(storeFile, err)
1355
- : (outOfFdsAtOpen(storeFile, err) ?? err)
1436
+ : (cantOpenIndex(storeFile, err) ?? err)
1356
1437
  }
1357
1438
  }
1358
1439
 
1359
1440
  /**
1360
1441
  * The store is an inventory of the artifacts beside it, and only this vx
1361
1442
  * reads it: a store another `SCHEMA_VERSION` wrote has its tables dropped
1362
- * (owner, 2026-10-06), the artifacts kept. Each is indexed again from its
1443
+ * (2026-10-06), the artifacts kept. Each is indexed again from its
1363
1444
  * own bytes on its next hit (`adopt`). A reading verb reads such a store
1364
1445
  * as empty and changes nothing. Creates the store's tables either way.
1365
1446
  */
@@ -1613,8 +1694,8 @@ export class Cache implements CacheLayer {
1613
1694
 
1614
1695
  /**
1615
1696
  * Index an artifact the index does not know, from its own bytes: the
1616
- * artifact is the record, the index only an inventory of it (owner,
1617
- * 2026-10-06). A reset index (a `SCHEMA_VERSION` change, a `cache.db` or
1697
+ * artifact is the record, the index only an inventory of it
1698
+ * (2026-10-06). A reset index (a `SCHEMA_VERSION` change, a `cache.db` or
1618
1699
  * `store.db` deleted) loses nothing a task asks for again. The artifact
1619
1700
  * is checked as an ingest checks a remote's (its recorded key, its names
1620
1701
  * against the declared outputs); one that fails is a miss, and the run
@@ -1699,6 +1780,10 @@ export class Cache implements CacheLayer {
1699
1780
  return this.guard(() => this.readEntry(hash))
1700
1781
  }
1701
1782
 
1783
+ getSaved(hash: string): Promise<CacheEntry | null> {
1784
+ return this.getIngested(hash)
1785
+ }
1786
+
1702
1787
  private async readEntry(hash: string): Promise<CacheEntry | null> {
1703
1788
  const row = this.selectEntry.get(hash) as EntryRow | null
1704
1789
  if (row === null) return null
@@ -1829,7 +1914,7 @@ export class Cache implements CacheLayer {
1829
1914
 
1830
1915
  /**
1831
1916
  * The subset of `hashes` this store holds, `has` for each: the prefetch
1832
- * pass asks the remote only about the rest (M, 2026-10-15).
1917
+ * pass asks the remote only about the rest (2026-10-15).
1833
1918
  */
1834
1919
  async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
1835
1920
  const out = new Set<string>()
@@ -1864,7 +1949,7 @@ export class Cache implements CacheLayer {
1864
1949
  loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
1865
1950
  return this.guard(() => this.outputs.loadOutputFilesBatch(hashes))
1866
1951
  }
1867
- isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
1952
+ isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean {
1868
1953
  return this.outputs.isOutputsCurrent(projectDir, expected)
1869
1954
  }
1870
1955
  outputsPath(hash: string): string {
@@ -1895,7 +1980,7 @@ export class Cache implements CacheLayer {
1895
1980
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
1896
1981
  return this.guard(() => this.outputs.loadOutputDirsBatch(hashes))
1897
1982
  }
1898
- outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
1983
+ outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): boolean {
1899
1984
  return this.outputs.outputDirsCurrent(projectDir, rows)
1900
1985
  }
1901
1986
  async restoreOutputs(
@@ -1945,9 +2030,9 @@ export class Cache implements CacheLayer {
1945
2030
  `its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
1946
2031
  )
1947
2032
  }
1948
- if (await this.copyTree(tree, rows, files, projectDir, accept)) return
2033
+ if (await this.copyTree(hash, tree, rows, files, projectDir, accept)) return
1949
2034
  await extractArtifactStream(
1950
- packArtifactStream(planOfTree(tree, this.stdoutText(hash))),
2035
+ packArtifactStream(this.treePlan(hash, tree, rows)),
1951
2036
  projectDir,
1952
2037
  workspaceRoot,
1953
2038
  verify,
@@ -1956,32 +2041,112 @@ export class Cache implements CacheLayer {
1956
2041
  )
1957
2042
  }
1958
2043
 
1959
- /** The copy-out: false, having written nothing, where it is not the case it serves. */
2044
+ /**
2045
+ * The tree packed back into its tar, each file held to its row's size:
2046
+ * the tree form keeps no checksum, and a file cut short at rest (a crash
2047
+ * after its rename, before its data reached the disk) was restored, and
2048
+ * uploaded under a fresh sum.
2049
+ */
2050
+ private treePlan(hash: string, tree: string, rows: readonly OutputFileRow[]): ArtifactPlan {
2051
+ const plan = planOfTree(tree, this.stdoutText(hash))
2052
+ const size = new Map(rows.map((r) => [treeName(r.path), r.size]))
2053
+ for (const i of plan.inputs) {
2054
+ const want = size.get(i.name)
2055
+ if (want !== undefined && want !== i.size)
2056
+ throw new CorruptArtifactError(hash, `${i.name} holds ${i.size} bytes, recorded ${want}`)
2057
+ }
2058
+ return plan
2059
+ }
2060
+
2061
+ /**
2062
+ * The copy-out: false, having written nothing, where it is not the case it
2063
+ * serves. It writes only where nothing stands: each file's place is absent,
2064
+ * and every directory on the way either is a real directory or is absent
2065
+ * and made here. solid's build outputs `store/dist/**` beside the sources
2066
+ * in `store/`; asking that each output's TOP be absent sent every such
2067
+ * restore through the tar extractor, ~25 ms a task (2026-10-22).
2068
+ */
1960
2069
  private async copyTree(
2070
+ hash: string,
1961
2071
  tree: string,
1962
2072
  rows: readonly OutputFileRow[],
1963
2073
  files: readonly OutputFileRow[],
1964
2074
  projectDir: string,
1965
2075
  accept: ((name: string) => boolean) | undefined,
1966
2076
  ): Promise<boolean> {
1967
- const tops = new Set<string>()
1968
2077
  for (const r of rows) {
1969
2078
  if (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) || r.mode === LINK_MODE) return false
1970
2079
  if (accept !== undefined && !r.path.endsWith('/') && !accept(`outputs/${r.path}`))
1971
2080
  return false
1972
- tops.add(r.path.split('/')[0]!)
1973
2081
  }
1974
- const held = await Promise.all(
1975
- [...tops].map((t) =>
1976
- lstat(path.join(projectDir, t)).then(
1977
- () => true,
1978
- () => false,
1979
- ),
1980
- ),
2082
+ // What stands at each path, asked once: a real directory, nothing, or
2083
+ // anything else (a file, a link), which the extractor's rules decide.
2084
+ const standing = new Map<string, Promise<'dir' | 'absent' | 'other'>>()
2085
+ const at = (rel: string): Promise<'dir' | 'absent' | 'other'> => {
2086
+ let known = standing.get(rel)
2087
+ if (known === undefined) {
2088
+ known = lstat(path.join(projectDir, rel)).then(
2089
+ (st) => (st.isDirectory() ? 'dir' : 'other'),
2090
+ (err: NodeJS.ErrnoException) => (err.code === 'ENOENT' ? 'absent' : 'other'),
2091
+ )
2092
+ standing.set(rel, known)
2093
+ }
2094
+ return known
2095
+ }
2096
+ // The topmost absent directories this copy makes, and the files it
2097
+ // writes into a directory that stood: what a failure removes, and only that.
2098
+ const made = new Set<string>()
2099
+ const placed: string[] = []
2100
+ const fits = await Promise.all(
2101
+ rows.map(async (r) => {
2102
+ const segs = r.path.replace(/\/$/, '').split('/')
2103
+ for (let i = 1; i <= segs.length; i++) {
2104
+ const rel = segs.slice(0, i).join('/')
2105
+ const isLeaf = i === segs.length
2106
+ const state = await at(rel)
2107
+ if (state === 'absent') {
2108
+ if (!isLeaf || r.path.endsWith('/')) made.add(rel)
2109
+ else placed.push(rel)
2110
+ return true
2111
+ }
2112
+ if (state === 'other' || (isLeaf && !r.path.endsWith('/'))) return false
2113
+ }
2114
+ return true
2115
+ }),
1981
2116
  )
1982
- if (held.includes(true)) return false
2117
+ if (fits.includes(false)) return false
2118
+ // A directory made under another one made here is the outer one's.
2119
+ const roots = [...made].filter((d) => ![...made].some((o) => d.startsWith(`${o}/`)))
1983
2120
  readUmask()
1984
2121
  const plain = restoredMode(0o666)
2122
+ // Another process's re-save of this key renames the tree aside and a
2123
+ // new one in mid-copy: the files copied after came from another build.
2124
+ // A changed inode is that race, a miss (the task runs), and the entry
2125
+ // the other process saved is kept.
2126
+ const ino = lstatSync(tree).ino
2127
+ const swapped = (): boolean => lstatSync(tree, { throwIfNoEntry: false })?.ino !== ino
2128
+ // A standing directory of this user's that withholds its own write (a
2129
+ // task's `chmod 555 dist/ro`) gets owner write and search for the copy
2130
+ // and its mode back after, as the extractor's rename does: else only
2131
+ // root could restore a file into it, and the hit failed EACCES.
2132
+ const reopened = new Map<string, number>()
2133
+ const reopen = (dir: string): boolean => {
2134
+ if (reopened.has(dir)) return true
2135
+ const st = lstatSync(dir, { throwIfNoEntry: false })
2136
+ if (st?.isDirectory() !== true || st.uid !== process.getuid?.()) return false
2137
+ if ((st.mode & 0o300) === 0o300) return false
2138
+ reopened.set(dir, st.mode & 0o7777)
2139
+ chmodSync(dir, (st.mode & 0o7777) | 0o300)
2140
+ return true
2141
+ }
2142
+ const copy = async (to: string, from: string): Promise<number> => {
2143
+ try {
2144
+ return await Bun.write(to, Bun.file(from), { createPath: false })
2145
+ } catch (err) {
2146
+ if ((err as NodeJS.ErrnoException).code !== 'EACCES' || !reopen(path.dirname(to))) throw err
2147
+ return await Bun.write(to, Bun.file(from), { createPath: false })
2148
+ }
2149
+ }
1985
2150
  try {
1986
2151
  const dirs = new Set<string>()
1987
2152
  for (const r of rows) {
@@ -1991,18 +2156,32 @@ export class Cache implements CacheLayer {
1991
2156
  await Promise.all(
1992
2157
  files.map(async (r) => {
1993
2158
  const to = path.join(projectDir, r.path)
1994
- await Bun.write(to, Bun.file(path.join(tree, treeName(r.path))), { createPath: false })
2159
+ const n = await copy(to, path.join(tree, treeName(r.path)))
2160
+ if (n !== r.size)
2161
+ throw new CorruptArtifactError(hash, `${r.path} holds ${n} bytes, recorded ${r.size}`)
1995
2162
  const mode = restoredMode(r.mode)
1996
2163
  if (mode !== plain) await chmod(to, mode)
1997
2164
  const at = new Date(r.mtimeMs)
1998
2165
  await utimes(to, at, at)
1999
2166
  }),
2000
2167
  )
2168
+ if (swapped()) throw new ArtifactVanishedError(hash)
2001
2169
  } catch (err) {
2002
- await Promise.all(
2003
- [...tops].map((t) => rm(path.join(projectDir, t), { recursive: true, force: true })),
2004
- )
2170
+ await Promise.all([
2171
+ ...roots.map((d) => rm(path.join(projectDir, d), { recursive: true, force: true })),
2172
+ ...placed.map((f) => rm(path.join(projectDir, f), { force: true })),
2173
+ ])
2174
+ if (!(err instanceof ArtifactVanishedError) && swapped())
2175
+ throw new ArtifactVanishedError(hash)
2005
2176
  throw err
2177
+ } finally {
2178
+ for (const [dir, mode] of reopened) {
2179
+ try {
2180
+ chmodSync(dir, mode)
2181
+ } catch {
2182
+ // gone with the task's own cleanup since
2183
+ }
2184
+ }
2006
2185
  }
2007
2186
  return true
2008
2187
  }
@@ -2097,6 +2276,7 @@ export class Cache implements CacheLayer {
2097
2276
  .filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
2098
2277
  .map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
2099
2278
  endRows()
2279
+ const moded = absentModedDirs(rows, projectDir, workspaceRoot)
2100
2280
  const verify = (provided: ReadonlySet<string>): void => {
2101
2281
  const missing = expected.filter((name) => !provided.has(name))
2102
2282
  if (missing.length > 0) {
@@ -2132,10 +2312,16 @@ export class Cache implements CacheLayer {
2132
2312
  const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
2133
2313
  await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
2134
2314
  }
2315
+ for (const [dir, mode] of moded) await chmod(dir, restoredMode(mode))
2135
2316
  } catch (err) {
2136
2317
  // A UserError here is the extractor naming the tree's fault (an
2137
2318
  // output directory that links out of the project).
2138
- if (err instanceof CorruptArtifactError || err instanceof UserError) throw err
2319
+ if (
2320
+ err instanceof CorruptArtifactError ||
2321
+ err instanceof UserError ||
2322
+ err instanceof ArtifactVanishedError
2323
+ )
2324
+ throw err
2139
2325
  // The save and the ingest proved every name safe, so one that reads
2140
2326
  // unsafe now is damaged or tampered bytes: a long name's pax record
2141
2327
  // has no header checksum, and the name is judged before the CRC at
@@ -2214,6 +2400,15 @@ export class Cache implements CacheLayer {
2214
2400
  if (code === 'ENOENT' && (err as NodeJS.ErrnoException).path === src) {
2215
2401
  throw new ArtifactVanishedError(hash)
2216
2402
  }
2403
+ // So is a tree file: a prune or another process's re-save moved the
2404
+ // tree aside mid-copy.
2405
+ if (
2406
+ code === 'ENOENT' &&
2407
+ tree !== undefined &&
2408
+ inTree((err as NodeJS.ErrnoException).path, tree)
2409
+ ) {
2410
+ throw new ArtifactVanishedError(hash)
2411
+ }
2217
2412
  // A staged file the restore itself just wrote is gone before its
2218
2413
  // commit: another process cleaned the same outputs under us (two runs
2219
2414
  // on one workspace, a `vx watch` beside a `vx run` — a clean landing
@@ -2250,7 +2445,7 @@ export class Cache implements CacheLayer {
2250
2445
 
2251
2446
  /**
2252
2447
  * A run over a cache directory it cannot write: one holding entries
2253
- * serves its hits and saves nothing (M, 2026-10-16), and the line this
2448
+ * serves its hits and saves nothing (2026-10-16), and the line this
2254
2449
  * returns says so; an empty one has nothing to serve and refuses.
2255
2450
  */
2256
2451
  readOnlyNotice(): string | null {
@@ -2270,6 +2465,7 @@ export class Cache implements CacheLayer {
2270
2465
  outputFiles: string[]
2271
2466
  outputDirs?: string[]
2272
2467
  workspaceOutputFiles?: string[]
2468
+ workspaceOutputDirs?: string[]
2273
2469
  workspaceRoot?: string
2274
2470
  inputComponents?: readonly TaskInputRow[]
2275
2471
  /** See `CacheLayer.save`. */
@@ -2360,12 +2556,17 @@ export class Cache implements CacheLayer {
2360
2556
  const pinned = this.tempPath(hash)
2361
2557
  try {
2362
2558
  linkSync(this.tarPath(hash), pinned)
2559
+ // The link shares the artifact's mtime, as adopt's does: an artifact
2560
+ // saved over an hour ago made the pin a crashed save's temp to another
2561
+ // process's orphan sweep, which took it mid-upload.
2562
+ const now = new Date()
2563
+ utimesSync(pinned, now, now)
2363
2564
  } catch (err) {
2364
2565
  const tree = this.treePath(hash)
2365
2566
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || !existsSync(tree)) throw err
2366
2567
  // A tree is packed into the artifact a remote expects here, in the
2367
2568
  // upload queue, off the save; a small one in memory.
2368
- const plan = planOfTree(tree, this.stdoutText(hash))
2569
+ const plan = this.treePlan(hash, tree, this.outputs.rowsOf(hash))
2369
2570
  if (plan.size <= STREAM_DECODE_FROM) {
2370
2571
  const bytes = await zstdCompress(await packArtifactBytes(plan))
2371
2572
  return { body: new Blob([bytes]), release: async () => {} }
@@ -2463,6 +2664,7 @@ export class Cache implements CacheLayer {
2463
2664
  outputFiles: string[]
2464
2665
  outputDirs?: string[]
2465
2666
  workspaceOutputFiles?: string[]
2667
+ workspaceOutputDirs?: string[]
2466
2668
  workspaceRoot?: string
2467
2669
  }): Map<string, string> {
2468
2670
  const outputs = new Map<string, string>()
@@ -2496,6 +2698,7 @@ export class Cache implements CacheLayer {
2496
2698
  outputFiles: string[]
2497
2699
  outputDirs?: string[]
2498
2700
  workspaceOutputFiles?: string[]
2701
+ workspaceOutputDirs?: string[]
2499
2702
  workspaceRoot?: string
2500
2703
  }): Promise<Uint8Array> {
2501
2704
  // stdout is ALWAYS present in the artifact, even if empty, so the
@@ -2518,9 +2721,15 @@ export class Cache implements CacheLayer {
2518
2721
  key: args.hash,
2519
2722
  stdout: args.entry.stdout ?? '',
2520
2723
  outputs: this.outputsOf(args),
2521
- dirs: args.outputDirs?.map((d) => `outputs/${relPosix(args.projectDir, d)}`),
2724
+ dirs: [
2725
+ ...(args.outputDirs ?? []).map((d) => `outputs/${relPosix(args.projectDir, d)}`),
2726
+ ...(args.workspaceOutputDirs ?? []).map(
2727
+ (d) => `${WORKSPACE_OUTPUT_PREFIX}${relPosix(args.workspaceRoot!, d)}`,
2728
+ ),
2729
+ ],
2522
2730
  exec: usageOfEntry(args.entry),
2523
2731
  root: args.workspaceRoot ?? args.projectDir,
2732
+ projectDir: args.projectDir,
2524
2733
  })
2525
2734
  if (plan.size > this.artifactCeiling) {
2526
2735
  throw new Error(
@@ -2736,7 +2945,7 @@ export class Cache implements CacheLayer {
2736
2945
  throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
2737
2946
  }
2738
2947
  const { entries } = scanned
2739
- const totalBytes =
2948
+ let totalBytes =
2740
2949
  saved !== undefined
2741
2950
  ? saved.bytes
2742
2951
  : compressed instanceof Uint8Array
@@ -2770,6 +2979,12 @@ export class Cache implements CacheLayer {
2770
2979
  tmpPath = undefined
2771
2980
  tree = dir
2772
2981
  }
2982
+ // A tree keeps its files as they are: counted as a saved tree counts
2983
+ // them, not as the archive it was unpacked from (`--max-size`, stats).
2984
+ if (tree !== undefined && saved === undefined) {
2985
+ totalBytes = 0
2986
+ for (const e of entries) totalBytes += e.size
2987
+ }
2773
2988
  const outputFileRows: Array<[string, number, number, number]> = []
2774
2989
  // Per-output-file fingerprint rows feed the skip-restore check.
2775
2990
  // Row paths: project entries store the bare rel (`outputs/`
@@ -2795,15 +3010,21 @@ export class Cache implements CacheLayer {
2795
3010
  outputFileRows.push([rowPath, e.size, e.mode & 0o777, Math.floor(e.mtimeMs)])
2796
3011
  }
2797
3012
  // An empty directory's row is its rel with a trailing slash: the hit's
2798
- // check asks only that a directory stands there.
2799
- for (const d of scanned.dirs) {
3013
+ // check asks only that a directory stands there. So is a directory with
3014
+ // a mode to restore, which carries it beside `DIR_MODE`.
3015
+ const dirModes = new Map(scanned.dirModes)
3016
+ const dirRow = (d: string): void => {
3017
+ const mode = dirModes.get(d)
3018
+ dirModes.delete(d)
2800
3019
  outputFileRows.push([
2801
3020
  `${d.startsWith('outputs/') ? d.slice('outputs/'.length) : d}/`,
2802
3021
  0,
2803
- 0,
3022
+ mode === undefined ? 0 : DIR_MODE | mode,
2804
3023
  0,
2805
3024
  ])
2806
3025
  }
3026
+ for (const d of scanned.dirs) dirRow(d)
3027
+ for (const d of [...dirModes.keys()]) dirRow(d)
2807
3028
  // A link's row: its rel, the target's length and CRC-32 where a file
2808
3029
  // has its size and mtime, and the link type in the mode, so the hit's
2809
3030
  // check compares the target and never follows it.
@@ -2881,7 +3102,7 @@ export class Cache implements CacheLayer {
2881
3102
  }
2882
3103
  return moved
2883
3104
  }
2884
- const tx = this.db.transaction(() => {
3105
+ const index = () => {
2885
3106
  if (inline !== null) {
2886
3107
  aside = moveAside()
2887
3108
  upsertBlob.run(hash, now, inline)
@@ -2927,10 +3148,13 @@ export class Cache implements CacheLayer {
2927
3148
  if (inputComponents !== undefined && inputComponents.length > 0) {
2928
3149
  insertEntryInput.run(hash, encodeEntryInputs(inputComponents))
2929
3150
  }
2930
- })
3151
+ }
2931
3152
  const endTx = span('save: index tx')
2932
3153
  try {
2933
- tx.immediate()
3154
+ // Built inside the try: on a database a stopping run closed while a
3155
+ // download streamed in, building it throws, and the temp or tree
3156
+ // the body went to stayed behind.
3157
+ this.db.transaction(index).immediate()
2934
3158
  } catch (err) {
2935
3159
  endTx()
2936
3160
  const left = renamed ?? tree ?? tmpPath
@@ -3013,6 +3237,7 @@ export class Cache implements CacheLayer {
3013
3237
  return this.guard(() => this.history.recordRunBundle(bundle))
3014
3238
  }
3015
3239
  recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void {
3240
+ if (this.writeBlocked !== null) return
3016
3241
  return this.guard(() => this.history.recordRemoteErrors(runId, errors))
3017
3242
  }
3018
3243
  stats(opts: CacheStatsOptions = {}): CacheStats {
@@ -3060,7 +3285,7 @@ export class Cache implements CacheLayer {
3060
3285
  // orphan rows in either order: landed first, the cascade takes them;
3061
3286
  // landed after, the flush's own entry check drops them (item 628).
3062
3287
  this.outputs.flushOutputDirs()
3063
- const { olderThanMs, maxBytes, dryRun = false } = options
3288
+ const { olderThanMs, maxBytes, dry = false } = options
3064
3289
  if (olderThanMs === undefined && maxBytes === undefined) {
3065
3290
  throw new Error('prune: pass at least one of `olderThanMs` or `maxBytes`')
3066
3291
  }
@@ -3090,7 +3315,7 @@ export class Cache implements CacheLayer {
3090
3315
  // Promise.all over the unlinks. The hash list binds as one `json_each`
3091
3316
  // parameter, so a huge eviction stays under any build's bound-parameter
3092
3317
  // ceiling.
3093
- if (dryRun) {
3318
+ if (dry) {
3094
3319
  return { evicted: victims.size, bytesFreed, orphans: picked.files.length, orphanBytes }
3095
3320
  }
3096
3321
  if (victims.size > 0 || stale.length > 0) {
@@ -3131,17 +3356,35 @@ export class Cache implements CacheLayer {
3131
3356
  if (!isIndexFull(err)) throw err
3132
3357
  }
3133
3358
  // History and memo rows a month old go with a prune, never a run's
3134
- // close: five DELETEs on every run's exit was its slowest step (owner,
3135
- // 2026-10-18). `invocations` shares the window with `runs`, or `vx last`
3359
+ // close: five DELETEs on every run's exit was its slowest step
3360
+ // (2026-10-18). `invocations` shares the window with `runs`, or `vx last`
3136
3361
  // would list a run whose task rows are gone.
3137
3362
  const cutoff = now - HISTORY_RETENTION_MS
3138
3363
  this.history.pruneOlderThan(cutoff)
3139
3364
  this.files.pruneOlderThan(cutoff)
3140
3365
  this.folds.prune(cutoff)
3141
3366
  const orphans = await this.reapOrphans(picked.files)
3367
+ // An inline artifact's deleted pages go to SQLite's free list and the
3368
+ // file keeps its size: give them back, or the bytes said freed are not.
3369
+ if ([...victims].some((h) => inline.has(h))) this.releaseFreePages()
3142
3370
  return { evicted: victims.size, bytesFreed, ...orphans }
3143
3371
  }
3144
3372
 
3373
+ /** VACUUM the schema holding inline artifacts; busy or no room to rewrite keeps the pages for reuse. */
3374
+ private releaseFreePages(): void {
3375
+ const schema = this.storeDir === undefined ? 'main' : 'store'
3376
+ try {
3377
+ const { freelist_count: free } = this.db.prepare(`PRAGMA ${schema}.freelist_count`).get() as {
3378
+ freelist_count: number
3379
+ }
3380
+ if (free === 0) return
3381
+ this.db.exec(`VACUUM ${schema}`)
3382
+ this.db.exec(`PRAGMA ${schema}.wal_checkpoint(TRUNCATE)`)
3383
+ } catch {
3384
+ // Another process holds the database, or the disk has no room for the copy.
3385
+ }
3386
+ }
3387
+
3145
3388
  /**
3146
3389
  * Unlink the row-less files `pickVictims` took: a `<hash>.tar.zst` with
3147
3390
  * no `entries` row (another vx version's open dropped it, a `cache.db`
@@ -3320,11 +3563,7 @@ export class Cache implements CacheLayer {
3320
3563
  }
3321
3564
  }
3322
3565
 
3323
- /**
3324
- * SQLite's file-control opcode (sqlite3.h). Spelled here, not imported from
3325
- * `bun:sqlite`'s `constants`: the playground bundles this file against a
3326
- * shim that exports only `Database`.
3327
- */
3566
+ /** SQLite's file-control opcode (sqlite3.h). */
3328
3567
  const SQLITE_FCNTL_PERSIST_WAL = 10
3329
3568
 
3330
3569
  /**
@@ -3390,6 +3629,32 @@ function assertArtifactNames(
3390
3629
  }
3391
3630
  }
3392
3631
 
3632
+ /**
3633
+ * The directories with a mode to restore (`DIR_MODE` rows) that are absent
3634
+ * before the restore, deepest first: the restore makes them with mkdir's
3635
+ * default, and each is then given the mode the miss left, minus the umask
3636
+ * as a file's is. One that stands is the tree's, left as it is. A mode
3637
+ * only ever narrows mkdir's default, whatever an artifact says.
3638
+ */
3639
+ function absentModedDirs(
3640
+ rows: readonly OutputFileRow[],
3641
+ projectDir: string,
3642
+ workspaceRoot: string | undefined,
3643
+ ): Array<[string, number]> {
3644
+ const out: Array<[string, number]> = []
3645
+ for (const r of rows) {
3646
+ if ((r.mode & 0o170000) !== DIR_MODE || !r.path.endsWith('/')) continue
3647
+ const rel = r.path.slice(0, -1)
3648
+ const ws = rel.startsWith(WORKSPACE_OUTPUT_PREFIX)
3649
+ if (ws && workspaceRoot === undefined) continue
3650
+ const dir = ws
3651
+ ? path.join(workspaceRoot!, rel.slice(WORKSPACE_OUTPUT_PREFIX.length))
3652
+ : path.join(projectDir, rel)
3653
+ if (lstatSync(dir, { throwIfNoEntry: false }) === undefined) out.push([dir, r.mode & 0o777])
3654
+ }
3655
+ return out.sort((a, b) => b[0].length - a[0].length)
3656
+ }
3657
+
3393
3658
  /** A row's path as the tree names its file. */
3394
3659
  function treeName(rowPath: string): string {
3395
3660
  return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`