@vzn/vx 0.0.646 → 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 (184) hide show
  1. package/README.md +29 -22
  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 +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  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 +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -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/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -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,11 +164,13 @@ 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
- // The workspace config's bytecode bundle (workspace/config-bytecode.ts).
169
- 'compiled',
170
174
  ])
171
175
 
172
176
  /**
@@ -219,20 +223,28 @@ function unreadableIndexError(dbFile: string, err: unknown): UserError {
219
223
  * SQLite reports a descriptor it could not get as `SQLITE_CANTOPEN`, never
220
224
  * `EMFILE`, so an open under a low `ulimit -n` reached the user as a bare
221
225
  * `vx: SQLiteError: unable to open database file` (D's lead, A-56). One
222
- * 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.
223
228
  */
224
- function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
229
+ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
225
230
  const code = (err as { code?: unknown } | null)?.code
226
231
  if (typeof code !== 'string' || !code.startsWith('SQLITE_CANTOPEN')) return undefined
227
232
  try {
228
233
  closeSync(openSync(process.execPath, 'r'))
229
- return undefined
230
234
  } catch (probe) {
231
235
  if (!isOutOfFds(probe)) return undefined
232
236
  return new UserError(
233
237
  `the cache index ${dbFile} could not be opened (${(err as Error).message}) — ${OUT_OF_FDS_HINT}`,
238
+ 'VX_E_FDS',
234
239
  )
235
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
+ )
236
248
  }
237
249
 
238
250
  // SCHEMA history (drop+recreate on mismatch; pre-alpha, no migrations):
@@ -335,7 +347,17 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
335
347
  // The cache KEY is unchanged.
336
348
  // v46: runs.keyless_by — the task with no inputs a key folded, so
337
349
  // `vx why` names it. The cache KEY is unchanged.
338
- export const SCHEMA_VERSION = 'v46'
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'
339
361
 
340
362
  /**
341
363
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -366,6 +388,7 @@ const STORE_TABLES = [
366
388
  'task_outcomes',
367
389
  'blob_sizes',
368
390
  'plugin_blobs',
391
+ 'remote_hits',
369
392
  'store_meta',
370
393
  'entries',
371
394
  ]
@@ -689,7 +712,7 @@ function ensurePrivateDir(dir: string): string | null {
689
712
  if ((st.mode & 0o022) !== 0) return `${dir} is writable by other users`
690
713
  // The user's own directory open to others (a `~/.vx` made at the
691
714
  // default umask, 0755) is vx's to close: refusing it left the store
692
- // 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").
693
716
  if ((st.mode & 0o077) !== 0) {
694
717
  try {
695
718
  chmodSync(dir, 0o700)
@@ -708,12 +731,11 @@ function ensurePrivateDir(dir: string): string | null {
708
731
  // ignored directory is skipped by the walk entirely. Only written when
709
732
  // absent, so a user's own file wins.
710
733
  const utf8 = new TextEncoder()
711
- const IGNORE_ALL = '*\n'
712
734
 
713
735
  /**
714
736
  * The ignore file that keeps `cacheDir` out of git, and its text. The
715
737
  * default `.vx/cache` is named by `.vx/.gitignore` (`/cache`), committed
716
- * 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
717
739
  * cache directory holds a `*` one of its own. Written only when missing:
718
740
  * an existing file is the user's.
719
741
  */
@@ -794,15 +816,40 @@ function makeCacheDir(cacheDir: string): void {
794
816
  try {
795
817
  mkdirSync(cacheDir, { recursive: true })
796
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
+ }
797
830
  // A read-only checkout with no cache yet: every verb opens the cache,
798
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'
799
833
  throw new UserError(
800
- `cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; make ` +
801
- `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',
802
836
  )
803
837
  }
804
838
  }
805
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
+
806
853
  function errorText(err: unknown): string {
807
854
  return err instanceof Error ? err.message : String(err)
808
855
  }
@@ -903,6 +950,8 @@ export class Cache implements CacheLayer {
903
950
  private readonly outputs: OutputIndex
904
951
  private readonly folds: FoldMemo
905
952
  private readonly history: RunHistory
953
+ /** What the remotes said they hold, across runs (remote-hits.ts). */
954
+ readonly remoteHits: RemoteHits
906
955
 
907
956
  /**
908
957
  * Local-layer read/write gates for the task ARTIFACT path only.
@@ -930,7 +979,7 @@ export class Cache implements CacheLayer {
930
979
  * Set when THIS open found an index written by another `SCHEMA_VERSION`
931
980
  * and dropped every table. The next open sees the current version and
932
981
  * reports null. vx prints nothing for it: the cache is vx's to keep
933
- * (owner, 2026-10-06).
982
+ * (2026-10-06).
934
983
  */
935
984
  readonly schemaReset: SchemaReset | null = null
936
985
 
@@ -1016,7 +1065,7 @@ export class Cache implements CacheLayer {
1016
1065
  try {
1017
1066
  this.db = new Database(memory ? ':memory:' : dbFile, { create: true })
1018
1067
  } catch (err) {
1019
- throw outOfFdsAtOpen(dbFile, err) ?? err
1068
+ throw cantOpenIndex(dbFile, err) ?? err
1020
1069
  }
1021
1070
  // busy_timeout makes concurrent writers wait for the lock instead of
1022
1071
  // failing immediately with SQLITE_BUSY. Two parallel `vx run`
@@ -1035,7 +1084,7 @@ export class Cache implements CacheLayer {
1035
1084
  return read()
1036
1085
  } catch (err) {
1037
1086
  if (!unreadableIndex(err)) {
1038
- const fds = outOfFdsAtOpen(dbFile, err)
1087
+ const fds = cantOpenIndex(dbFile, err)
1039
1088
  if (fds === undefined) throw err
1040
1089
  try {
1041
1090
  closeDb(this.db)
@@ -1098,8 +1147,8 @@ export class Cache implements CacheLayer {
1098
1147
  | undefined
1099
1148
  )?.value
1100
1149
  // Any other schema is reset by an opener that may write it: the index is
1101
- // an inventory the artifacts rebuild, whichever vx wrote it (owner,
1102
- // 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
1103
1152
  // leaves it as it was.
1104
1153
  const refuseUnreadable = (found: string): void => {
1105
1154
  if (mode !== 'open') {
@@ -1123,7 +1172,7 @@ export class Cache implements CacheLayer {
1123
1172
  // keeps it so).
1124
1173
  if (found !== undefined) {
1125
1174
  this.db.exec(
1126
- '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;',
1127
1176
  )
1128
1177
  }
1129
1178
  // The tables land under the same write lock as the stamp: made
@@ -1166,8 +1215,10 @@ export class Cache implements CacheLayer {
1166
1215
  } | null
1167
1216
  )?.value,
1168
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.
1169
1220
  let storeDir =
1170
- mode !== 'open'
1221
+ mode === 'inspect'
1171
1222
  ? (recorded ?? (typeof storeRoot === 'string' ? storeRoot : undefined))
1172
1223
  : storeRoot === undefined
1173
1224
  ? recorded
@@ -1284,9 +1335,10 @@ export class Cache implements CacheLayer {
1284
1335
  // The slices: each owns its statements over this handle and its table(s);
1285
1336
  // the schema above is the one place every table is declared.
1286
1337
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
1287
- this.outputs = new OutputIndex(this.db)
1338
+ this.outputs = new OutputIndex(this.db, this.writeBlocked === null && !this.inspecting)
1288
1339
  this.folds = new FoldMemo(this.db, this.write && !this.inspecting)
1289
1340
  this.history = new RunHistory(this.db, (v) => this.digestValue(v))
1341
+ this.remoteHits = new RemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
1290
1342
 
1291
1343
  // A CACHE_VERSION bump keeps the index but moves every key, so the run
1292
1344
  // after an upgrade misses everything. Roadmap 3.3: that is announced,
@@ -1318,7 +1370,7 @@ export class Cache implements CacheLayer {
1318
1370
  */
1319
1371
  private guard<T>(op: () => T): T {
1320
1372
  const refuse = (err: unknown): never => {
1321
- throw unreadableIndex(err) ? unreadableIndexError(this.dbFile, err) : err
1373
+ throw unreadableIndex(err) ? unreadableIndexError(this.unreadableFile(), err) : err
1322
1374
  }
1323
1375
  try {
1324
1376
  const out = op()
@@ -1328,6 +1380,24 @@ export class Cache implements CacheLayer {
1328
1380
  }
1329
1381
  }
1330
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
+
1331
1401
  /**
1332
1402
  * Attach the shared store as `store`, with the pragmas the index takes:
1333
1403
  * WAL for both, so a workspace's run and another's share the store as
@@ -1363,14 +1433,14 @@ export class Cache implements CacheLayer {
1363
1433
  }
1364
1434
  throw unreadableIndex(err)
1365
1435
  ? unreadableIndexError(storeFile, err)
1366
- : (outOfFdsAtOpen(storeFile, err) ?? err)
1436
+ : (cantOpenIndex(storeFile, err) ?? err)
1367
1437
  }
1368
1438
  }
1369
1439
 
1370
1440
  /**
1371
1441
  * The store is an inventory of the artifacts beside it, and only this vx
1372
1442
  * reads it: a store another `SCHEMA_VERSION` wrote has its tables dropped
1373
- * (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
1374
1444
  * own bytes on its next hit (`adopt`). A reading verb reads such a store
1375
1445
  * as empty and changes nothing. Creates the store's tables either way.
1376
1446
  */
@@ -1624,8 +1694,8 @@ export class Cache implements CacheLayer {
1624
1694
 
1625
1695
  /**
1626
1696
  * Index an artifact the index does not know, from its own bytes: the
1627
- * artifact is the record, the index only an inventory of it (owner,
1628
- * 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
1629
1699
  * `store.db` deleted) loses nothing a task asks for again. The artifact
1630
1700
  * is checked as an ingest checks a remote's (its recorded key, its names
1631
1701
  * against the declared outputs); one that fails is a miss, and the run
@@ -1844,7 +1914,7 @@ export class Cache implements CacheLayer {
1844
1914
 
1845
1915
  /**
1846
1916
  * The subset of `hashes` this store holds, `has` for each: the prefetch
1847
- * pass asks the remote only about the rest (M, 2026-10-15).
1917
+ * pass asks the remote only about the rest (2026-10-15).
1848
1918
  */
1849
1919
  async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
1850
1920
  const out = new Set<string>()
@@ -1879,7 +1949,7 @@ export class Cache implements CacheLayer {
1879
1949
  loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
1880
1950
  return this.guard(() => this.outputs.loadOutputFilesBatch(hashes))
1881
1951
  }
1882
- isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
1952
+ isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean {
1883
1953
  return this.outputs.isOutputsCurrent(projectDir, expected)
1884
1954
  }
1885
1955
  outputsPath(hash: string): string {
@@ -1910,7 +1980,7 @@ export class Cache implements CacheLayer {
1910
1980
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
1911
1981
  return this.guard(() => this.outputs.loadOutputDirsBatch(hashes))
1912
1982
  }
1913
- outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
1983
+ outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): boolean {
1914
1984
  return this.outputs.outputDirsCurrent(projectDir, rows)
1915
1985
  }
1916
1986
  async restoreOutputs(
@@ -1960,9 +2030,9 @@ export class Cache implements CacheLayer {
1960
2030
  `its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
1961
2031
  )
1962
2032
  }
1963
- if (await this.copyTree(tree, rows, files, projectDir, accept)) return
2033
+ if (await this.copyTree(hash, tree, rows, files, projectDir, accept)) return
1964
2034
  await extractArtifactStream(
1965
- packArtifactStream(planOfTree(tree, this.stdoutText(hash))),
2035
+ packArtifactStream(this.treePlan(hash, tree, rows)),
1966
2036
  projectDir,
1967
2037
  workspaceRoot,
1968
2038
  verify,
@@ -1971,6 +2041,23 @@ export class Cache implements CacheLayer {
1971
2041
  )
1972
2042
  }
1973
2043
 
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
+
1974
2061
  /**
1975
2062
  * The copy-out: false, having written nothing, where it is not the case it
1976
2063
  * serves. It writes only where nothing stands: each file's place is absent,
@@ -1980,6 +2067,7 @@ export class Cache implements CacheLayer {
1980
2067
  * restore through the tar extractor, ~25 ms a task (2026-10-22).
1981
2068
  */
1982
2069
  private async copyTree(
2070
+ hash: string,
1983
2071
  tree: string,
1984
2072
  rows: readonly OutputFileRow[],
1985
2073
  files: readonly OutputFileRow[],
@@ -2031,6 +2119,34 @@ export class Cache implements CacheLayer {
2031
2119
  const roots = [...made].filter((d) => ![...made].some((o) => d.startsWith(`${o}/`)))
2032
2120
  readUmask()
2033
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
+ }
2034
2150
  try {
2035
2151
  const dirs = new Set<string>()
2036
2152
  for (const r of rows) {
@@ -2040,19 +2156,32 @@ export class Cache implements CacheLayer {
2040
2156
  await Promise.all(
2041
2157
  files.map(async (r) => {
2042
2158
  const to = path.join(projectDir, r.path)
2043
- 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}`)
2044
2162
  const mode = restoredMode(r.mode)
2045
2163
  if (mode !== plain) await chmod(to, mode)
2046
2164
  const at = new Date(r.mtimeMs)
2047
2165
  await utimes(to, at, at)
2048
2166
  }),
2049
2167
  )
2168
+ if (swapped()) throw new ArtifactVanishedError(hash)
2050
2169
  } catch (err) {
2051
2170
  await Promise.all([
2052
2171
  ...roots.map((d) => rm(path.join(projectDir, d), { recursive: true, force: true })),
2053
2172
  ...placed.map((f) => rm(path.join(projectDir, f), { force: true })),
2054
2173
  ])
2174
+ if (!(err instanceof ArtifactVanishedError) && swapped())
2175
+ throw new ArtifactVanishedError(hash)
2055
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
+ }
2056
2185
  }
2057
2186
  return true
2058
2187
  }
@@ -2147,6 +2276,7 @@ export class Cache implements CacheLayer {
2147
2276
  .filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
2148
2277
  .map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
2149
2278
  endRows()
2279
+ const moded = absentModedDirs(rows, projectDir, workspaceRoot)
2150
2280
  const verify = (provided: ReadonlySet<string>): void => {
2151
2281
  const missing = expected.filter((name) => !provided.has(name))
2152
2282
  if (missing.length > 0) {
@@ -2182,10 +2312,16 @@ export class Cache implements CacheLayer {
2182
2312
  const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
2183
2313
  await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
2184
2314
  }
2315
+ for (const [dir, mode] of moded) await chmod(dir, restoredMode(mode))
2185
2316
  } catch (err) {
2186
2317
  // A UserError here is the extractor naming the tree's fault (an
2187
2318
  // output directory that links out of the project).
2188
- 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
2189
2325
  // The save and the ingest proved every name safe, so one that reads
2190
2326
  // unsafe now is damaged or tampered bytes: a long name's pax record
2191
2327
  // has no header checksum, and the name is judged before the CRC at
@@ -2264,6 +2400,15 @@ export class Cache implements CacheLayer {
2264
2400
  if (code === 'ENOENT' && (err as NodeJS.ErrnoException).path === src) {
2265
2401
  throw new ArtifactVanishedError(hash)
2266
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
+ }
2267
2412
  // A staged file the restore itself just wrote is gone before its
2268
2413
  // commit: another process cleaned the same outputs under us (two runs
2269
2414
  // on one workspace, a `vx watch` beside a `vx run` — a clean landing
@@ -2300,7 +2445,7 @@ export class Cache implements CacheLayer {
2300
2445
 
2301
2446
  /**
2302
2447
  * A run over a cache directory it cannot write: one holding entries
2303
- * 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
2304
2449
  * returns says so; an empty one has nothing to serve and refuses.
2305
2450
  */
2306
2451
  readOnlyNotice(): string | null {
@@ -2320,6 +2465,7 @@ export class Cache implements CacheLayer {
2320
2465
  outputFiles: string[]
2321
2466
  outputDirs?: string[]
2322
2467
  workspaceOutputFiles?: string[]
2468
+ workspaceOutputDirs?: string[]
2323
2469
  workspaceRoot?: string
2324
2470
  inputComponents?: readonly TaskInputRow[]
2325
2471
  /** See `CacheLayer.save`. */
@@ -2410,12 +2556,17 @@ export class Cache implements CacheLayer {
2410
2556
  const pinned = this.tempPath(hash)
2411
2557
  try {
2412
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)
2413
2564
  } catch (err) {
2414
2565
  const tree = this.treePath(hash)
2415
2566
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || !existsSync(tree)) throw err
2416
2567
  // A tree is packed into the artifact a remote expects here, in the
2417
2568
  // upload queue, off the save; a small one in memory.
2418
- const plan = planOfTree(tree, this.stdoutText(hash))
2569
+ const plan = this.treePlan(hash, tree, this.outputs.rowsOf(hash))
2419
2570
  if (plan.size <= STREAM_DECODE_FROM) {
2420
2571
  const bytes = await zstdCompress(await packArtifactBytes(plan))
2421
2572
  return { body: new Blob([bytes]), release: async () => {} }
@@ -2513,6 +2664,7 @@ export class Cache implements CacheLayer {
2513
2664
  outputFiles: string[]
2514
2665
  outputDirs?: string[]
2515
2666
  workspaceOutputFiles?: string[]
2667
+ workspaceOutputDirs?: string[]
2516
2668
  workspaceRoot?: string
2517
2669
  }): Map<string, string> {
2518
2670
  const outputs = new Map<string, string>()
@@ -2546,6 +2698,7 @@ export class Cache implements CacheLayer {
2546
2698
  outputFiles: string[]
2547
2699
  outputDirs?: string[]
2548
2700
  workspaceOutputFiles?: string[]
2701
+ workspaceOutputDirs?: string[]
2549
2702
  workspaceRoot?: string
2550
2703
  }): Promise<Uint8Array> {
2551
2704
  // stdout is ALWAYS present in the artifact, even if empty, so the
@@ -2568,9 +2721,15 @@ export class Cache implements CacheLayer {
2568
2721
  key: args.hash,
2569
2722
  stdout: args.entry.stdout ?? '',
2570
2723
  outputs: this.outputsOf(args),
2571
- 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
+ ],
2572
2730
  exec: usageOfEntry(args.entry),
2573
2731
  root: args.workspaceRoot ?? args.projectDir,
2732
+ projectDir: args.projectDir,
2574
2733
  })
2575
2734
  if (plan.size > this.artifactCeiling) {
2576
2735
  throw new Error(
@@ -2786,7 +2945,7 @@ export class Cache implements CacheLayer {
2786
2945
  throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
2787
2946
  }
2788
2947
  const { entries } = scanned
2789
- const totalBytes =
2948
+ let totalBytes =
2790
2949
  saved !== undefined
2791
2950
  ? saved.bytes
2792
2951
  : compressed instanceof Uint8Array
@@ -2820,6 +2979,12 @@ export class Cache implements CacheLayer {
2820
2979
  tmpPath = undefined
2821
2980
  tree = dir
2822
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
+ }
2823
2988
  const outputFileRows: Array<[string, number, number, number]> = []
2824
2989
  // Per-output-file fingerprint rows feed the skip-restore check.
2825
2990
  // Row paths: project entries store the bare rel (`outputs/`
@@ -2845,15 +3010,21 @@ export class Cache implements CacheLayer {
2845
3010
  outputFileRows.push([rowPath, e.size, e.mode & 0o777, Math.floor(e.mtimeMs)])
2846
3011
  }
2847
3012
  // An empty directory's row is its rel with a trailing slash: the hit's
2848
- // check asks only that a directory stands there.
2849
- 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)
2850
3019
  outputFileRows.push([
2851
3020
  `${d.startsWith('outputs/') ? d.slice('outputs/'.length) : d}/`,
2852
3021
  0,
2853
- 0,
3022
+ mode === undefined ? 0 : DIR_MODE | mode,
2854
3023
  0,
2855
3024
  ])
2856
3025
  }
3026
+ for (const d of scanned.dirs) dirRow(d)
3027
+ for (const d of [...dirModes.keys()]) dirRow(d)
2857
3028
  // A link's row: its rel, the target's length and CRC-32 where a file
2858
3029
  // has its size and mtime, and the link type in the mode, so the hit's
2859
3030
  // check compares the target and never follows it.
@@ -2931,7 +3102,7 @@ export class Cache implements CacheLayer {
2931
3102
  }
2932
3103
  return moved
2933
3104
  }
2934
- const tx = this.db.transaction(() => {
3105
+ const index = () => {
2935
3106
  if (inline !== null) {
2936
3107
  aside = moveAside()
2937
3108
  upsertBlob.run(hash, now, inline)
@@ -2977,10 +3148,13 @@ export class Cache implements CacheLayer {
2977
3148
  if (inputComponents !== undefined && inputComponents.length > 0) {
2978
3149
  insertEntryInput.run(hash, encodeEntryInputs(inputComponents))
2979
3150
  }
2980
- })
3151
+ }
2981
3152
  const endTx = span('save: index tx')
2982
3153
  try {
2983
- 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()
2984
3158
  } catch (err) {
2985
3159
  endTx()
2986
3160
  const left = renamed ?? tree ?? tmpPath
@@ -3063,6 +3237,7 @@ export class Cache implements CacheLayer {
3063
3237
  return this.guard(() => this.history.recordRunBundle(bundle))
3064
3238
  }
3065
3239
  recordRemoteErrors(runId: string, errors: readonly (readonly [string, string])[]): void {
3240
+ if (this.writeBlocked !== null) return
3066
3241
  return this.guard(() => this.history.recordRemoteErrors(runId, errors))
3067
3242
  }
3068
3243
  stats(opts: CacheStatsOptions = {}): CacheStats {
@@ -3110,7 +3285,7 @@ export class Cache implements CacheLayer {
3110
3285
  // orphan rows in either order: landed first, the cascade takes them;
3111
3286
  // landed after, the flush's own entry check drops them (item 628).
3112
3287
  this.outputs.flushOutputDirs()
3113
- const { olderThanMs, maxBytes, dryRun = false } = options
3288
+ const { olderThanMs, maxBytes, dry = false } = options
3114
3289
  if (olderThanMs === undefined && maxBytes === undefined) {
3115
3290
  throw new Error('prune: pass at least one of `olderThanMs` or `maxBytes`')
3116
3291
  }
@@ -3140,7 +3315,7 @@ export class Cache implements CacheLayer {
3140
3315
  // Promise.all over the unlinks. The hash list binds as one `json_each`
3141
3316
  // parameter, so a huge eviction stays under any build's bound-parameter
3142
3317
  // ceiling.
3143
- if (dryRun) {
3318
+ if (dry) {
3144
3319
  return { evicted: victims.size, bytesFreed, orphans: picked.files.length, orphanBytes }
3145
3320
  }
3146
3321
  if (victims.size > 0 || stale.length > 0) {
@@ -3181,17 +3356,35 @@ export class Cache implements CacheLayer {
3181
3356
  if (!isIndexFull(err)) throw err
3182
3357
  }
3183
3358
  // History and memo rows a month old go with a prune, never a run's
3184
- // close: five DELETEs on every run's exit was its slowest step (owner,
3185
- // 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`
3186
3361
  // would list a run whose task rows are gone.
3187
3362
  const cutoff = now - HISTORY_RETENTION_MS
3188
3363
  this.history.pruneOlderThan(cutoff)
3189
3364
  this.files.pruneOlderThan(cutoff)
3190
3365
  this.folds.prune(cutoff)
3191
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()
3192
3370
  return { evicted: victims.size, bytesFreed, ...orphans }
3193
3371
  }
3194
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
+
3195
3388
  /**
3196
3389
  * Unlink the row-less files `pickVictims` took: a `<hash>.tar.zst` with
3197
3390
  * no `entries` row (another vx version's open dropped it, a `cache.db`
@@ -3370,11 +3563,7 @@ export class Cache implements CacheLayer {
3370
3563
  }
3371
3564
  }
3372
3565
 
3373
- /**
3374
- * SQLite's file-control opcode (sqlite3.h). Spelled here, not imported from
3375
- * `bun:sqlite`'s `constants`: the playground bundles this file against a
3376
- * shim that exports only `Database`.
3377
- */
3566
+ /** SQLite's file-control opcode (sqlite3.h). */
3378
3567
  const SQLITE_FCNTL_PERSIST_WAL = 10
3379
3568
 
3380
3569
  /**
@@ -3440,6 +3629,32 @@ function assertArtifactNames(
3440
3629
  }
3441
3630
  }
3442
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
+
3443
3658
  /** A row's path as the tree names its file. */
3444
3659
  function treeName(rowPath: string): string {
3445
3660
  return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`