@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -12,7 +12,7 @@
12
12
  // metadata — taskId, command, exitCode, durationMs, storedAt — lives
13
13
  // in the SQLite `entries` row. The same tar.zst bytes ship to a remote
14
14
  // cache server unchanged; on remote-hit, the caller supplies metadata
15
- // via the `ingest(hash, bytes, meta)` API so the local SQL index gets
15
+ // via the `ingest(hash, body, meta)` API so the local SQL index gets
16
16
  // populated without sniffing the artifact.
17
17
  //
18
18
  // We never cache failed runs, so stderr is dropped from the cached
@@ -26,12 +26,23 @@
26
26
  // layer speaks is `CacheLayer` in layer.ts; `plugin-host.ts` enforces it.
27
27
 
28
28
  import { Database, type SQLQueryBindings } from 'bun:sqlite'
29
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
30
- import { mkdir, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
29
+ import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from 'node:fs'
30
+ import { readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
31
31
  import path from 'node:path'
32
- import { relPosix, UserError, xxh3, xxh3hex, span } from '../util/index.js'
32
+ import {
33
+ UserError,
34
+ asTrees,
35
+ formatBytes,
36
+ isDiskFull,
37
+ isFsRefusal,
38
+ relPosix,
39
+ span,
40
+ splitTaskId,
41
+ taskGlob,
42
+ } from '../util/index.js'
33
43
  import {
34
44
  ArchiveSecurityError,
45
+ type ArtifactPlan,
35
46
  type ExecUsage,
36
47
  extractArtifactStream,
37
48
  scanArtifact,
@@ -40,6 +51,7 @@ import {
40
51
  planArtifact,
41
52
  } from './archive.js'
42
53
  import {
54
+ ArtifactVanishedError,
43
55
  type CacheEntry,
44
56
  type CacheGetContext,
45
57
  type CacheKeyInput,
@@ -47,6 +59,7 @@ import {
47
59
  type CacheStats,
48
60
  type CacheStatsOptions,
49
61
  CorruptArtifactError,
62
+ type DeclaredOutputs,
50
63
  type IngestMeta,
51
64
  type InvocationRecord,
52
65
  type OutputDirRow,
@@ -56,72 +69,20 @@ import {
56
69
  type RunRecord,
57
70
  type SaveArgs,
58
71
  type TaskInputRow,
72
+ WORKSPACE_OUTPUT_PREFIX,
59
73
  } from './layer.js'
60
- import { bytesOf, decodedTar, STREAM_DECODE_FROM, zstdEncoder } from './zstd.js'
74
+ import {
75
+ bytesOf,
76
+ decodedTar,
77
+ MAX_DECOMPRESSED_ARTIFACT_BYTES,
78
+ STREAM_DECODE_FROM,
79
+ zstdEncoder,
80
+ } from './zstd.js'
61
81
  import { ConfigEvalTable } from './config-evals.js'
62
82
  import { FileHashStore } from './file-hashes.js'
63
83
  import { OutputIndex } from './output-index.js'
64
84
  import { RunHistory } from './run-history.js'
65
-
66
- // v17: artifact carries only logs + outputs (stdout + outputs/<rel>).
67
- // Local and remote layers transport the SAME tar.zst bytes — no
68
- // separate stage/meta.json/tar.gz dance for remote, no
69
- // `cache-archive.ts`. stderr is no longer cached: we only cache
70
- // successful runs and stderr is rarely meaningful on success.
71
- // v19: '^task' dependsOn expansion switched from transitive-deps to
72
- // nearest-holder frontier — upstream-hash sets shrink, so keys change.
73
- // v20: input-file content hashes switched from xxh3 to git blob OIDs
74
- // (Turbo parity). Clean tracked files take their OID straight from
75
- // the index (harvested by the bulk `git ls-files -s`); dirty /
76
- // untracked files get the identical OID computed in-process. Every
77
- // file's hash bytes change → bump. SCHEMA_VERSION moves with it:
78
- // pre-v20 `file_hashes.content_hash` rows hold xxh3 digests that
79
- // must not leak into the OID domain via the mtime+size memo.
80
- // v22: reverted the v21 output-fold "early cutoff". Downstream keys
81
- // fold the upstream's INPUT key (its task hash) again — pure-input
82
- // transitive hashing (Turbo/Nx model). No output content participates
83
- // in any cache key. SCHEMA v21 drops the now-unused outputs_hash
84
- // column. Early cutoff removed (an upstream that re-emits identical
85
- // output still re-runs dependents) — rare, not worth the cascade.
86
- // v23: fold cache.inputs.runtime / workspaceRuntime command output into
87
- // the key (two namespaced sections after env-values). Command strings
88
- // live in the resolved config (frozen by `vx lock`); the OUTPUT is
89
- // resolved live every run, so it stays correct under --frozen. No
90
- // SCHEMA bump — only Cache.key derivation changed.
91
- // v24: vx-lock.json added to the always-ignored input set (cache/
92
- // inputs.ts ALWAYS_IGNORE). A task whose globs matched the root
93
- // lockfile (broad `**/*` on the root project) drops it from the hashed
94
- // file set, so those keys change; tasks that never matched it are
95
- // byte-identical. No SCHEMA bump — only the input file set changed.
96
- //
97
- // CACHE_VERSION stays v24 through the Tier-3 schema roll (SCHEMA v22).
98
- // Tier 3 persists the very components already fed to `Cache.key()` —
99
- // it adds nothing to, reorders nothing in, and reweights nothing
100
- // inside the key fold (the new `captureInto` sink is a pure
101
- // side-channel). A task's hash is byte-identical before and after, so
102
- // existing artifacts stay valid and there is no cache-version bump.
103
- // v25: the ARTIFACT BYTES are wrong in every existing entry, while the
104
- // key that addresses them is unchanged — the one situation a version
105
- // bump exists for. Two defects: (a) `packArtifact` staged outputs with
106
- // `Bun.write`, which does not carry the source mode, so every artifact
107
- // records 0644 and a cache hit strips the executable bit off any binary
108
- // or generated script; (b) it packed `--format=ustar`, which splits a
109
- // name over 100 bytes into prefix+name, and the reader read only `name`
110
- // — so those entries lost their `outputs/` prefix and were silently
111
- // dropped from both the restore and the output_files index. Neither is
112
- // self-healing: the stored bytes/headers are already wrong, so without a
113
- // new namespace the fixed code would keep replaying them forever.
114
- // v26: same situation, different producer. A task whose child was killed by
115
- // a shutdown signal reported `aborted`, but `aborted` did not propagate to
116
- // dependents — so a dependent ran against PARTIAL outputs, succeeded, and
117
- // cached what it built. The fold takes the upstream's INPUT key, which a
118
- // signal does not change, so that entry sits under exactly the key a healthy
119
- // run derives and replays forever as a green hit. Fixing the propagation
120
- // stops new poison but cannot reach entries already written, and a
121
- // `LayeredCache` uploads them, so the reach is a whole team's shared cache
122
- // rather than one developer's disk. Pre-alpha, so one cold rebuild is the
123
- // cheap side of that trade.
124
- export const CACHE_VERSION = 'vx-cache-v27'
85
+ import { CACHE_VERSION, foldKey } from './key-fold.js'
125
86
 
126
87
  /**
127
88
  * An artifact or temp file without an `entries` row is reaped by
@@ -132,13 +93,38 @@ export const CACHE_VERSION = 'vx-cache-v27'
132
93
  */
133
94
  const ORPHAN_GRACE_MS = 60 * 60 * 1000
134
95
 
96
+ /**
97
+ * The only names the orphan sweep may unlink: an artifact under a key
98
+ * `foldKey` prints (16 lowercase hex) and the temp `tempPath` makes of
99
+ * one. `cacheDir` is the user's to point anywhere (`build/`, a shared
100
+ * directory), and the sweep once took any `*.tar.zst` an hour old there,
101
+ * the user's own release tarball included (item 968).
102
+ */
103
+ const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.tar\.zst(\.tmp-\d+-\d+-[0-9a-z]*)?$/
104
+
105
+ /** The `schema_meta` key holding when the orphan sweep last ran (ms epoch). */
106
+ const SWEPT_AT = 'orphans_swept_at'
107
+
135
108
  export interface SchemaReset {
136
109
  from: string
137
110
  to: string
138
111
  }
139
112
 
113
+ /** SQLite's answer for a file that is not a readable database: corrupt, or not one at all. */
114
+ function unreadableIndex(err: unknown): boolean {
115
+ const code = (err as { code?: unknown }).code
116
+ return typeof code === 'string' && (code.startsWith('SQLITE_CORRUPT') || code === 'SQLITE_NOTADB')
117
+ }
118
+
140
119
  /** Say once, on the channel the opener has, that an upgrade emptied the index. */
141
120
  export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
121
+ if (cache.formatChange !== null) {
122
+ const { from, to } = cache.formatChange
123
+ warn(
124
+ `[vx] cache format changed: ${from} → ${to} (vx upgraded); every cached task misses once and re-saves, and the old entries, never read again, age out under \`vx cache prune --older-than\` or \`cacheRetention\``,
125
+ )
126
+ return
127
+ }
142
128
  if (cache.schemaReset === null) return
143
129
  const { from, to } = cache.schemaReset
144
130
  warn(
@@ -187,7 +173,20 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
187
173
  // tell the history what the task needs. The cache KEY and the
188
174
  // artifact container are unchanged (no CACHE_VERSION bump: an
189
175
  // artifact without the field reads as before).
190
- export const SCHEMA_VERSION = 'v26'
176
+ // v28: output_files.ino + .ctime_ms — the skip-restore check's guard
177
+ // against another entry's bytes. Two entries whose outputs carry
178
+ // one fixed mtime (`tar -x`, `cp -p`, SOURCE_DATE_EPOCH) and one
179
+ // size matched each other's (size, mode, mtime) rows, so a hit left
180
+ // the last entry's bytes on disk under a green run (item 886). A row
181
+ // is current only with the inode and ctime recorded after the save
182
+ // or restore that wrote it. The cache KEY is unchanged.
183
+ export const SCHEMA_VERSION = 'v28'
184
+
185
+ /** A schema version's number (`v28` → 28); one that is not `v<n>` is older than any. */
186
+ function schemaOrdinal(version: string): number {
187
+ const m = /^v(\d+)$/.exec(version)
188
+ return m === null ? -1 : Number(m[1])
189
+ }
191
190
 
192
191
  /**
193
192
  * SQL predicate selecting `runs` rows that record an EXECUTION.
@@ -214,17 +213,6 @@ export const EXECUTED_RUNS_SQL = "status <> 'skipped'"
214
213
  */
215
214
  export const KEYED_RUNS_SQL = "hash <> ''"
216
215
 
217
- /**
218
- * Artifact + `output_files` namespace prefix for workspace-root-
219
- * anchored outputs (`cache.outputs.workspaceFiles`). Project outputs
220
- * keep their bare project-relative `path` rows; workspace rows store
221
- * the full `workspace-outputs/<rel-to-root>` tar entry name as the
222
- * discriminator — least-invasive row format, no schema change. A
223
- * project output dir literally named `workspace-outputs/` would
224
- * collide with the namespace; the name is reserved.
225
- */
226
- export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
227
-
228
216
  // The contract, the policy grammar and the zstd framing live beside this
229
217
  // file; they are re-exported here so an importer of `./cache.js` — the
230
218
  // module index, the sibling layers, the tests — keeps one path.
@@ -273,6 +261,93 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
273
261
  }
274
262
  }
275
263
 
264
+ /**
265
+ * Make `cacheDir` exist and say why this process cannot write into it, or
266
+ * `null` when it can: the directory must take new files (the WAL, the
267
+ * artifacts) and `cache.db`, when it exists, must take pages. The file
268
+ * system's answer, not a trial write — under WAL a rolled-back write never
269
+ * reaches the disk, so `BEGIN IMMEDIATE … ROLLBACK` passes on a handle
270
+ * SQLite opened read-only (proven as an unprivileged user, 2026-09-16).
271
+ *
272
+ * Every verb opens the cache, so each question here is one call whose
273
+ * failure is another question's answer: `access` on the directory is also
274
+ * "does it exist" (made only on ENOENT), `access` on the database also "is
275
+ * it there", and the ignore file is created exclusively instead of probed
276
+ * first. An existing cache costs three calls; it cost six.
277
+ */
278
+ function openCacheDir(cacheDir: string): string | null {
279
+ const ignore = path.join(cacheDir, '.gitignore')
280
+ try {
281
+ accessSync(cacheDir, constants.W_OK)
282
+ } catch (err) {
283
+ makeCacheDir(cacheDir)
284
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return errorText(err)
285
+ writeFileSync(ignore, IGNORE_ALL)
286
+ return null
287
+ }
288
+ try {
289
+ accessSync(path.join(cacheDir, 'cache.db'), constants.W_OK)
290
+ } catch (err) {
291
+ const code = (err as NodeJS.ErrnoException).code
292
+ // A FILE at `cacheDir` passed the `access` above; mkdir names it.
293
+ if (code === 'ENOTDIR') makeCacheDir(cacheDir)
294
+ if (code !== 'ENOENT') return errorText(err)
295
+ refuseManifestDir(cacheDir)
296
+ }
297
+ try {
298
+ writeFileSync(ignore, IGNORE_ALL, { flag: 'wx' })
299
+ } catch (err) {
300
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
301
+ }
302
+ return null
303
+ }
304
+
305
+ // Make the cache dir invisible to git, every time it is created: a `*`
306
+ // .gitignore inside it (the Cargo / Nx convention). Two reasons, both
307
+ // measured. A cache nobody ignored gets COMMITTED by the next `git add -A`;
308
+ // and vx's own `git status -uall` walks it — 1000 artifacts doubled the
309
+ // enumeration on the bench workspace before its generator ignored `.vx`. An
310
+ // ignored directory is skipped by the walk entirely. Only written when
311
+ // absent, so a user's own file wins.
312
+ const IGNORE_ALL = '*\n'
313
+
314
+ /**
315
+ * A first index in a directory that holds a manifest is a cache pointed at
316
+ * the workspace or a project (`cacheDir: ''`, `'.'`, `'packages/a'`): the
317
+ * `*` ignore file above hid every file in it from git, so its inputs
318
+ * matched nothing and a changed source replayed the old output, and the
319
+ * artifacts landed among the sources (item 997). Asked only when there is
320
+ * no index yet, so an open cache pays nothing for it.
321
+ */
322
+ function refuseManifestDir(cacheDir: string): void {
323
+ for (const manifest of ['package.json', 'pnpm-workspace.yaml']) {
324
+ if (!existsSync(path.join(cacheDir, manifest))) continue
325
+ throw new UserError(
326
+ `cache directory ${cacheDir} holds a ${manifest}: it is the workspace's or a project's own ` +
327
+ `directory, and vx would keep its index there under a \`*\` .gitignore that hides every file ` +
328
+ `in it from git and from the cache keys. Point \`cacheDir\` in vx.workspace.ts (or ` +
329
+ `--cache-dir) at a directory of its own, such as .vx/cache.`,
330
+ )
331
+ }
332
+ }
333
+
334
+ function makeCacheDir(cacheDir: string): void {
335
+ try {
336
+ mkdirSync(cacheDir, { recursive: true })
337
+ } catch (err) {
338
+ // A read-only checkout with no cache yet: every verb opens the cache,
339
+ // so this is the first thing any of them says there.
340
+ throw new UserError(
341
+ `cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; make ` +
342
+ `the workspace writable, set \`cacheDir\` in vx.workspace.ts, or pass --cache-dir <path>`,
343
+ )
344
+ }
345
+ }
346
+
347
+ function errorText(err: unknown): string {
348
+ return err instanceof Error ? err.message : String(err)
349
+ }
350
+
276
351
  export class Cache implements CacheLayer {
277
352
  /** This IS the local layer — there is nothing slower behind it. */
278
353
  readonly hasRemote = false
@@ -298,6 +373,10 @@ export class Cache implements CacheLayer {
298
373
  */
299
374
  private readonly read: boolean
300
375
  private readonly write: boolean
376
+ /** A reading verb's handle (`Cache.inspect`): it prunes no history. */
377
+ private readonly inspecting: boolean
378
+ /** Why this process cannot write into `cacheDir`, or `null`; decided at open. */
379
+ private readonly writeBlocked: string | null
301
380
 
302
381
  /**
303
382
  * Set when THIS open found an index written by another `SCHEMA_VERSION`
@@ -309,42 +388,92 @@ export class Cache implements CacheLayer {
309
388
  */
310
389
  readonly schemaReset: SchemaReset | null = null
311
390
 
391
+ /**
392
+ * Set when THIS open found entries written under another
393
+ * `CACHE_VERSION`: the index survives, but no old key is derived again,
394
+ * so every cached task misses once. Reported by `noteSchemaReset`.
395
+ */
396
+ readonly formatChange: SchemaReset | null = null
397
+
398
+ /** A reading verb's open: it never resets the index (the constructor's `mode`). */
399
+ static inspect(cacheDir: string): Cache {
400
+ return new Cache(cacheDir, undefined, undefined, undefined, 'inspect')
401
+ }
402
+
312
403
  constructor(
313
404
  private readonly cacheDir: string,
314
405
  localPolicy: { read: boolean; write: boolean } = { read: true, write: true },
406
+ /** The workspace root, where the file hasher asks git for the object format (file-hashes.ts). */
407
+ repoDir?: string,
408
+ /**
409
+ * The largest artifact, decoded, this cache saves, ingests or
410
+ * restores (zstd.ts). Lowered only by a test, through
411
+ * `RunOptions.artifactCeiling`: 2 GiB of output is out of a test's reach.
412
+ */
413
+ private readonly artifactCeiling: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
414
+ /**
415
+ * `'inspect'`: a reading verb (`why`, `last`, `info`, a dry prune). It
416
+ * never resets the index: a schema it cannot read is refused, named,
417
+ * and left as it was.
418
+ */
419
+ mode: 'open' | 'inspect' = 'open',
315
420
  ) {
421
+ this.inspecting = mode === 'inspect'
316
422
  this.read = localPolicy.read
317
- this.write = localPolicy.write
318
- // Ensure the directory exists before opening the DB — bun:sqlite
319
- // won't create parent dirs for us. The constructor stays sync
320
- // because callers use `new Cache(...)` directly; `mkdirSync` keeps
321
- // that property without a subprocess fork.
322
- mkdirSync(cacheDir, { recursive: true })
323
- // Make the cache dir invisible to git, every time it is created: a
324
- // `*` .gitignore inside it (the Cargo / Nx convention). Two reasons,
325
- // both measured. A cache nobody ignored gets COMMITTED by the next
326
- // `git add -A`; and vx's own `git status -uall` walks it — 1000
327
- // artifacts doubled the enumeration on the bench workspace before
328
- // its generator ignored `.vx`. An ignored directory is skipped by the
329
- // walk entirely. Only written when absent, so a user's own file wins.
330
- const ignore = path.join(cacheDir, '.gitignore')
331
- if (!existsSync(ignore)) writeFileSync(ignore, '*\n')
332
- this.db = new Database(path.join(cacheDir, 'cache.db'), { create: true })
333
- this.db.exec('PRAGMA journal_mode = WAL')
334
- this.db.exec('PRAGMA synchronous = NORMAL')
335
- this.db.exec('PRAGMA foreign_keys = ON')
423
+ // The directory exists before the DB opens — bun:sqlite won't create
424
+ // parent dirs for us. A directory this user cannot write into is a
425
+ // read-only cache for every reader (`vx show`, `why`, `last`, the
426
+ // doctor, the watch sweep): the write axis goes off, so the
427
+ // config-evaluation store and the file-hash memo skip their upserts
428
+ // instead of dying in SQLite on the first miss (an unprivileged user on
429
+ // a root-owned `.vx`, 2026-09-16). A run wants more than a quiet
430
+ // read-only cache — `assertWritable()`.
431
+ // A reading verb over a directory with no index yet reads an empty one
432
+ // in memory and makes nothing on disk: `vx last --cache-dir .vx/cahce`
433
+ // created the typo's directory, a `.gitignore` and a database, then
434
+ // said "no recorded runs yet" (item 900).
435
+ const dbFile = path.join(cacheDir, 'cache.db')
436
+ const absent = mode === 'inspect' && !existsSync(dbFile)
437
+ this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir)
438
+ this.write = localPolicy.write && this.writeBlocked === null
439
+ this.db = new Database(absent ? ':memory:' : dbFile, { create: true })
336
440
  // busy_timeout makes concurrent writers wait for the lock instead of
337
441
  // failing immediately with SQLITE_BUSY. Two parallel `vx run`
338
442
  // invocations in CI is a normal pattern; without this the second one
339
- // crashes in recordRun().
340
- this.db.exec('PRAGMA busy_timeout = 5000')
341
-
342
- this.db.exec(`
343
- CREATE TABLE IF NOT EXISTS schema_meta (
344
- key TEXT PRIMARY KEY,
345
- value TEXT NOT NULL
346
- );
347
- `)
443
+ // crashes in recordRun(). FIRST, before the journal-mode switch: that
444
+ // pragma takes a lock of its own, and set after it the timeout did not
445
+ // cover it — two CLI runs opening one cache at the same instant met
446
+ // `database is locked` right here, on macOS's slower disk first
447
+ // (2026-09-16, the run lock's own e2e; the lock is taken after the
448
+ // cache opens, so the open is the one moment two runs still overlap).
449
+ // A file SQLite cannot read as a database answers the first statement
450
+ // that reads it, and reached every verb as `SQLiteError: file is not a
451
+ // database` and a stack, `vx run` and the doctor included (item 1005).
452
+ const readable = <T>(read: () => T): T => {
453
+ try {
454
+ return read()
455
+ } catch (err) {
456
+ if (!unreadableIndex(err)) throw err
457
+ this.db.close()
458
+ throw new UserError(
459
+ `the cache index ${dbFile} is not a database SQLite can read (${(err as Error).message}) — ` +
460
+ `it holds nothing a run cannot rebuild: remove it with its -wal and -shm files and ` +
461
+ `re-run, and \`vx cache prune\` reclaims the artifacts it indexed`,
462
+ )
463
+ }
464
+ }
465
+ readable(() => {
466
+ this.db.exec('PRAGMA busy_timeout = 5000')
467
+ this.db.exec('PRAGMA journal_mode = WAL')
468
+ this.db.exec('PRAGMA synchronous = NORMAL')
469
+ this.db.exec('PRAGMA foreign_keys = ON')
470
+ this.db.exec(`
471
+ CREATE TABLE IF NOT EXISTS schema_meta (
472
+ key TEXT PRIMARY KEY,
473
+ value TEXT NOT NULL
474
+ );
475
+ `)
476
+ })
348
477
 
349
478
  // Schema-version gate runs BEFORE the rest of the schema lands so
350
479
  // a column rename (e.g. v15's `sha256` → `content_hash`) actually
@@ -353,19 +482,62 @@ export class Cache implements CacheLayer {
353
482
  // sees them (rows first, then the file), the next save of the same
354
483
  // key renames over them, and `prune()`'s orphan sweep unlinks the
355
484
  // rest.
356
- const meta = this.db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as
357
- | { value: string }
358
- | undefined
359
- if (meta && meta.value !== SCHEMA_VERSION) {
360
- this.schemaReset = { from: meta.value, to: SCHEMA_VERSION }
361
- this.db.exec(
362
- 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals;',
363
- )
364
- this.db.prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'").run(SCHEMA_VERSION)
365
- } else if (!meta) {
366
- this.db
367
- .prepare("INSERT INTO schema_meta(key, value) VALUES ('version', ?)")
368
- .run(SCHEMA_VERSION)
485
+ //
486
+ // The write half runs under the write lock and reads the row again: two
487
+ // processes opening one NEW cache both read no row, and the second
488
+ // insert died on the primary key (upstream survey, nx#28608). A warm
489
+ // open reads the current version and takes no lock.
490
+ const readVersion = (): string | undefined =>
491
+ (
492
+ this.db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as
493
+ | { value: string }
494
+ | undefined
495
+ )?.value
496
+ // Only an EARLIER schema is reset, and only by an opener that may write
497
+ // it. A newer one is another vx's index and history: an older binary
498
+ // (a global install beside a workspace's own) dropped every table of it
499
+ // and announced "vx upgraded", and so did a reading verb, a dry prune
500
+ // among them (item 896).
501
+ const refuseUnreadable = (found: string): void => {
502
+ if (schemaOrdinal(found) > schemaOrdinal(SCHEMA_VERSION)) {
503
+ throw new UserError(
504
+ `the cache at ${cacheDir} holds index schema ${found}, written by a newer vx; this vx reads ${SCHEMA_VERSION} and leaves it untouched. Run the newer vx, or give this one another --cache-dir`,
505
+ )
506
+ }
507
+ if (mode === 'inspect') {
508
+ throw new UserError(
509
+ `the cache at ${cacheDir} holds index schema ${found} from an earlier vx; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here. The next \`vx run\` resets it; a reading verb leaves it untouched`,
510
+ )
511
+ }
512
+ }
513
+ const current = readable(readVersion)
514
+ if (current !== SCHEMA_VERSION) {
515
+ try {
516
+ if (current !== undefined) refuseUnreadable(current)
517
+ this.schemaReset = this.db
518
+ .transaction((): SchemaReset | null => {
519
+ const found = readVersion()
520
+ if (found !== undefined && found !== SCHEMA_VERSION) refuseUnreadable(found)
521
+ if (found === undefined) {
522
+ this.db
523
+ .prepare("INSERT INTO schema_meta(key, value) VALUES ('version', ?)")
524
+ .run(SCHEMA_VERSION)
525
+ return null
526
+ }
527
+ if (found === SCHEMA_VERSION) return null
528
+ this.db.exec(
529
+ 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals;',
530
+ )
531
+ this.db
532
+ .prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
533
+ .run(SCHEMA_VERSION)
534
+ return { from: found, to: SCHEMA_VERSION }
535
+ })
536
+ .immediate()
537
+ } catch (err) {
538
+ this.db.close()
539
+ throw err
540
+ }
369
541
  }
370
542
 
371
543
  // Cached config evaluations (workspace/config-cache.ts): the validated
@@ -426,7 +598,16 @@ export class Cache implements CacheLayer {
426
598
  attempts INTEGER,
427
599
  -- v25: 1 when the task declared a cache block, 0 when it runs every
428
600
  -- time by design; NULL on rows older than the column.
429
- cached INTEGER
601
+ cached INTEGER,
602
+ -- v27: why a task failed or was skipped, as the run's own footer
603
+ -- said it (items 267–270): a skip's root blocker (a task id), vx's
604
+ -- own timeout (1), the sandbox's violation count, and why a
605
+ -- persistent task never became ready ('timeout' | 'exited' |
606
+ -- 'spawn'). NULL where the reason does not apply.
607
+ blocked_by TEXT,
608
+ timed_out INTEGER,
609
+ sandbox_violations INTEGER,
610
+ not_ready TEXT
430
611
  );
431
612
  -- Two whole-table indexes only, and both APPEND: every row of a run carries the
432
613
  -- same run_id and a started_at newer than everything before it, so
@@ -483,14 +664,13 @@ export class Cache implements CacheLayer {
483
664
  size_bytes INTEGER NOT NULL,
484
665
  mode INTEGER NOT NULL,
485
666
  mtime_ms INTEGER NOT NULL,
667
+ -- v28: the inode and ctime this machine saw after the save or
668
+ -- restore that left the file equal to the entry; NULL until then.
669
+ ino INTEGER,
670
+ ctime_ms INTEGER,
486
671
  PRIMARY KEY (entry_hash, path),
487
672
  FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
488
673
  );
489
- -- Every directory under a whole-subtree output glob, with its mtime
490
- -- as of the last save/restore on THIS machine (2026-09-03). On a warm
491
- -- hit, unchanged mtimes prove the output SET is unchanged, replacing
492
- -- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
493
- -- ingest writes none, and the first hit after it walks and records.
494
674
  -- Each config's ORDERED import closure (the config first), so a warm
495
675
  -- load keys it by stat-hashing the list (the file_hashes memo) instead
496
676
  -- of reading and scanning every file. Machine-local; pruned with
@@ -500,6 +680,11 @@ export class Cache implements CacheLayer {
500
680
  files_json TEXT NOT NULL,
501
681
  created_at INTEGER NOT NULL
502
682
  );
683
+ -- Every directory under a whole-subtree output glob, with its mtime
684
+ -- as of the last save/restore on THIS machine (2026-09-03). On a warm
685
+ -- hit, unchanged mtimes prove the output SET is unchanged, replacing
686
+ -- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
687
+ -- ingest writes none, and the first hit after it walks and records.
503
688
  CREATE TABLE IF NOT EXISTS output_dirs (
504
689
  entry_hash TEXT NOT NULL,
505
690
  path TEXT NOT NULL,
@@ -588,10 +773,29 @@ export class Cache implements CacheLayer {
588
773
  `)
589
774
  // The slices: each owns its statements over this handle and its table(s);
590
775
  // the schema above is the one place every table is declared.
591
- this.files = new FileHashStore(this.db, cacheDir)
592
- this.configEvals = new ConfigEvalTable(this.db, localPolicy)
776
+ this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
777
+ this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
593
778
  this.outputs = new OutputIndex(this.db)
594
779
  this.history = new RunHistory(this.db)
780
+
781
+ // A CACHE_VERSION bump keeps the index but moves every key, so the run
782
+ // after an upgrade misses everything. Roadmap 3.3: that is announced,
783
+ // never silent. A store with no recorded version and no entries is
784
+ // new; one with entries predates the record (item 671).
785
+ const format = this.db
786
+ .prepare("SELECT value FROM schema_meta WHERE key = 'cache_version'")
787
+ .get() as { value: string } | null
788
+ if (format?.value !== CACHE_VERSION && this.writeBlocked === null) {
789
+ const hasEntries = this.db.prepare('SELECT 1 FROM entries LIMIT 1').get() != null
790
+ if (this.schemaReset === null && (format !== null || hasEntries)) {
791
+ this.formatChange = { from: format?.value ?? 'an earlier format', to: CACHE_VERSION }
792
+ }
793
+ this.db
794
+ .prepare(
795
+ "INSERT INTO schema_meta(key, value) VALUES ('cache_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
796
+ )
797
+ .run(CACHE_VERSION)
798
+ }
595
799
  }
596
800
 
597
801
  // --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
@@ -610,113 +814,47 @@ export class Cache implements CacheLayer {
610
814
  putConfigEval(key: string, json: string): void {
611
815
  this.configEvals.putConfigEval(key, json)
612
816
  }
817
+ putConfigEvals(entries: ReadonlyArray<readonly [string, string]>): void {
818
+ this.configEvals.putConfigEvals(entries)
819
+ }
820
+ putConfigClosures(entries: ReadonlyArray<readonly [string, readonly string[]]>): void {
821
+ this.configEvals.putConfigClosures(entries)
822
+ }
613
823
  // --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
614
824
  hashFile(filePath: string): Promise<string> {
615
825
  return this.files.hashFile(filePath)
616
826
  }
827
+ hashBytes(bytes: Uint8Array, nearPath: string): string {
828
+ return this.files.hashBytes(bytes, nearPath)
829
+ }
617
830
  hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
618
831
  return this.files.hashFiles(paths)
619
832
  }
620
- async key(input: CacheKeyInput): Promise<string> {
621
- // Seed-chained xxHash3: each step folds one field into the
622
- // running digest via `xxh3(part, prevDigest)`. Equivalent to the
623
- // old CryptoHasher.update() pattern, no intermediate buffer.
624
- // Field-order matters; each line is prefixed with its label so
625
- // adjacent fields can't collide via concat.
626
- // Optional capture sink — see CacheKeyInput.captureInto. Pure
627
- // side-channel; every push below mirrors the fold immediately
628
- // above/beside it so the recorded set can't drift from the key.
629
- const cap = input.captureInto
630
- let h = xxh3(CACHE_VERSION)
631
- h = xxh3(`task:${input.taskId}`, h)
632
- h = xxh3(`workspace:${input.workspaceFingerprint}`, h)
633
- h = xxh3(`pkg:${input.projectPackageJsonHash}`, h)
634
- h = xxh3(`config:${input.taskConfigHash}`, h)
635
- if (cap) {
636
- cap.push({ kind: 'workspace', name: 'fingerprint', hash: input.workspaceFingerprint })
637
- cap.push({ kind: 'package', name: 'package.json', hash: input.projectPackageJsonHash })
638
- cap.push({ kind: 'config', name: 'config', hash: input.taskConfigHash })
639
- }
640
-
641
- // NOTE on `cap` (entry_inputs capture): the `hash` field stores a
642
- // DIGEST of each component, never the raw value. For value-bearing kinds
643
- // (env, runtime, ws-runtime, forward) the payload is a secret or
644
- // sensitive string (API keys via cache.inputs.env, runtime-command
645
- // output, args after `--`), so we push `xxh3hex(v)` — the diff consumer
646
- // only needs to know whether a component CHANGED, which a digest
647
- // preserves losslessly. cache.db must never hold plaintext secrets at
648
- // rest. The cache KEY (`h`) folds the plaintext separately below and is
649
- // unaffected.
650
- const forwarded = input.forwardArgs ?? []
651
- h = xxh3(`forward-args:${forwarded.length}`, h)
652
- for (const a of forwarded) h = xxh3(a, h)
653
- if (cap && forwarded.length > 0) {
654
- cap.push({ kind: 'forward', name: 'argv', hash: xxh3hex(JSON.stringify(forwarded)) })
655
- }
656
-
657
- h = xxh3(`env-values:${input.envValues.length}`, h)
658
- // \0 delimiter, not `=`: names and values may themselves contain
659
- // `=`, and `A` + `B=C` must never fold the same bytes as `A=B` + `C`.
660
- for (const [n, v] of input.envValues) h = xxh3(`${n}\0${v}`, h)
661
- if (cap)
662
- for (const [n, v] of input.envValues) cap.push({ kind: 'env', name: n, hash: xxh3hex(v) })
663
-
664
- const runtimeValues = input.runtimeValues ?? []
665
- h = xxh3(`runtime-values:${runtimeValues.length}`, h)
666
- for (const [c, o] of runtimeValues) h = xxh3(`${c}\0${o}`, h)
667
- if (cap)
668
- for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash: xxh3hex(o) })
669
-
670
- const wsRuntimeValues = input.workspaceRuntimeValues ?? []
671
- h = xxh3(`ws-runtime-values:${wsRuntimeValues.length}`, h)
672
- for (const [c, o] of wsRuntimeValues) h = xxh3(`${c}\0${o}`, h)
673
- if (cap)
674
- for (const [c, o] of wsRuntimeValues)
675
- cap.push({ kind: 'ws-runtime', name: c, hash: xxh3hex(o) })
676
-
677
- const upstream = [...input.upstreamHashes].sort()
678
- h = xxh3(`upstream:${upstream.length}`, h)
679
- for (const u of upstream) h = xxh3(u, h)
680
- if (cap) {
681
- for (const u of upstream) {
682
- cap.push({ kind: 'upstream', name: input.upstreamIds?.get(u) ?? u, hash: u })
683
- }
684
- }
685
-
686
- const pluginParts = input.pluginParts ?? []
687
- if (pluginParts.length > 0) {
688
- h = xxh3(`plugin:${pluginParts.length}`, h)
689
- for (const [n, v] of pluginParts) h = xxh3(`${n}\0${v}`, h)
690
- if (cap) {
691
- for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: xxh3hex(v) })
692
- }
833
+ /**
834
+ * `relPosix` against the run's workspace root, memoized: the same three
835
+ * thousand files are re-relativized for every task of the project, which
836
+ * is 132,000 calls for 3,000 answers on this repo's own gate. Cleared
837
+ * when a caller arrives with a different root, so the memo can never
838
+ * answer for a workspace it did not measure.
839
+ */
840
+ private relMemo = new Map<string, string>()
841
+ private relMemoRoot: string | undefined
842
+ private relFor(root: string, file: string): string {
843
+ if (root !== this.relMemoRoot) {
844
+ this.relMemoRoot = root
845
+ this.relMemo.clear()
693
846
  }
847
+ let rel = this.relMemo.get(file)
848
+ if (rel === undefined) this.relMemo.set(file, (rel = relPosix(root, file)))
849
+ return rel
850
+ }
694
851
 
695
- const sortedInputs = [...input.inputFiles].sort()
696
- h = xxh3(`inputs:${sortedInputs.length}`, h)
697
- // Per-file hash source, in preference order: the caller-supplied
698
- // index-OID map (clean tracked files — zero I/O), then hashFile's
699
- // stat memo (no read), then a full in-process blob-OID computation.
700
- // All three describe the WORKTREE bytes, which is what the task
701
- // reads — the index-OID map is only populated for paths whose
702
- // worktree form matches their index form, because a clean filter
703
- // (`text`/`eol`/`ident`) makes the index blob a DIFFERENT sequence
704
- // of bytes and folding it would let two distinct worktree contents
705
- // share a key. The fold order is locked to `sortedInputs` so
706
- // results are stable across runs.
707
- const fileHashes = await Promise.all(
708
- sortedInputs.map((f) => input.fileHashes?.get(f) ?? this.hashFile(f)),
852
+ key(input: CacheKeyInput): Promise<string> {
853
+ return foldKey(
854
+ input,
855
+ (f) => this.hashFile(f),
856
+ (f) => this.relFor(input.workspaceRoot, f),
709
857
  )
710
- for (let i = 0; i < sortedInputs.length; i++) {
711
- const file = sortedInputs[i]!
712
- const rel = relPosix(input.workspaceRoot, file)
713
- const oid = fileHashes[i]!
714
- h = xxh3(`${rel}\0${oid}`, h)
715
- // The OID is already awaited — file capture is zero extra I/O.
716
- if (cap) cap.push({ kind: 'file', name: rel, hash: oid })
717
- }
718
-
719
- return h.toString(16).padStart(16, '0')
720
858
  }
721
859
 
722
860
  // ctx is accepted but ignored — the local layer reads metadata from
@@ -841,6 +979,9 @@ export class Cache implements CacheLayer {
841
979
  ): Promise<void> {
842
980
  await this.outputs.recordOutputDirs(hash, projectDir, prefixes)
843
981
  }
982
+ recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
983
+ this.outputs.recordOutputStamps(hash, projectDir, workspaceRoot)
984
+ }
844
985
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
845
986
  return this.outputs.loadOutputDirsBatch(hashes)
846
987
  }
@@ -861,16 +1002,22 @@ export class Cache implements CacheLayer {
861
1002
  // replay through the logger.
862
1003
  const src = this.tarPath(hash)
863
1004
  // The caller already committed to this hit and WIPED the declared
864
- // outputs, so returning quietly here would report a green cache hit over
865
- // an emptied output tree. The artifact existed when `get()` probed it, so
866
- // its absence now means something removed it underneath us (a concurrent
867
- // `vx cache prune` is the documented way) — fail loud; the task re-runs.
868
- const endExists = span('restore: exists')
869
- const exists = await Bun.file(src).exists()
870
- endExists()
871
- if (!exists) {
872
- throw new CorruptArtifactError(hash, 'artifact file vanished before restore')
873
- }
1005
+ // outputs, so a restore that did not fail here would report a green cache
1006
+ // hit over an emptied output tree. The artifact existed when `get()`
1007
+ // probed it, so its absence now means something removed it underneath us
1008
+ // (a `vx cache prune` in another shell, or another workspace's retention
1009
+ // on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
1010
+ // turns into a miss: the task runs.
1011
+ //
1012
+ // The FAILING is held twice: the decode below reaches the missing file
1013
+ // and throws `CorruptArtifactError` from the extract catch either way.
1014
+ // What the `ENOENT` mapping in that catch carries alone is the CLASS,
1015
+ // and the two ask for opposite answers — a prune raced this run (run
1016
+ // the task) versus the cache holds bad bytes (fail, and say so). The
1017
+ // roundtrip row asserts the class (item 481); a row that only asserts
1018
+ // "the restore fails" passes with the mapping deleted. It was a
1019
+ // separate `exists()` probe until item 627: one thread-pool round trip
1020
+ // per restore, spent to learn what the read reports itself.
874
1021
  const endRows = span('restore: rows')
875
1022
  // The index says exactly which files this entry materializes. If the
876
1023
  // archive cannot produce one of them, restoring "successfully" leaves a
@@ -909,14 +1056,25 @@ export class Cache implements CacheLayer {
909
1056
  // 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
910
1057
  // four workers overlap their round trips and a blocking one stalls
911
1058
  // the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
912
- const tar = await decodedTar(Bun.file(src), hash)
1059
+ const tar = await decodedTar(Bun.file(src), hash, this.artifactCeiling)
913
1060
  await extractArtifactStream(tar, projectDir, workspaceRoot, verify)
914
1061
  } catch (err) {
915
- if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
1062
+ // A UserError here is the extractor naming the tree's fault (an
1063
+ // output directory that links out of the project).
1064
+ if (
1065
+ err instanceof ArchiveSecurityError ||
1066
+ err instanceof CorruptArtifactError ||
1067
+ err instanceof UserError
1068
+ ) {
1069
+ throw err
1070
+ }
916
1071
  // What is on disk, not what is in the archive: a directory standing
917
1072
  // where the entry holds a file, or a file where it needs a directory.
918
1073
  // The clean removes everything the output globs cover, so this is a
919
- // stray they do not — name it as such, not as a corrupt artifact.
1074
+ // stray they do not — name it as such, not as a corrupt artifact. A
1075
+ // link on the way never lands here: the extractor writes through one
1076
+ // that stays in the project, dangling or not, and names one that
1077
+ // leaves it or loops (item 748).
920
1078
  const code = (err as NodeJS.ErrnoException).code
921
1079
  if (code === 'EISDIR' || code === 'ENOTDIR' || code === 'EEXIST' || code === 'ENOTEMPTY') {
922
1080
  throw new UserError(
@@ -924,12 +1082,65 @@ export class Cache implements CacheLayer {
924
1082
  `Declared outputs are wiped before a restore, so this is a path the output globs do not cover — remove it and re-run.`,
925
1083
  )
926
1084
  }
1085
+ // A legal name under a destination deep enough that the two together
1086
+ // pass PATH_MAX: the artifact is fine, the workspace's location is
1087
+ // not (the parity audit's last open archive row, item 670).
1088
+ if (code === 'ENAMETOOLONG') {
1089
+ throw new UserError(
1090
+ `restore of ${hash} into ${projectDir} could not write its outputs (${code}: ${(err as Error).message}). ` +
1091
+ `An output path under this directory is longer than the file system allows — move the workspace to a shorter path.`,
1092
+ )
1093
+ }
1094
+ // Same distinction for a tree the process cannot write into — a
1095
+ // `dist/` another user owns, a read-only checkout, a full disk: the
1096
+ // artifact is intact, the tree is not the process's to change.
1097
+ if (isFsRefusal(err)) {
1098
+ const remedy = isDiskFull(err)
1099
+ ? 'Free space on that disk and re-run.'
1100
+ : 'Make the declared output paths writable by this user, or stop declaring them as outputs.'
1101
+ throw new UserError(
1102
+ `restore of ${hash} into ${projectDir} could not write its outputs (${code}: ${err.message}). ${remedy}`,
1103
+ )
1104
+ }
1105
+ // The artifact itself is gone: the read names its path (`bytes()`
1106
+ // opens it; a staged file's ENOENT names the temp below).
1107
+ if (code === 'ENOENT' && (err as NodeJS.ErrnoException).path === src) {
1108
+ throw new ArtifactVanishedError(hash)
1109
+ }
1110
+ // A staged file the restore itself just wrote is gone before its
1111
+ // commit: another process cleaned the same outputs under us (two runs
1112
+ // on one workspace, a `vx watch` beside a `vx run` — a clean landing
1113
+ // 4–32 ms into a 2,000-file restore reproduces it, 2026-09-16). The
1114
+ // artifact is intact; the tree was not ours alone.
1115
+ if (code === 'ENOENT' && (err as Error).message.includes('.vx-tmp-')) {
1116
+ throw new UserError(
1117
+ `restore of ${hash} into ${projectDir} was interrupted: a file it had just written vanished (${(err as Error).message}). ` +
1118
+ `Another vx run is using this workspace — re-run once it is done.`,
1119
+ )
1120
+ }
927
1121
  throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
928
1122
  } finally {
929
1123
  endExtract()
930
1124
  }
931
1125
  }
932
1126
 
1127
+ /**
1128
+ * A run writes here on every path — the run record at its end, an
1129
+ * entry's `accessed_at` on a hit, the artifact on a miss — so a cache
1130
+ * directory this user cannot write into fails the run before the graph
1131
+ * starts, once, with the directory named, rather than every task at
1132
+ * 0 ms (or the run at its very end) with SQLite's "attempt to write a
1133
+ * readonly database" as an internal error. The readers open the same
1134
+ * directory read-only and go on; a run refuses.
1135
+ */
1136
+ assertWritable(): void {
1137
+ if (this.writeBlocked === null) return
1138
+ throw new UserError(
1139
+ `cache directory ${this.cacheDir} is not writable (${this.writeBlocked}) — every run records its ` +
1140
+ `history there; make it writable by this user, or pass --cache-dir <path>`,
1141
+ )
1142
+ }
1143
+
933
1144
  async save(args: {
934
1145
  hash: string
935
1146
  /** See `CacheLayer.save` — `exitCode` is not a caller's to supply. */
@@ -961,7 +1172,10 @@ export class Cache implements CacheLayer {
961
1172
  // the bytes, since there's no local artifact to read off disk.
962
1173
  if (!this.write) return
963
1174
  if (args.skipLocalWrite === true) return
964
- await mkdir(this.cacheDir, { recursive: true })
1175
+ // The cache directory is the constructor's (`mkdirSync`, with a
1176
+ // refusal recorded for `assertWritable`); re-creating it here and
1177
+ // again in `writeArtifactAndIndex` cost every save two thread-pool
1178
+ // round trips for an EEXIST and a stat (item 630).
965
1179
  const endPack = span('save: pack')
966
1180
  const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args)
967
1181
  endPack()
@@ -991,20 +1205,26 @@ export class Cache implements CacheLayer {
991
1205
  return this.packArtifact(args)
992
1206
  }
993
1207
 
994
- async ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
995
- await this.writeArtifactAndIndex(hash, compressed, meta)
996
- }
997
-
998
1208
  /**
999
- * Collect stdout + outputs into artifact bytes, zstd-compress, return
1000
- * them. No disk write to the final cache path — that's the index
1001
- * step's job. Pure transform, so `ingest()` can skip this and hand
1002
- * its remote-supplied bytes straight to `writeArtifactAndIndex`.
1003
- *
1004
- * Entries are named directly into the archive, so there is no staging
1005
- * copy of every output byte and no `tar` subprocess (see
1006
- * `archive.ts`).
1209
+ * The remote body goes to the temp by `Bun.write`, which streams a
1210
+ * `Response` and copies a file `Blob` without collecting either, so a
1211
+ * pull never holds the artifact. A body that fails mid-stream (a dropped
1212
+ * socket) leaves a partial temp behind it, removed here; validation
1213
+ * removes its own.
1007
1214
  */
1215
+ async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
1216
+ const tmpPath = this.tempPath(hash)
1217
+ try {
1218
+ // Split only for the typings: Bun.write's Response and Blob overloads
1219
+ // do not accept their union.
1220
+ await (body instanceof Response ? Bun.write(tmpPath, body) : Bun.write(tmpPath, body))
1221
+ } catch (err) {
1222
+ await unlink(tmpPath).catch(() => undefined)
1223
+ throw err
1224
+ }
1225
+ await this.writeArtifactAndIndex(hash, { tmpPath }, meta)
1226
+ }
1227
+
1008
1228
  /** Archive name → absolute source path for every declared output. */
1009
1229
  private outputsOf(args: {
1010
1230
  projectDir: string
@@ -1027,9 +1247,17 @@ export class Cache implements CacheLayer {
1027
1247
  /**
1028
1248
  * The compressed artifact, in memory: the remote upload when local
1029
1249
  * writes are off. A large artifact is packed and compressed as a
1030
- * stream and collected — the seam takes bytes.
1250
+ * stream and collected — with no local artifact the bytes must be
1251
+ * captured while the outputs are still on disk.
1252
+ *
1253
+ * Both packers name entries directly into the archive — no staging
1254
+ * copy of the outputs, no `tar` subprocess (`archive.ts`) — and
1255
+ * neither writes the final cache path, which is the index step's job:
1256
+ * `ingest()` packs nothing and hands its remote-supplied temp straight
1257
+ * to `writeArtifactAndIndex`.
1031
1258
  */
1032
1259
  private async packArtifact(args: {
1260
+ hash: string
1033
1261
  entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
1034
1262
  projectDir: string
1035
1263
  outputFiles: string[]
@@ -1039,14 +1267,33 @@ export class Cache implements CacheLayer {
1039
1267
  // stdout is ALWAYS present in the artifact, even if empty, so the
1040
1268
  // layout is predictable: a successful read finds `stdout` and
1041
1269
  // zero-or-more `outputs/<rel>` / `workspace-outputs/<rel>` entries.
1270
+ const plan = await this.planWithin(args)
1271
+ if (plan.size <= STREAM_DECODE_FROM)
1272
+ return await Bun.zstdCompress(await packArtifactBytes(plan))
1273
+ return bytesOf(packArtifactStream(plan).pipeThrough(zstdEncoder()))
1274
+ }
1275
+
1276
+ /**
1277
+ * The artifact's plan, refused when its tar — exactly the bytes a restore
1278
+ * decodes — is past the ceiling every restore enforces. Refused here, it
1279
+ * costs a stat per output; left to the scan, it cost the whole compress
1280
+ * and a decode of a 2.2 GB output (6 to 14 s) to learn the same thing
1281
+ * and call the task's own outputs a "corrupt artifact".
1282
+ */
1283
+ private async planWithin(args: Parameters<Cache['packArtifact']>[0]): Promise<ArtifactPlan> {
1042
1284
  const plan = await planArtifact({
1285
+ key: args.hash,
1043
1286
  stdout: args.entry.stdout ?? '',
1044
1287
  outputs: this.outputsOf(args),
1045
1288
  exec: usageOfEntry(args.entry),
1046
1289
  })
1047
- if (plan.size <= STREAM_DECODE_FROM)
1048
- return await Bun.zstdCompress(await packArtifactBytes(plan))
1049
- return bytesOf(packArtifactStream(plan).pipeThrough(zstdEncoder()))
1290
+ if (plan.size > this.artifactCeiling) {
1291
+ throw new Error(
1292
+ `${args.entry.taskId} is not cached: its outputs pack to ${formatBytes(plan.size)}, past the ` +
1293
+ `${formatBytes(this.artifactCeiling)} artifact ceiling a restore enforces — narrow cache.outputs.files`,
1294
+ )
1295
+ }
1296
+ return plan
1050
1297
  }
1051
1298
 
1052
1299
  /**
@@ -1062,11 +1309,7 @@ export class Cache implements CacheLayer {
1062
1309
  tmpPath: string,
1063
1310
  args: Parameters<Cache['packArtifact']>[0],
1064
1311
  ): Promise<Uint8Array | { tmpPath: string }> {
1065
- const plan = await planArtifact({
1066
- stdout: args.entry.stdout ?? '',
1067
- outputs: this.outputsOf(args),
1068
- exec: usageOfEntry(args.entry),
1069
- })
1312
+ const plan = await this.planWithin(args)
1070
1313
  if (plan.size <= STREAM_DECODE_FROM)
1071
1314
  return await Bun.zstdCompress(await packArtifactBytes(plan))
1072
1315
  const sink = Bun.file(tmpPath).writer()
@@ -1085,13 +1328,6 @@ export class Cache implements CacheLayer {
1085
1328
  return { tmpPath }
1086
1329
  }
1087
1330
 
1088
- /**
1089
- * Atomically write `compressed` to `<hash>.tar.zst` and (re)build the
1090
- * entries + output_files SQL rows from the archive itself. Shared by
1091
- * `save()` (we just packed the bytes) and `ingest()` (we got them from
1092
- * the remote layer) — both index the identical values, because both
1093
- * read them out of the artifact.
1094
- */
1095
1331
  /** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
1096
1332
  * the same hash from the same process (or from two forked workers that
1097
1333
  * happen to share a wall-clock ms) don't pick the same tmp filename and
@@ -1100,13 +1336,20 @@ export class Cache implements CacheLayer {
1100
1336
  return `${this.tarPath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
1101
1337
  }
1102
1338
 
1339
+ /**
1340
+ * Atomically write `compressed` to `<hash>.tar.zst` and (re)build the
1341
+ * entries + output_files SQL rows from the archive itself. Shared by
1342
+ * `save()` (we just packed the bytes) and `ingest()` (it streamed them
1343
+ * from the remote layer into a temp) — both index the identical values, because both
1344
+ * read them out of the artifact.
1345
+ */
1103
1346
  private async writeArtifactAndIndex(
1104
1347
  hash: string,
1105
1348
  compressed: Uint8Array | { tmpPath: string },
1106
1349
  meta: IngestMeta,
1107
1350
  ): Promise<void> {
1108
1351
  // Validate BEFORE anything touches the final path. `ingest()` feeds
1109
- // us network bytes; a truncated/garbage body that went live first
1352
+ // us a temp of network bytes; a truncated/garbage body that went live first
1110
1353
  // would leave a corrupt `<hash>.tar.zst` behind (with no SQL row,
1111
1354
  // since the decompress throw aborted indexing) for every later
1112
1355
  // reader to trip over. Decompress + parse also produce the
@@ -1117,7 +1360,6 @@ export class Cache implements CacheLayer {
1117
1360
  let tmpPath: string
1118
1361
  if (compressed instanceof Uint8Array) {
1119
1362
  tmpPath = this.tempPath(hash)
1120
- await mkdir(this.cacheDir, { recursive: true })
1121
1363
  // The temp is written BEFORE validation so a large artifact can be
1122
1364
  // scanned from the file as it decodes — a file stream reads in
1123
1365
  // bounded pieces; the bytes in memory would not (see `decodedTar`).
@@ -1128,12 +1370,12 @@ export class Cache implements CacheLayer {
1128
1370
  await writeFile(tmpPath, compressed)
1129
1371
  endWrite()
1130
1372
  } else {
1131
- // `save` already streamed the artifact into its temp.
1373
+ // `save` or `ingest` already streamed the artifact into its temp.
1132
1374
  tmpPath = compressed.tmpPath
1133
1375
  }
1134
1376
  let scanned: Awaited<ReturnType<typeof scanArtifact>>
1135
1377
  try {
1136
- // ingest() is the UNTRUSTED boundary — `compressed` is bytes just
1378
+ // ingest() is the UNTRUSTED boundary — its temp holds bytes just
1137
1379
  // pulled from a remote. Refuse a bomb (declared or sizeless) before it
1138
1380
  // can expand into memory.
1139
1381
  const source =
@@ -1141,13 +1383,22 @@ export class Cache implements CacheLayer {
1141
1383
  ? compressed
1142
1384
  : Bun.file(tmpPath)
1143
1385
  const endScan = span('save: scan')
1144
- scanned = await scanArtifact(await decodedTar(source, hash))
1386
+ scanned = await scanArtifact(await decodedTar(source, hash, this.artifactCeiling))
1145
1387
  endScan()
1146
1388
  // v17 invariant: every artifact carries a `stdout` entry. Its
1147
1389
  // absence means the bytes decompressed but aren't a vx artifact.
1148
1390
  if (scanned.stdout === null) {
1149
1391
  throw new CorruptArtifactError(hash, 'missing stdout entry')
1150
1392
  }
1393
+ if (scanned.key !== hash) {
1394
+ throw new CorruptArtifactError(
1395
+ hash,
1396
+ scanned.key === undefined
1397
+ ? 'artifact records no cache key'
1398
+ : `artifact was stored under another key (${scanned.key})`,
1399
+ )
1400
+ }
1401
+ assertArtifactNames(hash, scanned.entries, meta.outputs)
1151
1402
  } catch (err) {
1152
1403
  await unlink(tmpPath).catch(() => undefined)
1153
1404
  if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
@@ -1280,6 +1531,7 @@ export class Cache implements CacheLayer {
1280
1531
  }
1281
1532
  stats(opts: CacheStatsOptions = {}): CacheStats {
1282
1533
  this.flushAccessed()
1534
+ this.outputs.flushOutputDirs()
1283
1535
  const project = opts.project
1284
1536
  const scoped = project !== undefined
1285
1537
  const scopeParams: string[] = project === undefined ? [] : [project]
@@ -1306,8 +1558,57 @@ export class Cache implements CacheLayer {
1306
1558
  }
1307
1559
  }
1308
1560
 
1561
+ /**
1562
+ * The workspace's `cacheRetention`, applied at the end of a run: `prune()`
1563
+ * with the same policy, but only when it would evict something, else the
1564
+ * orphan sweep alone when an hour has passed since the last one. A run
1565
+ * with nothing due pays the accessed-at flush it owed at close anyway, one
1566
+ * scan of the index and one read of the sweep's clock — never the
1567
+ * sweep's readdir. Null when nothing was due or this handle does not write.
1568
+ */
1569
+ async evictIfDue(
1570
+ policy: { maxAgeMs?: number; maxBytes?: number },
1571
+ now: number = Date.now(),
1572
+ ): Promise<PruneResult | null> {
1573
+ if (!this.write) return null
1574
+ // First: an entry this run restored still carries its old `accessed_at`
1575
+ // until the deferred bump lands, and would read as due for eviction.
1576
+ this.flushAccessed()
1577
+ const olderThanMs = policy.maxAgeMs === undefined ? undefined : now - policy.maxAgeMs
1578
+ const { oldest, bytes } = this.db
1579
+ .prepare(
1580
+ 'SELECT MIN(accessed_at) AS oldest, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries',
1581
+ )
1582
+ .get() as { oldest: number | null; bytes: number }
1583
+ const ageDue = olderThanMs !== undefined && oldest !== null && oldest < olderThanMs
1584
+ const sizeDue = policy.maxBytes !== undefined && bytes > policy.maxBytes
1585
+ if (ageDue || sizeDue) {
1586
+ return this.prune({
1587
+ ...(olderThanMs !== undefined ? { olderThanMs } : {}),
1588
+ ...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
1589
+ })
1590
+ }
1591
+ // Row-less artifacts are bytes the index cannot count, so the policy
1592
+ // above never sees them (upstream survey, nx#35483: 9 MiB of them sat
1593
+ // under a 1 MB limit). Listing the directory to find them costs 0.5 ms
1594
+ // per 1,000 entries (4.9 ms at 10,000), every run; the sweep's own
1595
+ // clock in `schema_meta` costs one indexed read. An orphan is reapable
1596
+ // only an hour after its last write anyway, so an hourly sweep reaps
1597
+ // it within two.
1598
+ const swept = this.db.prepare('SELECT value FROM schema_meta WHERE key = ?').get(SWEPT_AT) as {
1599
+ value: string
1600
+ } | null
1601
+ if (swept !== null && now - Number(swept.value) < ORPHAN_GRACE_MS) return null
1602
+ return { evicted: 0, bytesFreed: 0, ...(await this.reapOrphans(now)) }
1603
+ }
1604
+
1309
1605
  async prune(options: PruneOptions): Promise<PruneResult> {
1310
1606
  this.flushAccessed()
1607
+ // Before any entry is deleted, so a kept entry's pending snapshot is on
1608
+ // disk whatever the eviction does next. A pruned hash's snapshot cannot
1609
+ // orphan rows in either order: landed first, the cascade takes them;
1610
+ // landed after, the flush's own entry check drops them (item 628).
1611
+ this.outputs.flushOutputDirs()
1311
1612
  const { olderThanMs, maxBytes, dryRun = false } = options
1312
1613
  if (olderThanMs === undefined && maxBytes === undefined) {
1313
1614
  throw new Error('prune: pass at least one of `olderThanMs` or `maxBytes`')
@@ -1315,12 +1616,18 @@ export class Cache implements CacheLayer {
1315
1616
 
1316
1617
  const victims = new Set<string>()
1317
1618
  let bytesFreed = 0
1619
+ // Rows whose artifact is gone (deleted by hand, or by a disk cleaner):
1620
+ // never a hit, and their bytes are on no disk, but `--max-size` counted
1621
+ // them and evicted real entries to make room for them (item 975). They
1622
+ // are dropped, neither evicted nor freed.
1623
+ const phantoms = await this.phantomRows()
1318
1624
 
1319
1625
  if (olderThanMs !== undefined) {
1320
1626
  const rows = this.db
1321
1627
  .prepare('SELECT hash, size_bytes FROM entries WHERE accessed_at < ?')
1322
1628
  .all(olderThanMs) as Array<{ hash: string; size_bytes: number }>
1323
1629
  for (const r of rows) {
1630
+ if (phantoms.has(r.hash)) continue
1324
1631
  victims.add(r.hash)
1325
1632
  bytesFreed += r.size_bytes
1326
1633
  }
@@ -1330,7 +1637,9 @@ export class Cache implements CacheLayer {
1330
1637
  const totalRow = this.db
1331
1638
  .prepare('SELECT COALESCE(SUM(size_bytes), 0) AS bytes FROM entries')
1332
1639
  .get() as { bytes: number }
1333
- let remaining = totalRow.bytes - bytesFreed
1640
+ let phantomBytes = 0
1641
+ for (const b of phantoms.values()) phantomBytes += b
1642
+ let remaining = totalRow.bytes - phantomBytes - bytesFreed
1334
1643
  if (remaining > maxBytes) {
1335
1644
  // Exclude already-picked victims in JS, not via a SQL NOT-IN —
1336
1645
  // an IN-list over tens of thousands of TTL victims would blow
@@ -1339,7 +1648,7 @@ export class Cache implements CacheLayer {
1339
1648
  this.db
1340
1649
  .prepare('SELECT hash, size_bytes FROM entries ORDER BY accessed_at ASC')
1341
1650
  .all() as Array<{ hash: string; size_bytes: number }>
1342
- ).filter((row) => !victims.has(row.hash))
1651
+ ).filter((row) => !victims.has(row.hash) && !phantoms.has(row.hash))
1343
1652
  for (const row of candidates) {
1344
1653
  if (remaining <= maxBytes) break
1345
1654
  victims.add(row.hash)
@@ -1364,11 +1673,12 @@ export class Cache implements CacheLayer {
1364
1673
  orphanBytes: orphans.orphanBytes,
1365
1674
  }
1366
1675
  }
1367
- if (victims.size > 0) {
1676
+ if (victims.size > 0 || phantoms.size > 0) {
1368
1677
  const hashes = [...victims]
1678
+ const rows = [...hashes, ...phantoms.keys()]
1369
1679
  this.db.transaction(() => {
1370
- for (let i = 0; i < hashes.length; i += 900) {
1371
- const chunk = hashes.slice(i, i + 900)
1680
+ for (let i = 0; i < rows.length; i += 900) {
1681
+ const chunk = rows.slice(i, i + 900)
1372
1682
  this.db
1373
1683
  .prepare(`DELETE FROM entries WHERE hash IN (${chunk.map(() => '?').join(',')})`)
1374
1684
  .run(...(chunk as readonly SQLQueryBindings[]))
@@ -1392,7 +1702,12 @@ export class Cache implements CacheLayer {
1392
1702
  * exists while the bytes are still being written, so a fresh file
1393
1703
  * without a row is a save in flight, not an orphan.
1394
1704
  */
1395
- private async reapOrphans(): Promise<{ orphans: number; orphanBytes: number }> {
1705
+ private async reapOrphans(
1706
+ now: number = Date.now(),
1707
+ ): Promise<{ orphans: number; orphanBytes: number }> {
1708
+ this.db
1709
+ .prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
1710
+ .run(SWEPT_AT, String(now))
1396
1711
  let orphans = 0
1397
1712
  let orphanBytes = 0
1398
1713
  await Promise.all(
@@ -1411,6 +1726,29 @@ export class Cache implements CacheLayer {
1411
1726
  return { orphans, orphanBytes }
1412
1727
  }
1413
1728
 
1729
+ /**
1730
+ * Index rows whose artifact is not in the directory, with their recorded
1731
+ * sizes. Only rows last written or used before the grace window: a save
1732
+ * renames its artifact in before its row commits, so a fresh row without
1733
+ * one is a listing that raced a save, not a phantom. An unreadable
1734
+ * directory judges nothing.
1735
+ */
1736
+ private async phantomRows(): Promise<Map<string, number>> {
1737
+ let names: string[]
1738
+ try {
1739
+ names = await readdir(this.cacheDir)
1740
+ } catch {
1741
+ return new Map()
1742
+ }
1743
+ const present = new Set(names)
1744
+ const rows = this.db
1745
+ .prepare('SELECT hash, size_bytes FROM entries WHERE accessed_at < ?')
1746
+ .all(Date.now() - ORPHAN_GRACE_MS) as Array<{ hash: string; size_bytes: number }>
1747
+ const out = new Map<string, number>()
1748
+ for (const r of rows) if (!present.has(`${r.hash}.tar.zst`)) out.set(r.hash, r.size_bytes)
1749
+ return out
1750
+ }
1751
+
1414
1752
  /** What `prune()` would reap right now, for `vx info` to say before anyone prunes. */
1415
1753
  async orphanStats(): Promise<{ orphans: number; orphanBytes: number }> {
1416
1754
  const found = await this.scanOrphans()
@@ -1435,11 +1773,9 @@ export class Cache implements CacheLayer {
1435
1773
  const cutoff = Date.now() - ORPHAN_GRACE_MS
1436
1774
  const candidates: string[] = []
1437
1775
  for (const name of names) {
1438
- if (name.indexOf('.tar.zst.tmp-') > 0) {
1439
- candidates.push(name)
1440
- } else if (name.endsWith('.tar.zst') && !indexed.has(name.slice(0, -'.tar.zst'.length))) {
1441
- candidates.push(name)
1442
- }
1776
+ const m = VX_ARTIFACT_NAME.exec(name)
1777
+ if (m === null) continue
1778
+ if (m[2] !== undefined || !indexed.has(m[1]!)) candidates.push(name)
1443
1779
  }
1444
1780
  const found: Array<{ file: string; size: number }> = []
1445
1781
  await Promise.all(
@@ -1466,15 +1802,21 @@ export class Cache implements CacheLayer {
1466
1802
  // header would outlive its `runs` rows, so `vx info`/`vx last` would
1467
1803
  // list an invocation whose task detail is already gone, and the
1468
1804
  // table would grow unbounded on a long-lived checkout.
1805
+ // A reading verb and a dry run delete nothing: `vx last --list` over
1806
+ // month-old runs emptied the history it listed, and `vx cache prune
1807
+ // --dry-run` pruned it (item 1004).
1469
1808
  try {
1470
- const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
1471
- this.history.pruneOlderThan(cutoff)
1472
- this.configEvals.pruneOlderThan(cutoff)
1809
+ if (!this.inspecting) {
1810
+ const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
1811
+ this.history.pruneOlderThan(cutoff)
1812
+ this.configEvals.pruneOlderThan(cutoff)
1813
+ }
1473
1814
  } catch {
1474
1815
  // Retention is best-effort; never block closing the handle.
1475
1816
  }
1476
1817
  try {
1477
1818
  this.flushAccessed()
1819
+ this.outputs.flushOutputDirs()
1478
1820
  } catch {
1479
1821
  // Same contract as the retention prune above, which was already
1480
1822
  // guarded while this sibling was not: `accessed_at` is LRU
@@ -1493,8 +1835,45 @@ export class Cache implements CacheLayer {
1493
1835
  }
1494
1836
  }
1495
1837
 
1496
- function splitTaskId(id: string): [string, string] {
1497
- const i = id.indexOf('#')
1498
- if (i < 0) return [id, '']
1499
- return [id.slice(0, i), id.slice(i + 1)]
1838
+ /**
1839
+ * Refuse an artifact whose file names no restore can materialise as one
1840
+ * tree, or (given the task's declared outputs) that names a file outside
1841
+ * them. Both reached the tree before: `outputs/out.txt` beside
1842
+ * `outputs/out.txt/x` failed every later run from the local copy, blaming
1843
+ * what was on disk, and an undeclared name overwrote an input or planted a
1844
+ * git hook under a green remote hit (item 942). Thrown at ingest, so the
1845
+ * bytes never reach the local store and the remote read is a miss.
1846
+ */
1847
+ function assertArtifactNames(
1848
+ hash: string,
1849
+ entries: ReadonlyArray<{ name: string }>,
1850
+ declared: DeclaredOutputs | undefined,
1851
+ ): void {
1852
+ const names = new Set(entries.map((e) => e.name))
1853
+ for (const name of names) {
1854
+ for (let sep = name.indexOf('/'); sep !== -1; sep = name.indexOf('/', sep + 1)) {
1855
+ if (names.has(name.slice(0, sep))) {
1856
+ throw new CorruptArtifactError(
1857
+ hash,
1858
+ `artifact holds ${name.slice(0, sep)} as a file and as a directory`,
1859
+ )
1860
+ }
1861
+ }
1862
+ }
1863
+ if (declared === undefined) return
1864
+ const files = asTrees(declared.files).map(taskGlob)
1865
+ const wsFiles = asTrees(declared.workspaceFiles).map(taskGlob)
1866
+ for (const name of names) {
1867
+ const ok = name.startsWith('outputs/')
1868
+ ? files.some((g) => g.match(name.slice('outputs/'.length)))
1869
+ : name.startsWith(WORKSPACE_OUTPUT_PREFIX)
1870
+ ? wsFiles.some((g) => g.match(name.slice(WORKSPACE_OUTPUT_PREFIX.length)))
1871
+ : true
1872
+ if (!ok) {
1873
+ throw new CorruptArtifactError(
1874
+ hash,
1875
+ `artifact carries ${name}, which is not one of the task's declared outputs`,
1876
+ )
1877
+ }
1878
+ }
1500
1879
  }