@vzn/vx 0.0.643 → 0.0.644

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 (43) hide show
  1. package/package.json +5 -5
  2. package/schemas/info.json +0 -11
  3. package/skills/vx/SKILL.md +1 -1
  4. package/src/cache/archive.ts +153 -2
  5. package/src/cache/cache.ts +416 -176
  6. package/src/cache/file-hashes.ts +2 -14
  7. package/src/cache/fold-memo.ts +7 -3
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/key-fold.ts +4 -1
  10. package/src/cache/layer.ts +9 -4
  11. package/src/cache/layered-cache.ts +55 -3
  12. package/src/cache/plugin-cache.ts +33 -32
  13. package/src/cache/remote-lane.ts +12 -7
  14. package/src/cache/tar-stream.ts +12 -7
  15. package/src/cli/format.ts +1 -2
  16. package/src/cli/help.ts +6 -0
  17. package/src/cli/info.ts +2 -19
  18. package/src/cli/plugin-commands.ts +1 -1
  19. package/src/cli/run.ts +3 -0
  20. package/src/cli/upgrade.ts +38 -8
  21. package/src/config.ts +15 -10
  22. package/src/exec/sandbox-paths.ts +2 -2
  23. package/src/graph/scheduler.ts +3 -1
  24. package/src/graph/task-graph.ts +15 -11
  25. package/src/index.ts +1 -1
  26. package/src/orchestrator/doctor.ts +0 -17
  27. package/src/orchestrator/execute-task.ts +1 -1
  28. package/src/orchestrator/options.ts +6 -0
  29. package/src/orchestrator/plugin-host.ts +6 -2
  30. package/src/orchestrator/plugin.ts +4 -1
  31. package/src/orchestrator/prepare.ts +16 -3
  32. package/src/orchestrator/projects.ts +8 -1
  33. package/src/orchestrator/remote-prefetch.ts +11 -4
  34. package/src/orchestrator/run-context.ts +10 -5
  35. package/src/orchestrator/run-settings.ts +3 -0
  36. package/src/orchestrator/run.ts +8 -26
  37. package/src/report/logger.ts +0 -4
  38. package/src/report/summary.ts +0 -11
  39. package/src/settings.ts +43 -14
  40. package/src/util/index.ts +1 -0
  41. package/src/util/paths.ts +42 -33
  42. package/src/util/size.ts +4 -5
  43. package/src/workspace/config-schema.ts +0 -36
@@ -49,7 +49,7 @@ import {
49
49
  utimesSync,
50
50
  writeFileSync,
51
51
  } from 'node:fs'
52
- import { readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
52
+ import { chmod, lstat, mkdir, readdir, rm, stat, unlink, utimes, writeFile } from 'node:fs/promises'
53
53
  import path from 'node:path'
54
54
  import { createArtifactTables, createTables, inHashes, lazyStatement } from './schema.js'
55
55
  import {
@@ -76,7 +76,12 @@ import {
76
76
  packArtifactBytes,
77
77
  packArtifactStream,
78
78
  planArtifact,
79
+ planOfTree,
80
+ sealTree,
81
+ treeIntact,
82
+ writeTree,
79
83
  } from './archive.js'
84
+ import { readUmask, restoredMode } from './umask.js'
80
85
  import {
81
86
  ArtifactVanishedError,
82
87
  type CacheEntry,
@@ -119,7 +124,7 @@ import { behindLink } from './linked-inputs.js'
119
124
  * An artifact or temp file without an `entries` row is never reaped
120
125
  * younger than this: a crashed save's temp is stale long before it. A
121
126
  * row-less artifact may still be in use (another vx version's open dropped
122
- * its row), so past this it is judged by the retention policy, its file
127
+ * its row), so past this it is judged by the prune policy, its file
123
128
  * time standing for `accessed_at`. A hit renews a file time older than
124
129
  * this, so the file time trails the last use by at most this much, and
125
130
  * the policy reads the file time plus this as the last use.
@@ -137,7 +142,7 @@ const ADOPT_STATS = 64
137
142
  * A hit renews an entry's `accessed_at` only once it is older than this:
138
143
  * renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
139
144
  * close, ~30 ms of it. So `accessed_at` trails the last use by at most
140
- * this much, and the retention policy reads it plus this as the last use:
145
+ * this much, and the prune policy reads it plus this as the last use:
141
146
  * an entry used within the limit is never pruned, and one may stay up to
142
147
  * this long past it (owner, 2026-10-08).
143
148
  */
@@ -150,7 +155,7 @@ const ACCESS_REFRESH_MS = 60 * 60 * 1000
150
155
  * directory), and the sweep once took any `*.tar.zst` an hour old there,
151
156
  * the user's own release tarball included (item 968).
152
157
  */
153
- const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.tar\.zst(\.tmp-\d+-\d+-[0-9a-z]*)?$/
158
+ const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.(?:tar\.zst|tree)(\.tmp-\d+-\d+-[0-9a-z]*)?$/
154
159
 
155
160
  /** What vx writes in a cache directory besides its artifacts. */
156
161
  const VX_CACHE_NAMES = new Set([
@@ -186,8 +191,8 @@ export function vxCacheEntries(cacheDir: string): string[] {
186
191
  })
187
192
  }
188
193
 
189
- /** The `schema_meta` key holding when the orphan sweep last ran (ms epoch). */
190
- const SWEPT_AT = 'orphans_swept_at'
194
+ /** What `vx cache clean` keeps of run history and the hash memos. */
195
+ const HISTORY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
191
196
 
192
197
  export interface SchemaReset {
193
198
  from: string
@@ -295,7 +300,7 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
295
300
  // output_files' ino + ctime_ms moved to the workspace's own
296
301
  // output_stamps, output_dirs lost its foreign key, and the value
297
302
  // salt moved to store_meta. The cache KEY is unchanged.
298
- // v33: an artifact of at most INLINE_MAX compressed bytes lives in the
303
+ // v33: an artifact of at most 32 KiB compressed lives in the
299
304
  // store's `artifacts` table, written in its rows' transaction, and
300
305
  // every entry read joins it. The artifact bytes and the cache KEY are
301
306
  // unchanged (CACHE_VERSION not bumped).
@@ -370,13 +375,6 @@ const ARTIFACT_TABLES = ['artifacts', 'artifacts_meta']
370
375
  */
371
376
  const ARTIFACT_LAYOUT = 'a2'
372
377
 
373
- /**
374
- * The largest compressed artifact kept in the index rather than in a file
375
- * of its own: it is written in its rows' transaction, with no temp and no
376
- * rename (docs/design/cache-save-cpu-2026-10.md).
377
- */
378
- const INLINE_MAX = 32 * 1024
379
-
380
378
  /**
381
379
  * An index that records a schema other than this vx's: the one an opener
382
380
  * that writes resets. Reads the recorded version alone and touches
@@ -509,8 +507,9 @@ async function scanOrphanFiles(
509
507
  const file = path.join(cacheDir, name)
510
508
  try {
511
509
  const st = await stat(file)
512
- if (st.isFile() && st.mtimeMs <= cutoff) {
513
- found.push({ file, size: st.size, mtimeMs: st.mtimeMs, temp })
510
+ if ((name.includes('.tree') ? st.isDirectory() : st.isFile()) && st.mtimeMs <= cutoff) {
511
+ const size = st.isDirectory() ? await treeBytes(file) : st.size
512
+ found.push({ file, size, mtimeMs: st.mtimeMs, temp })
514
513
  }
515
514
  } catch {
516
515
  // Gone between readdir and stat: not an orphan any more.
@@ -520,6 +519,16 @@ async function scanOrphanFiles(
520
519
  return found
521
520
  }
522
521
 
522
+ /** The bytes a tree-form artifact's files hold. */
523
+ async function treeBytes(dir: string): Promise<number> {
524
+ let n = 0
525
+ for (const e of await readdir(dir, { recursive: true, withFileTypes: true }).catch(() => [])) {
526
+ if (e.isFile())
527
+ n += (await stat(path.join(e.parentPath, e.name)).catch(() => ({ size: 0 }))).size
528
+ }
529
+ return n
530
+ }
531
+
523
532
  /**
524
533
  * SQL predicate selecting `runs` rows that record an EXECUTION.
525
534
  *
@@ -613,7 +622,7 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
613
622
  * it there", and the ignore file is created exclusively instead of probed
614
623
  * first. An existing cache costs three calls; it cost six.
615
624
  */
616
- function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
625
+ function openCacheDir(cacheDir: string, workspaceRoot?: string, index = 'cache.db'): string | null {
617
626
  const [ignore, ignoreText] = ignoreFileFor(cacheDir)
618
627
  try {
619
628
  accessSync(cacheDir, constants.W_OK)
@@ -624,7 +633,7 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
624
633
  return null
625
634
  }
626
635
  try {
627
- accessSync(path.join(cacheDir, 'cache.db'), constants.W_OK)
636
+ accessSync(path.join(cacheDir, index), constants.W_OK)
628
637
  } catch (err) {
629
638
  const code = (err as NodeJS.ErrnoException).code
630
639
  // A FILE at `cacheDir` passed the `access` above; mkdir names it.
@@ -649,7 +658,7 @@ function storeBlocked(storeDir: string, storeRoot: string | null): string | null
649
658
  if (refused !== null) return refused
650
659
  }
651
660
  }
652
- return openCacheDir(storeDir)
661
+ return openCacheDir(storeDir, undefined, 'store.db')
653
662
  } catch (err) {
654
663
  return errorText(err)
655
664
  }
@@ -788,6 +797,33 @@ function errorText(err: unknown): string {
788
797
  return err instanceof Error ? err.message : String(err)
789
798
  }
790
799
 
800
+ /** A tree the save wrote from its own outputs, with the scan its tar would give. */
801
+ interface SavedTree {
802
+ tree: string
803
+ scan: Awaited<ReturnType<typeof scanArtifact>>
804
+ bytes: number
805
+ }
806
+
807
+ /** How a save keeps an entry locally (`cache.store`). */
808
+ export interface StoreOptions {
809
+ /**
810
+ * The largest entry kept in the index, as one zstd tar (fewer files to
811
+ * make): written in its rows' transaction, with no temp and no rename
812
+ * (docs/design/cache-save-cpu-2026-10.md). 0 keeps every entry as files.
813
+ */
814
+ embeddings: number
815
+ /** A file-backed entry is a `<hash>.tree` of plain files; off, one `<hash>.tar.zst`. */
816
+ decompress: boolean
817
+ /** What a tree restore checks first: each file's size and mtime, every byte, or nothing. */
818
+ verify: 'stat' | 'hash' | 'none'
819
+ }
820
+
821
+ export const DEFAULT_STORE: StoreOptions = {
822
+ embeddings: 32 * 1024,
823
+ decompress: true,
824
+ verify: 'stat',
825
+ }
826
+
791
827
  /** What a run's background uploads moved: totals over the ones that landed. */
792
828
  export interface UploadTally {
793
829
  count: number
@@ -939,6 +975,7 @@ export class Cache implements CacheLayer {
939
975
  * records none (deleted, or never written beside a run's store).
940
976
  */
941
977
  storeRoot?: string | null,
978
+ private readonly store: StoreOptions = DEFAULT_STORE,
942
979
  ) {
943
980
  this.inspecting = mode !== 'open'
944
981
  this.read = localPolicy.read
@@ -1677,7 +1714,7 @@ export class Cache implements CacheLayer {
1677
1714
  } else {
1678
1715
  // Verify the artifact file actually exists. The DB and the
1679
1716
  // filesystem can drift if someone manually deletes the cache dir.
1680
- const st = statSync(this.tarPath(hash), { throwIfNoEntry: false })
1717
+ const st = this.artifactStat(hash)
1681
1718
  if (st === undefined) return null
1682
1719
  if (st.mtimeMs < Date.now() - ORPHAN_GRACE_MS) this.staleTimes.add(hash)
1683
1720
  }
@@ -1717,7 +1754,7 @@ export class Cache implements CacheLayer {
1717
1754
  names = []
1718
1755
  }
1719
1756
  this.artifactNames ??= new Set(names)
1720
- return !names.some((n) => n.endsWith('.tar.zst'))
1757
+ return !names.some((n) => n.endsWith('.tar.zst') || n.endsWith('.tree'))
1721
1758
  }
1722
1759
 
1723
1760
  /**
@@ -1766,7 +1803,7 @@ export class Cache implements CacheLayer {
1766
1803
  if (r.inline_at < graceStart) this.staleInline.add(r.hash)
1767
1804
  return true
1768
1805
  }
1769
- const st = statSync(this.tarPath(r.hash), { throwIfNoEntry: false })
1806
+ const st = this.artifactStat(r.hash)
1770
1807
  if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
1771
1808
  return st !== undefined
1772
1809
  })
@@ -1811,7 +1848,7 @@ export class Cache implements CacheLayer {
1811
1848
  private storedHere(hash: string): boolean {
1812
1849
  const row = this.entryExists.get(hash) as { inline_at: number | null } | null
1813
1850
  if (row === null) return false
1814
- return row.inline_at !== null || existsSync(this.tarPath(hash))
1851
+ return row.inline_at !== null || this.artifactStat(hash) !== undefined
1815
1852
  }
1816
1853
 
1817
1854
  // Local cache has no slower layer to warm from — prefetch is a no-op.
@@ -1886,9 +1923,120 @@ export class Cache implements CacheLayer {
1886
1923
  }
1887
1924
  }
1888
1925
 
1926
+ /**
1927
+ * Restore a tree-form entry: its files checked as `cache.store.verify`
1928
+ * says, then copied (or cloned) out one by one where the outputs are
1929
+ * absent, as on every restore after a clean. Anything else (a tree to
1930
+ * restore into, a workspace output, a link) goes through the extractor
1931
+ * the tar form uses, fed the tree packed back into its tar.
1932
+ */
1933
+ private async restoreTree(
1934
+ hash: string,
1935
+ tree: string,
1936
+ rows: readonly OutputFileRow[],
1937
+ projectDir: string,
1938
+ workspaceRoot: string | undefined,
1939
+ verify: (provided: ReadonlySet<string>) => void,
1940
+ accept: ((name: string) => boolean) | undefined,
1941
+ ): Promise<void> {
1942
+ const files = rows.filter((r) => !r.path.endsWith('/') && r.mode !== LINK_MODE)
1943
+ let bytes = 0
1944
+ for (const r of files) bytes += r.size
1945
+ if (bytes > this.artifactCeiling) {
1946
+ throw new CorruptArtifactError(
1947
+ hash,
1948
+ `its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
1949
+ )
1950
+ }
1951
+ if (this.store.verify === 'stat') {
1952
+ const changed = await Promise.all(
1953
+ files.map((r) =>
1954
+ stat(path.join(tree, treeName(r.path))).then(
1955
+ (st) => st.size !== r.size || Math.floor(st.mtimeMs) !== r.mtimeMs,
1956
+ () => true,
1957
+ ),
1958
+ ),
1959
+ )
1960
+ if (changed.includes(true)) {
1961
+ throw new CorruptArtifactError(hash, 'a stored file changed since it was saved')
1962
+ }
1963
+ } else if (this.store.verify === 'hash' && !(await treeIntact(tree, this.stdoutText(hash)))) {
1964
+ throw new CorruptArtifactError(hash, 'the stored files no longer sum to what was saved')
1965
+ }
1966
+ if (await this.copyTree(tree, rows, files, projectDir, accept)) return
1967
+ await extractArtifactStream(
1968
+ packArtifactStream(planOfTree(tree, this.stdoutText(hash))),
1969
+ projectDir,
1970
+ workspaceRoot,
1971
+ verify,
1972
+ accept,
1973
+ workspaceRoot,
1974
+ )
1975
+ }
1976
+
1977
+ /** The copy-out: false, having written nothing, where it is not the case it serves. */
1978
+ private async copyTree(
1979
+ tree: string,
1980
+ rows: readonly OutputFileRow[],
1981
+ files: readonly OutputFileRow[],
1982
+ projectDir: string,
1983
+ accept: ((name: string) => boolean) | undefined,
1984
+ ): Promise<boolean> {
1985
+ const tops = new Set<string>()
1986
+ for (const r of rows) {
1987
+ if (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) || r.mode === LINK_MODE) return false
1988
+ if (accept !== undefined && !r.path.endsWith('/') && !accept(`outputs/${r.path}`))
1989
+ return false
1990
+ tops.add(r.path.split('/')[0]!)
1991
+ }
1992
+ const held = await Promise.all(
1993
+ [...tops].map((t) =>
1994
+ lstat(path.join(projectDir, t)).then(
1995
+ () => true,
1996
+ () => false,
1997
+ ),
1998
+ ),
1999
+ )
2000
+ if (held.includes(true)) return false
2001
+ readUmask()
2002
+ const plain = restoredMode(0o666)
2003
+ try {
2004
+ const dirs = new Set<string>()
2005
+ for (const r of rows) {
2006
+ dirs.add(path.join(projectDir, r.path.endsWith('/') ? r.path : path.dirname(r.path)))
2007
+ }
2008
+ for (const d of dirs) await mkdir(d, { recursive: true })
2009
+ await Promise.all(
2010
+ files.map(async (r) => {
2011
+ const to = path.join(projectDir, r.path)
2012
+ await Bun.write(to, Bun.file(path.join(tree, treeName(r.path))), { createPath: false })
2013
+ const mode = restoredMode(r.mode)
2014
+ if (mode !== plain) await chmod(to, mode)
2015
+ const at = new Date(r.mtimeMs)
2016
+ await utimes(to, at, at)
2017
+ }),
2018
+ )
2019
+ } catch (err) {
2020
+ await Promise.all(
2021
+ [...tops].map((t) => rm(path.join(projectDir, t), { recursive: true, force: true })),
2022
+ )
2023
+ throw err
2024
+ }
2025
+ return true
2026
+ }
2027
+
2028
+ private stdoutText(hash: string): string {
2029
+ const row = this.db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(hash) as {
2030
+ stdout: string | Uint8Array
2031
+ } | null
2032
+ if (row === null) return ''
2033
+ return typeof row.stdout === 'string' ? row.stdout : utf8Decode.decode(row.stdout)
2034
+ }
2035
+
1889
2036
  /** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
1890
2037
  private async dropEntry(hash: string): Promise<void> {
1891
2038
  await rm(this.tarPath(hash), { force: true })
2039
+ await rm(this.treePath(hash), { recursive: true, force: true })
1892
2040
  const drop = this.db.transaction(() => {
1893
2041
  this.deleteBlob.run(hash)
1894
2042
  this.deleteEntryRow.run(hash)
@@ -1924,7 +2072,7 @@ export class Cache implements CacheLayer {
1924
2072
  // outputs, so a restore that did not fail here would report a green cache
1925
2073
  // hit over an emptied output tree. The artifact existed when `get()`
1926
2074
  // probed it, so its absence now means something removed it underneath us
1927
- // (a `vx cache clean` in another shell, or another workspace's retention
2075
+ // (a `vx cache clean` in another shell, here or for another workspace
1928
2076
  // on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
1929
2077
  // turns into a miss: the task runs.
1930
2078
  //
@@ -1986,6 +2134,8 @@ export class Cache implements CacheLayer {
1986
2134
  // when every artifact is a one-file `dist/` (measured: 390 vs 355 ms
1987
2135
  // per 1 000). The local artifact was validated at ingest, so a missing
1988
2136
  // declared size is allowed; the output ceiling applies to both.
2137
+ const tree =
2138
+ inline === undefined && existsSync(this.treePath(hash)) ? this.treePath(hash) : undefined
1989
2139
  const endExtract = span('restore: extract')
1990
2140
  try {
1991
2141
  // Every step here is an async filesystem call on purpose: a
@@ -1993,15 +2143,13 @@ export class Cache implements CacheLayer {
1993
2143
  // 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
1994
2144
  // four workers overlap their round trips and a blocking one stalls
1995
2145
  // the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
1996
- const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
1997
- await extractArtifactStream(
1998
- tar,
1999
- projectDir,
2000
- workspaceRoot,
2001
- verify,
2002
- outputs === undefined ? undefined : declaredName(outputs),
2003
- workspaceRoot,
2004
- )
2146
+ const accept = outputs === undefined ? undefined : declaredName(outputs)
2147
+ if (tree !== undefined) {
2148
+ await this.restoreTree(hash, tree, rows, projectDir, workspaceRoot, verify, accept)
2149
+ } else {
2150
+ const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
2151
+ await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
2152
+ }
2005
2153
  } catch (err) {
2006
2154
  // A UserError here is the extractor naming the tree's fault (an
2007
2155
  // output directory that links out of the project).
@@ -2052,7 +2200,13 @@ export class Cache implements CacheLayer {
2052
2200
  // artifact is intact, the tree is not the process's to change.
2053
2201
  // A refusal to READ the artifact is the cache directory's, not the
2054
2202
  // outputs': the old line sent the reader to the wrong tree (A-40).
2055
- if (isFsRefusal(err) && err.path === src) {
2203
+ // A tree copy's error can name its source; no read fails ENOSPC.
2204
+ if (
2205
+ isFsRefusal(err) &&
2206
+ code !== 'ENOSPC' &&
2207
+ code !== 'EDQUOT' &&
2208
+ (err.path === src || (tree !== undefined && inTree(err.path, tree)))
2209
+ ) {
2056
2210
  throw new UserError(
2057
2211
  `restore of ${hash} could not read its artifact (${code}: ${err.message}). ` +
2058
2212
  `Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is.`,
@@ -2164,23 +2318,21 @@ export class Cache implements CacheLayer {
2164
2318
  // again in `writeArtifactAndIndex` cost every save two thread-pool
2165
2319
  // round trips for an EEXIST and a stat (item 630).
2166
2320
  const endPack = span('save: pack')
2167
- const packed = await this.packArtifactToTemp(this.tempPath(args.hash), args).catch(
2168
- (err: unknown) => {
2169
- // A save that could not open an output names the limit, not only
2170
- // the file, which is fine (A-39).
2171
- if (isOutOfFds(err)) {
2172
- throw new UserError(
2173
- `save of ${args.hash} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
2174
- )
2175
- }
2176
- throw err
2177
- },
2178
- )
2321
+ const packed = await this.packForSave(args).catch((err: unknown) => {
2322
+ // A save that could not open an output names the limit, not only
2323
+ // the file, which is fine (A-39).
2324
+ if (isOutOfFds(err)) {
2325
+ throw new UserError(
2326
+ `save of ${args.hash} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
2327
+ )
2328
+ }
2329
+ throw err
2330
+ })
2179
2331
  endPack()
2180
2332
  await this.guard(() =>
2181
2333
  this.writeArtifactAndIndex(
2182
2334
  args.hash,
2183
- 'tar' in packed ? packed.compressed : packed,
2335
+ 'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
2184
2336
  {
2185
2337
  taskId: args.entry.taskId,
2186
2338
  command: args.entry.command,
@@ -2220,11 +2372,30 @@ export class Cache implements CacheLayer {
2220
2372
  * gone; `release` unlinks the name. An inline artifact is read once into
2221
2373
  * the body, which no re-save can change.
2222
2374
  */
2223
- pinArtifact(hash: string): { body: Blob; release: () => Promise<void> } {
2375
+ async pinArtifact(hash: string): Promise<{ body: Blob; release: () => Promise<void> }> {
2224
2376
  const blob = this.guard(() => this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
2225
2377
  if (blob !== null) return { body: new Blob([blob.bytes]), release: async () => {} }
2226
2378
  const pinned = this.tempPath(hash)
2227
- linkSync(this.tarPath(hash), pinned)
2379
+ try {
2380
+ linkSync(this.tarPath(hash), pinned)
2381
+ } catch (err) {
2382
+ const tree = this.treePath(hash)
2383
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || !existsSync(tree)) throw err
2384
+ // A tree is packed into the artifact a remote expects here, in the
2385
+ // upload queue, off the save; a small one in memory.
2386
+ const plan = planOfTree(tree, this.stdoutText(hash))
2387
+ if (plan.size <= STREAM_DECODE_FROM) {
2388
+ const bytes = await zstdCompress(await packArtifactBytes(plan))
2389
+ return { body: new Blob([bytes]), release: async () => {} }
2390
+ }
2391
+ await Bun.write(
2392
+ pinned,
2393
+ new Response(packArtifactStream(plan).pipeThrough(zstdEncoder())),
2394
+ ).catch(async (e: unknown) => {
2395
+ await unlink(pinned).catch(() => undefined)
2396
+ throw e
2397
+ })
2398
+ }
2228
2399
  return { body: Bun.file(pinned), release: () => unlink(pinned).catch(() => undefined) }
2229
2400
  }
2230
2401
 
@@ -2233,7 +2404,7 @@ export class Cache implements CacheLayer {
2233
2404
  * `Response` and copies a file `Blob` without collecting either, so a
2234
2405
  * pull never holds the artifact. A body that fails mid-stream (a dropped
2235
2406
  * socket) leaves a partial temp behind it, removed here; validation
2236
- * removes its own. A body of at most `INLINE_MAX` bytes — by its size,
2407
+ * removes its own. A body of at most `cache.store.embeddings` bytes — by its size,
2237
2408
  * or counted as it streams — is read to memory and stored inline.
2238
2409
  */
2239
2410
  async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
@@ -2253,22 +2424,28 @@ export class Cache implements CacheLayer {
2253
2424
  let counted: ReadableStream<Uint8Array> | undefined
2254
2425
  let reader: ChunkReader | undefined
2255
2426
  let small: Uint8Array | undefined
2427
+ // A store of plain files holds a body up to the bulk decode's bound:
2428
+ // fetched whole, it is decoded in one call straight into the tree, with
2429
+ // no temp archive between (`writeArtifactAndIndex`).
2430
+ const held = this.store.decompress
2431
+ ? Math.max(this.store.embeddings, STREAM_DECODE_FROM)
2432
+ : this.store.embeddings
2256
2433
  try {
2257
2434
  const length =
2258
2435
  body instanceof Blob ? body.size : Number(body.headers.get('content-length') ?? 0)
2259
2436
  if (length > cap) throw past()
2260
2437
  if (body instanceof Blob) {
2261
- if (length <= INLINE_MAX) small = await body.bytes()
2438
+ if (length <= held && held > 0) small = await body.bytes()
2262
2439
  else await Bun.write(tmpPath, body)
2263
2440
  } else {
2264
2441
  let source = body.body
2265
2442
  if (source === null) small = new Uint8Array(0)
2266
- else if (length <= INLINE_MAX) {
2443
+ else if (length <= held && held > 0) {
2267
2444
  // Counted as it streams: a length the server did not send, or
2268
- // one it sent short, cannot make a large body inline.
2445
+ // one it sent short, cannot read a large body to memory.
2269
2446
  const r: ChunkReader = source.getReader()
2270
2447
  reader = r
2271
- const head = await readUpTo(r, INLINE_MAX)
2448
+ const head = await readUpTo(r, held)
2272
2449
  if (head.done) small = head.bytes
2273
2450
  else source = prepended(head.bytes, r)
2274
2451
  }
@@ -2406,6 +2583,36 @@ export class Cache implements CacheLayer {
2406
2583
  return { tmpPath }
2407
2584
  }
2408
2585
 
2586
+ /**
2587
+ * The save's local form: a small entry packed for the index when the
2588
+ * store embeds, else a `.tar.zst` (`decompress: false`) or a tree of the outputs
2589
+ * copied as they are.
2590
+ */
2591
+ private async packForSave(
2592
+ args: Parameters<Cache['packArtifact']>[0],
2593
+ ): Promise<
2594
+ | { compressed: Uint8Array; tar: Uint8Array }
2595
+ | { tmpPath: string }
2596
+ | { plan: true; saved: SavedTree }
2597
+ > {
2598
+ if (!this.store.decompress) return this.packArtifactToTemp(this.tempPath(args.hash), args)
2599
+ const plan = await this.planWithin(args)
2600
+ if (this.store.embeddings > 0 && plan.size <= this.store.embeddings) {
2601
+ const tar = await packArtifactBytes(plan)
2602
+ return { compressed: await zstdCompress(tar), tar }
2603
+ }
2604
+ const tree = this.tempTreePath(args.hash)
2605
+ try {
2606
+ const scan = await writeTree(plan, tree)
2607
+ let bytes = 0
2608
+ for (const e of scan.entries) bytes += e.size
2609
+ return { plan: true, saved: { tree, scan, bytes } }
2610
+ } catch (err) {
2611
+ await rm(tree, { recursive: true, force: true })
2612
+ throw err
2613
+ }
2614
+ }
2615
+
2409
2616
  /** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
2410
2617
  * the same hash from the same process (or from two forked workers that
2411
2618
  * happen to share a wall-clock ms) don't pick the same tmp filename and
@@ -2416,7 +2623,7 @@ export class Cache implements CacheLayer {
2416
2623
 
2417
2624
  /**
2418
2625
  * Atomically write `compressed` to `<hash>.tar.zst`, or inline into
2419
- * `artifacts` when it is at most `INLINE_MAX` bytes, and (re)build the
2626
+ * `artifacts` when it is at most `cache.store.embeddings` bytes, and (re)build the
2420
2627
  * entries + output_files SQL rows from the archive itself. Shared by
2421
2628
  * `save()` (we just packed the bytes) and `ingest()` (it streamed them
2422
2629
  * from the remote layer into a temp) — both index the identical values, because both
@@ -2424,7 +2631,7 @@ export class Cache implements CacheLayer {
2424
2631
  */
2425
2632
  private async writeArtifactAndIndex(
2426
2633
  hash: string,
2427
- compressed: Uint8Array | { tmpPath: string },
2634
+ given: Uint8Array | { tmpPath: string } | SavedTree,
2428
2635
  meta: IngestMeta,
2429
2636
  /**
2430
2637
  * The tar `compressed` was made from, when this process packed it: it is
@@ -2441,12 +2648,25 @@ export class Cache implements CacheLayer {
2441
2648
  // restore, so the size/mode/mtime fingerprint we store matches
2442
2649
  // what isOutputsCurrent will compare against post-restore.
2443
2650
  const finalPath = this.tarPath(hash)
2651
+ const saved = 'tree' in given ? given : undefined
2652
+ const compressed = 'tree' in given ? new Uint8Array(0) : given
2444
2653
  // An inline artifact is written in the rows' transaction below: no
2445
2654
  // temp, no rename.
2446
2655
  const inline =
2447
- compressed instanceof Uint8Array && compressed.byteLength <= INLINE_MAX ? compressed : null
2656
+ saved === undefined &&
2657
+ this.store.embeddings > 0 &&
2658
+ compressed instanceof Uint8Array &&
2659
+ compressed.byteLength <= this.store.embeddings
2660
+ ? compressed
2661
+ : null
2662
+ // A remote hit this store keeps as plain files is decoded once, into the
2663
+ // tree, and checked there: nothing names the tree until the rows go in.
2664
+ const oneTree =
2665
+ saved === undefined && inline === null && tar === undefined && this.store.decompress
2448
2666
  let tmpPath: string | undefined
2449
- if (inline === null && compressed instanceof Uint8Array) {
2667
+ if (saved !== undefined) {
2668
+ // The tree save wrote and scanned its own outputs.
2669
+ } else if (inline === null && compressed instanceof Uint8Array && !oneTree) {
2450
2670
  const temp = this.tempPath(hash)
2451
2671
  tmpPath = temp
2452
2672
  // The temp is written BEFORE validation so a large artifact can be
@@ -2470,9 +2690,35 @@ export class Cache implements CacheLayer {
2470
2690
  tmpPath = compressed.tmpPath
2471
2691
  }
2472
2692
  let scanned: Awaited<ReturnType<typeof scanArtifact>>
2693
+ let tree = saved?.tree
2473
2694
  try {
2474
2695
  const endScan = span('save: scan')
2475
- if (tar !== undefined) {
2696
+ if (saved !== undefined) {
2697
+ scanned = saved.scan
2698
+ } else if (oneTree) {
2699
+ const dir = this.tempTreePath(hash)
2700
+ tree = dir
2701
+ let scan: Awaited<ReturnType<typeof scanArtifact>> | undefined
2702
+ let raw: { meta: string; sum: string } | undefined
2703
+ await extractArtifactStream(
2704
+ await decodedTar(
2705
+ compressed instanceof Uint8Array ? compressed : Bun.file(tmpPath!),
2706
+ hash,
2707
+ this.artifactCeiling,
2708
+ ),
2709
+ path.join(dir, 'outputs'),
2710
+ path.join(dir, WORKSPACE_OUTPUT_PREFIX),
2711
+ undefined,
2712
+ undefined,
2713
+ undefined,
2714
+ (s, r) => {
2715
+ scan = s
2716
+ raw = r
2717
+ },
2718
+ )
2719
+ scanned = scan!
2720
+ await sealTree(dir, scanned, raw)
2721
+ } else if (tar !== undefined) {
2476
2722
  // Our own tar, in memory: read in place, on this thread.
2477
2723
  scanned = scanTarBytes(tar)
2478
2724
  } else {
@@ -2505,12 +2751,45 @@ export class Cache implements CacheLayer {
2505
2751
  ])
2506
2752
  } catch (err) {
2507
2753
  if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2754
+ if (tree !== undefined) await rm(tree, { recursive: true, force: true })
2508
2755
  if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
2509
2756
  throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
2510
2757
  }
2511
2758
  const { entries } = scanned
2512
2759
  const totalBytes =
2513
- compressed instanceof Uint8Array ? compressed.byteLength : Bun.file(tmpPath!).size
2760
+ saved !== undefined
2761
+ ? saved.bytes
2762
+ : compressed instanceof Uint8Array
2763
+ ? compressed.byteLength
2764
+ : Bun.file(tmpPath!).size
2765
+ // A remote hit this store keeps as plain files: the archive passed its
2766
+ // checks above, so it is unpacked once here and its temp goes.
2767
+ if (oneTree && tmpPath !== undefined) {
2768
+ await unlink(tmpPath).catch(() => undefined)
2769
+ tmpPath = undefined
2770
+ }
2771
+ if (tree === undefined && inline === null && this.store.decompress) {
2772
+ const dir = this.tempTreePath(hash)
2773
+ try {
2774
+ const source =
2775
+ compressed instanceof Uint8Array && compressed.byteLength > 0
2776
+ ? compressed
2777
+ : Bun.file(tmpPath!)
2778
+ await extractArtifactStream(
2779
+ await decodedTar(source, hash, this.artifactCeiling),
2780
+ path.join(dir, 'outputs'),
2781
+ path.join(dir, WORKSPACE_OUTPUT_PREFIX),
2782
+ )
2783
+ await sealTree(dir, scanned)
2784
+ } catch (err) {
2785
+ await rm(dir, { recursive: true, force: true })
2786
+ if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2787
+ throw err
2788
+ }
2789
+ if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2790
+ tmpPath = undefined
2791
+ tree = dir
2792
+ }
2514
2793
  const outputFileRows: Array<[string, number, number, number]> = []
2515
2794
  // Per-output-file fingerprint rows feed the skip-restore check.
2516
2795
  // Row paths: project entries store the bare rel (`outputs/`
@@ -2600,19 +2879,27 @@ export class Cache implements CacheLayer {
2600
2879
  // bytes are in one place. An inline save moves aside whatever file the
2601
2880
  // key has, as a file save does: one rename that finds nothing on a
2602
2881
  // first save, where asking the index first was a statement per save.
2603
- let aside: string | undefined
2604
- let renamed = false
2882
+ let aside: string[] = []
2883
+ let renamed: string | undefined
2605
2884
  const upsertBlob = this.upsertBlob
2606
2885
  const deleteBlob = this.deleteBlob
2607
- const moveAside = (): string | undefined => {
2608
- const to = this.tempPath(hash)
2609
- try {
2610
- renameSync(finalPath, to)
2611
- return to
2612
- } catch (err) {
2613
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
2614
- return undefined
2886
+ const treePath = this.treePath(hash)
2887
+ // Both file forms are moved aside: a key re-saved under another
2888
+ // `cache.store` leaves one live artifact.
2889
+ const moveAside = (): string[] => {
2890
+ const moved: string[] = []
2891
+ for (const [from, to] of [
2892
+ [finalPath, this.tempPath(hash)],
2893
+ [treePath, this.tempTreePath(hash)],
2894
+ ] as const) {
2895
+ try {
2896
+ renameSync(from, to)
2897
+ moved.push(to)
2898
+ } catch (err) {
2899
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
2900
+ }
2615
2901
  }
2902
+ return moved
2616
2903
  }
2617
2904
  const tx = this.db.transaction(() => {
2618
2905
  if (inline !== null) {
@@ -2620,9 +2907,10 @@ export class Cache implements CacheLayer {
2620
2907
  upsertBlob.run(hash, now, inline)
2621
2908
  } else {
2622
2909
  aside = moveAside()
2623
- renameSync(tmpPath!, finalPath)
2624
- this.artifactNames?.add(path.basename(finalPath))
2625
- renamed = true
2910
+ const live = tree !== undefined ? treePath : finalPath
2911
+ renameSync(tree ?? tmpPath!, live)
2912
+ this.artifactNames?.add(path.basename(live))
2913
+ renamed = live
2626
2914
  deleteBlob.run(hash)
2627
2915
  }
2628
2916
  insertEntry.run(
@@ -2665,13 +2953,17 @@ export class Cache implements CacheLayer {
2665
2953
  tx.immediate()
2666
2954
  } catch (err) {
2667
2955
  endTx()
2668
- const left = renamed ? finalPath : tmpPath
2669
- if (left !== undefined) await unlink(left).catch(() => undefined)
2670
- if (aside !== undefined) await unlink(aside).catch(() => undefined)
2956
+ const left = renamed ?? tree ?? tmpPath
2957
+ if (left !== undefined) await rm(left, { recursive: true, force: true })
2958
+ for (const a of aside) await rm(a, { recursive: true, force: true })
2671
2959
  throw err
2672
2960
  }
2673
2961
  endTx()
2674
- if (aside !== undefined) await unlink(aside).catch(() => undefined)
2962
+ for (const a of aside) await rm(a, { recursive: true, force: true })
2963
+ }
2964
+
2965
+ private tempTreePath(hash: string): string {
2966
+ return `${this.treePath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
2675
2967
  }
2676
2968
 
2677
2969
  /** Apply the deferred accessed_at bumps in one statement. */
@@ -2688,7 +2980,7 @@ export class Cache implements CacheLayer {
2688
2980
  const at = new Date(now)
2689
2981
  for (const hash of this.staleTimes) {
2690
2982
  try {
2691
- utimesSync(this.tarPath(hash), at, at)
2983
+ utimesSync(this.artifactStat(hash)?.path ?? this.tarPath(hash), at, at)
2692
2984
  } catch {
2693
2985
  // Gone, or another user's file: a sweep judges its old time.
2694
2986
  }
@@ -2777,67 +3069,6 @@ export class Cache implements CacheLayer {
2777
3069
  }
2778
3070
  }
2779
3071
 
2780
- /**
2781
- * The workspace's `cacheRetention`, applied at the end of a run: `prune()`
2782
- * with the same policy, but only when it would evict something, else the
2783
- * orphan sweep alone when an hour has passed since the last one. A run
2784
- * with nothing due pays the accessed-at flush it owed at close anyway, one
2785
- * scan of the index and one read of the sweep's clock — never the
2786
- * sweep's readdir. Null when nothing was due or the directory is not this
2787
- * handle's to write. The local WRITE axis does not gate it: under
2788
- * `--cache-policy=local:r,remote:rw` a save writes nothing here, but every remote
2789
- * hit is still ingested (`ingest`, ungated by design), and gated on that
2790
- * axis the retention never ran while the store grew hit by hit (A-7).
2791
- */
2792
- async evictIfDue(
2793
- policy: { maxAgeMs?: number; maxBytes?: number },
2794
- now: number = Date.now(),
2795
- ): Promise<PruneResult | null> {
2796
- return this.guard(() => this.evictIfDueNow(policy, now))
2797
- }
2798
-
2799
- private async evictIfDueNow(
2800
- policy: { maxAgeMs?: number; maxBytes?: number },
2801
- now: number,
2802
- ): Promise<PruneResult | null> {
2803
- if (this.writeBlocked !== null || this.inspecting) return null
2804
- // First: an entry this run restored still carries its old `accessed_at`
2805
- // until the deferred bump lands, and would read as due for eviction.
2806
- this.flushAccessed()
2807
- const olderThanMs = policy.maxAgeMs === undefined ? undefined : now - policy.maxAgeMs
2808
- const { oldest, bytes } = this.db
2809
- .prepare(
2810
- 'SELECT MIN(accessed_at) AS oldest, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries',
2811
- )
2812
- .get() as { oldest: number | null; bytes: number }
2813
- const ageDue =
2814
- olderThanMs !== undefined && oldest !== null && oldest + ACCESS_REFRESH_MS < olderThanMs
2815
- const sizeDue = policy.maxBytes !== undefined && bytes > policy.maxBytes
2816
- if (ageDue || sizeDue) {
2817
- return this.prune({
2818
- ...(olderThanMs !== undefined ? { olderThanMs } : {}),
2819
- ...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
2820
- })
2821
- }
2822
- // Row-less artifacts are bytes the index cannot count, so the policy
2823
- // above never sees them (upstream survey, nx#35483: 9 MiB of them sat
2824
- // under a 1 MB limit). Listing the directory to find them costs 0.5 ms
2825
- // per 1,000 entries (4.9 ms at 10,000), every run; the sweep's own
2826
- // clock in `schema_meta` costs one indexed read. The sweep is the
2827
- // policy's own prune, with the row-less files counted.
2828
- const swept = this.db.prepare('SELECT value FROM schema_meta WHERE key = ?').get(SWEPT_AT) as {
2829
- value: string
2830
- } | null
2831
- if (swept !== null && now - Number(swept.value) < ORPHAN_GRACE_MS) return null
2832
- return this.pruneIndex(
2833
- {
2834
- ...(olderThanMs !== undefined ? { olderThanMs } : {}),
2835
- ...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
2836
- },
2837
- now,
2838
- )
2839
- }
2840
-
2841
3072
  async prune(options: PruneOptions): Promise<PruneResult> {
2842
3073
  return this.guard(() => this.pruneIndex(options))
2843
3074
  }
@@ -2891,7 +3122,12 @@ export class Cache implements CacheLayer {
2891
3122
  // and the one verb meant to free a full disk freed nothing (A-14). An
2892
3123
  // inline artifact goes with its rows, in their transaction.
2893
3124
  await Promise.all(
2894
- hashes.filter((h) => !inline.has(h)).map((h) => rm(this.tarPath(h), { force: true })),
3125
+ hashes
3126
+ .filter((h) => !inline.has(h))
3127
+ .flatMap((h) => [
3128
+ rm(this.tarPath(h), { force: true }),
3129
+ rm(this.treePath(h), { recursive: true, force: true }),
3130
+ ]),
2895
3131
  )
2896
3132
  const { test, params } = inHashes(rows)
2897
3133
  const deleteRows = this.db.transaction(() => {
@@ -2914,7 +3150,15 @@ export class Cache implements CacheLayer {
2914
3150
  } catch (err) {
2915
3151
  if (!isIndexFull(err)) throw err
2916
3152
  }
2917
- const orphans = await this.reapOrphans(picked.files, now)
3153
+ // History and memo rows a month old go with a prune, never a run's
3154
+ // close: five DELETEs on every run's exit was its slowest step (owner,
3155
+ // 2026-10-18). `invocations` shares the window with `runs`, or `vx last`
3156
+ // would list a run whose task rows are gone.
3157
+ const cutoff = now - HISTORY_RETENTION_MS
3158
+ this.history.pruneOlderThan(cutoff)
3159
+ this.files.pruneOlderThan(cutoff)
3160
+ this.folds.prune(cutoff)
3161
+ const orphans = await this.reapOrphans(picked.files)
2918
3162
  return { evicted: victims.size, bytesFreed, ...orphans }
2919
3163
  }
2920
3164
 
@@ -2929,16 +3173,7 @@ export class Cache implements CacheLayer {
2929
3173
  */
2930
3174
  private async reapOrphans(
2931
3175
  taken: readonly RowlessFile[],
2932
- now: number,
2933
3176
  ): Promise<{ orphans: number; orphanBytes: number }> {
2934
- try {
2935
- this.db
2936
- .prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
2937
- .run(SWEPT_AT, String(now))
2938
- } catch (err) {
2939
- // The sweep's clock: on a full disk the sweep runs again next time.
2940
- if (!isIndexFull(err)) throw err
2941
- }
2942
3177
  let orphans = 0
2943
3178
  let orphanBytes = 0
2944
3179
  const blobs = taken.filter((o) => o.blob !== undefined)
@@ -2967,7 +3202,7 @@ export class Cache implements CacheLayer {
2967
3202
  try {
2968
3203
  // unlink, not `rm({ force })`: force swallows ENOENT, and a file a
2969
3204
  // concurrent prune took first must not be counted as ours.
2970
- await unlink(o.file)
3205
+ await rm(o.file, { recursive: true })
2971
3206
  orphans += 1
2972
3207
  orphanBytes += o.size
2973
3208
  } catch {
@@ -3016,7 +3251,8 @@ export class Cache implements CacheLayer {
3016
3251
  const phantoms = new Map<string, number>()
3017
3252
  const stale: string[] = []
3018
3253
  for (const r of rows) {
3019
- if (r.inline === 1 || present.has(`${r.hash}.tar.zst`)) continue
3254
+ if (r.inline === 1 || present.has(`${r.hash}.tar.zst`) || present.has(`${r.hash}.tree`))
3255
+ continue
3020
3256
  phantoms.set(r.hash, r.size_bytes)
3021
3257
  if (r.accessed_at < graceStart) stale.push(r.hash)
3022
3258
  }
@@ -3068,34 +3304,12 @@ export class Cache implements CacheLayer {
3068
3304
  }
3069
3305
 
3070
3306
  close(): void {
3071
- // Run-history retention: the runs table grows by one row per
3072
- // executed task per invocation — a 2000-task repo accretes ~20k
3073
- // rows in days, inflating insert and checkpoint cost forever.
3074
- // 30 days comfortably covers `vx info` (24 h windows) and any
3075
- // CI-side analytics consumers. The `invocations` header table (one
3076
- // row per `vx run`) is pruned on the SAME window — otherwise a
3077
- // header would outlive its `runs` rows, so `vx info`/`vx last` would
3078
- // list an invocation whose task detail is already gone, and the
3079
- // table would grow unbounded on a long-lived checkout.
3080
- // A reading verb and a dry run delete nothing: `vx last --list` over
3081
- // month-old runs emptied the history it listed, and `vx cache clean
3082
- // --dry-run` pruned it (item 1004).
3083
- try {
3084
- if (!this.inspecting) {
3085
- const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
3086
- this.history.pruneOlderThan(cutoff)
3087
- if (this.write) this.files.pruneOlderThan(cutoff)
3088
- }
3089
- } catch {
3090
- // Retention is best-effort; never block closing the handle.
3091
- }
3092
3307
  try {
3093
3308
  this.flushAccessed()
3094
3309
  this.outputs.flushOutputDirs()
3095
- this.folds.flush(Date.now() - 30 * 24 * 60 * 60 * 1000)
3310
+ this.folds.flush()
3096
3311
  } catch {
3097
- // Same contract as the retention prune above, which was already
3098
- // guarded while this sibling was not: `accessed_at` is LRU
3312
+ // `accessed_at` is LRU
3099
3313
  // bookkeeping, never correctness, and the run's results are
3100
3314
  // already recorded by the time we get here. Letting it throw also
3101
3315
  // SKIPPED `db.close()` below, leaking the handle. Reachable when
@@ -3109,6 +3323,21 @@ export class Cache implements CacheLayer {
3109
3323
  private tarPath(hash: string): string {
3110
3324
  return path.join(this.artifactDir, `${hash}.tar.zst`)
3111
3325
  }
3326
+
3327
+ private treePath(hash: string): string {
3328
+ return path.join(this.artifactDir, `${hash}.tree`)
3329
+ }
3330
+
3331
+ /** The key's file-backed artifact (tar or tree), the form this store writes asked first. */
3332
+ private artifactStat(hash: string): { path: string; tree: boolean; mtimeMs: number } | undefined {
3333
+ const order = this.store.decompress ? [true, false] : [false, true]
3334
+ for (const tree of order) {
3335
+ const at = tree ? this.treePath(hash) : this.tarPath(hash)
3336
+ const st = statSync(at, { throwIfNoEntry: false })
3337
+ if (st !== undefined) return { path: at, tree, mtimeMs: st.mtimeMs }
3338
+ }
3339
+ return undefined
3340
+ }
3112
3341
  }
3113
3342
 
3114
3343
  /**
@@ -3181,6 +3410,17 @@ function assertArtifactNames(
3181
3410
  }
3182
3411
  }
3183
3412
 
3413
+ /** A row's path as the tree names its file. */
3414
+ function treeName(rowPath: string): string {
3415
+ return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`
3416
+ }
3417
+
3418
+ function inTree(p: string | undefined, tree: string): boolean {
3419
+ return p !== undefined && (p === tree || p.startsWith(`${tree}/`))
3420
+ }
3421
+
3422
+ const utf8Decode = new TextDecoder('utf-8', { ignoreBOM: true })
3423
+
3184
3424
  /**
3185
3425
  * Whether an artifact entry names one of the task's declared outputs, as
3186
3426
  * the save could have packed it (`restorableOutput`: a `!` take-back, vx's