@vzn/vx 0.0.643 → 0.0.645

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 (56) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -23
  3. package/package.json +5 -5
  4. package/schemas/info.json +0 -11
  5. package/schemas/init.json +5 -0
  6. package/skills/vx/SKILL.md +1 -1
  7. package/src/cache/archive.ts +89 -2
  8. package/src/cache/cache.ts +396 -176
  9. package/src/cache/file-hashes.ts +2 -14
  10. package/src/cache/fold-memo.ts +7 -3
  11. package/src/cache/index.ts +4 -2
  12. package/src/cache/key-fold.ts +4 -1
  13. package/src/cache/layer.ts +14 -10
  14. package/src/cache/layered-cache.ts +157 -41
  15. package/src/cache/plugin-cache.ts +40 -44
  16. package/src/cache/remote-lane.ts +59 -25
  17. package/src/cache/scoped-cache.ts +3 -80
  18. package/src/cache/tar-stream.ts +12 -7
  19. package/src/cli/format.ts +1 -2
  20. package/src/cli/help.ts +10 -4
  21. package/src/cli/info.ts +2 -19
  22. package/src/cli/init.ts +121 -20
  23. package/src/cli/plan-format.ts +6 -2
  24. package/src/cli/plugin-commands.ts +4 -5
  25. package/src/cli/plugin-templates.ts +1 -1
  26. package/src/cli/run.ts +3 -1
  27. package/src/cli/upgrade.ts +39 -9
  28. package/src/config.ts +13 -10
  29. package/src/exec/sandbox-paths.ts +2 -2
  30. package/src/graph/scheduler.ts +3 -1
  31. package/src/graph/task-graph.ts +15 -11
  32. package/src/index.ts +2 -2
  33. package/src/orchestrator/affected-tasks.ts +0 -18
  34. package/src/orchestrator/doctor.ts +0 -17
  35. package/src/orchestrator/execute-task.ts +1 -1
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/options.ts +6 -8
  38. package/src/orchestrator/plan.ts +7 -1
  39. package/src/orchestrator/plugin-host.ts +6 -5
  40. package/src/orchestrator/plugin.ts +12 -5
  41. package/src/orchestrator/prepare.ts +16 -46
  42. package/src/orchestrator/projects.ts +8 -1
  43. package/src/orchestrator/remote-prefetch.ts +11 -4
  44. package/src/orchestrator/run-context.ts +48 -5
  45. package/src/orchestrator/run-settings.ts +3 -0
  46. package/src/orchestrator/run.ts +10 -26
  47. package/src/orchestrator/task-hash.ts +1 -3
  48. package/src/report/logger.ts +0 -4
  49. package/src/report/summary.ts +14 -13
  50. package/src/settings.ts +38 -15
  51. package/src/util/index.ts +1 -0
  52. package/src/util/paths.ts +42 -33
  53. package/src/util/size.ts +4 -5
  54. package/src/workspace/affected.ts +0 -39
  55. package/src/workspace/config-schema.ts +0 -36
  56. package/src/workspace/index.ts +0 -1
@@ -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,11 @@ import {
76
76
  packArtifactBytes,
77
77
  packArtifactStream,
78
78
  planArtifact,
79
+ planOfTree,
80
+ sealTree,
81
+ writeTree,
79
82
  } from './archive.js'
83
+ import { readUmask, restoredMode } from './umask.js'
80
84
  import {
81
85
  ArtifactVanishedError,
82
86
  type CacheEntry,
@@ -119,7 +123,7 @@ import { behindLink } from './linked-inputs.js'
119
123
  * An artifact or temp file without an `entries` row is never reaped
120
124
  * younger than this: a crashed save's temp is stale long before it. A
121
125
  * 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
126
+ * its row), so past this it is judged by the prune policy, its file
123
127
  * time standing for `accessed_at`. A hit renews a file time older than
124
128
  * this, so the file time trails the last use by at most this much, and
125
129
  * the policy reads the file time plus this as the last use.
@@ -137,7 +141,7 @@ const ADOPT_STATS = 64
137
141
  * A hit renews an entry's `accessed_at` only once it is older than this:
138
142
  * renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
139
143
  * 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:
144
+ * this much, and the prune policy reads it plus this as the last use:
141
145
  * an entry used within the limit is never pruned, and one may stay up to
142
146
  * this long past it (owner, 2026-10-08).
143
147
  */
@@ -150,7 +154,7 @@ const ACCESS_REFRESH_MS = 60 * 60 * 1000
150
154
  * directory), and the sweep once took any `*.tar.zst` an hour old there,
151
155
  * the user's own release tarball included (item 968).
152
156
  */
153
- const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.tar\.zst(\.tmp-\d+-\d+-[0-9a-z]*)?$/
157
+ const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.(?:tar\.zst|tree)(\.tmp-\d+-\d+-[0-9a-z]*)?$/
154
158
 
155
159
  /** What vx writes in a cache directory besides its artifacts. */
156
160
  const VX_CACHE_NAMES = new Set([
@@ -186,8 +190,8 @@ export function vxCacheEntries(cacheDir: string): string[] {
186
190
  })
187
191
  }
188
192
 
189
- /** The `schema_meta` key holding when the orphan sweep last ran (ms epoch). */
190
- const SWEPT_AT = 'orphans_swept_at'
193
+ /** What `vx cache clean` keeps of run history and the hash memos. */
194
+ const HISTORY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
191
195
 
192
196
  export interface SchemaReset {
193
197
  from: string
@@ -295,7 +299,7 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
295
299
  // output_files' ino + ctime_ms moved to the workspace's own
296
300
  // output_stamps, output_dirs lost its foreign key, and the value
297
301
  // salt moved to store_meta. The cache KEY is unchanged.
298
- // v33: an artifact of at most INLINE_MAX compressed bytes lives in the
302
+ // v33: an artifact of at most 32 KiB compressed lives in the
299
303
  // store's `artifacts` table, written in its rows' transaction, and
300
304
  // every entry read joins it. The artifact bytes and the cache KEY are
301
305
  // unchanged (CACHE_VERSION not bumped).
@@ -370,13 +374,6 @@ const ARTIFACT_TABLES = ['artifacts', 'artifacts_meta']
370
374
  */
371
375
  const ARTIFACT_LAYOUT = 'a2'
372
376
 
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
377
  /**
381
378
  * An index that records a schema other than this vx's: the one an opener
382
379
  * that writes resets. Reads the recorded version alone and touches
@@ -509,8 +506,9 @@ async function scanOrphanFiles(
509
506
  const file = path.join(cacheDir, name)
510
507
  try {
511
508
  const st = await stat(file)
512
- if (st.isFile() && st.mtimeMs <= cutoff) {
513
- found.push({ file, size: st.size, mtimeMs: st.mtimeMs, temp })
509
+ if ((name.includes('.tree') ? st.isDirectory() : st.isFile()) && st.mtimeMs <= cutoff) {
510
+ const size = st.isDirectory() ? await treeBytes(file) : st.size
511
+ found.push({ file, size, mtimeMs: st.mtimeMs, temp })
514
512
  }
515
513
  } catch {
516
514
  // Gone between readdir and stat: not an orphan any more.
@@ -520,6 +518,16 @@ async function scanOrphanFiles(
520
518
  return found
521
519
  }
522
520
 
521
+ /** The bytes a tree-form artifact's files hold. */
522
+ async function treeBytes(dir: string): Promise<number> {
523
+ let n = 0
524
+ for (const e of await readdir(dir, { recursive: true, withFileTypes: true }).catch(() => [])) {
525
+ if (e.isFile())
526
+ n += (await stat(path.join(e.parentPath, e.name)).catch(() => ({ size: 0 }))).size
527
+ }
528
+ return n
529
+ }
530
+
523
531
  /**
524
532
  * SQL predicate selecting `runs` rows that record an EXECUTION.
525
533
  *
@@ -613,7 +621,7 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
613
621
  * it there", and the ignore file is created exclusively instead of probed
614
622
  * first. An existing cache costs three calls; it cost six.
615
623
  */
616
- function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
624
+ function openCacheDir(cacheDir: string, workspaceRoot?: string, index = 'cache.db'): string | null {
617
625
  const [ignore, ignoreText] = ignoreFileFor(cacheDir)
618
626
  try {
619
627
  accessSync(cacheDir, constants.W_OK)
@@ -624,7 +632,7 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
624
632
  return null
625
633
  }
626
634
  try {
627
- accessSync(path.join(cacheDir, 'cache.db'), constants.W_OK)
635
+ accessSync(path.join(cacheDir, index), constants.W_OK)
628
636
  } catch (err) {
629
637
  const code = (err as NodeJS.ErrnoException).code
630
638
  // A FILE at `cacheDir` passed the `access` above; mkdir names it.
@@ -649,7 +657,7 @@ function storeBlocked(storeDir: string, storeRoot: string | null): string | null
649
657
  if (refused !== null) return refused
650
658
  }
651
659
  }
652
- return openCacheDir(storeDir)
660
+ return openCacheDir(storeDir, undefined, 'store.db')
653
661
  } catch (err) {
654
662
  return errorText(err)
655
663
  }
@@ -788,6 +796,30 @@ function errorText(err: unknown): string {
788
796
  return err instanceof Error ? err.message : String(err)
789
797
  }
790
798
 
799
+ /** A tree the save wrote from its own outputs, with the scan its tar would give. */
800
+ interface SavedTree {
801
+ tree: string
802
+ scan: Awaited<ReturnType<typeof scanArtifact>>
803
+ bytes: number
804
+ }
805
+
806
+ /** How a save keeps an entry locally (`cache.store`). */
807
+ export interface StoreOptions {
808
+ /**
809
+ * The largest entry kept in the index, as one zstd tar (fewer files to
810
+ * make): written in its rows' transaction, with no temp and no rename
811
+ * (docs/design/cache-save-cpu-2026-10.md). 0 keeps every entry as files.
812
+ */
813
+ embeddings: number
814
+ /** A file-backed entry is a `<hash>.tree` of plain files; off, one `<hash>.tar.zst`. */
815
+ decompress: boolean
816
+ }
817
+
818
+ export const DEFAULT_STORE: StoreOptions = {
819
+ embeddings: 32 * 1024,
820
+ decompress: true,
821
+ }
822
+
791
823
  /** What a run's background uploads moved: totals over the ones that landed. */
792
824
  export interface UploadTally {
793
825
  count: number
@@ -823,6 +855,8 @@ export class Cache implements CacheLayer {
823
855
  * so the run can report them without a method on the layer seam.
824
856
  */
825
857
  readonly uploads: UploadTally = { count: 0, bytes: 0, ms: 0, failed: 0 }
858
+ /** The run's hits pulled from those remotes, and their bytes on the wire. */
859
+ readonly downloads: { count: number; bytes: number } = { count: 0, bytes: 0 }
826
860
  /** The same remotes' traffic per key, filled by the same `LayeredCache`. */
827
861
  readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map() }
828
862
 
@@ -939,6 +973,7 @@ export class Cache implements CacheLayer {
939
973
  * records none (deleted, or never written beside a run's store).
940
974
  */
941
975
  storeRoot?: string | null,
976
+ private readonly store: StoreOptions = DEFAULT_STORE,
942
977
  ) {
943
978
  this.inspecting = mode !== 'open'
944
979
  this.read = localPolicy.read
@@ -1677,7 +1712,7 @@ export class Cache implements CacheLayer {
1677
1712
  } else {
1678
1713
  // Verify the artifact file actually exists. The DB and the
1679
1714
  // filesystem can drift if someone manually deletes the cache dir.
1680
- const st = statSync(this.tarPath(hash), { throwIfNoEntry: false })
1715
+ const st = this.artifactStat(hash)
1681
1716
  if (st === undefined) return null
1682
1717
  if (st.mtimeMs < Date.now() - ORPHAN_GRACE_MS) this.staleTimes.add(hash)
1683
1718
  }
@@ -1717,7 +1752,7 @@ export class Cache implements CacheLayer {
1717
1752
  names = []
1718
1753
  }
1719
1754
  this.artifactNames ??= new Set(names)
1720
- return !names.some((n) => n.endsWith('.tar.zst'))
1755
+ return !names.some((n) => n.endsWith('.tar.zst') || n.endsWith('.tree'))
1721
1756
  }
1722
1757
 
1723
1758
  /**
@@ -1766,7 +1801,7 @@ export class Cache implements CacheLayer {
1766
1801
  if (r.inline_at < graceStart) this.staleInline.add(r.hash)
1767
1802
  return true
1768
1803
  }
1769
- const st = statSync(this.tarPath(r.hash), { throwIfNoEntry: false })
1804
+ const st = this.artifactStat(r.hash)
1770
1805
  if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
1771
1806
  return st !== undefined
1772
1807
  })
@@ -1811,7 +1846,7 @@ export class Cache implements CacheLayer {
1811
1846
  private storedHere(hash: string): boolean {
1812
1847
  const row = this.entryExists.get(hash) as { inline_at: number | null } | null
1813
1848
  if (row === null) return false
1814
- return row.inline_at !== null || existsSync(this.tarPath(hash))
1849
+ return row.inline_at !== null || this.artifactStat(hash) !== undefined
1815
1850
  }
1816
1851
 
1817
1852
  // Local cache has no slower layer to warm from — prefetch is a no-op.
@@ -1886,9 +1921,104 @@ export class Cache implements CacheLayer {
1886
1921
  }
1887
1922
  }
1888
1923
 
1924
+ /**
1925
+ * Restore a tree-form entry: its files copied (or cloned) out one by
1926
+ * one where the outputs are absent, as on every restore after a clean. Anything else (a tree to
1927
+ * restore into, a workspace output, a link) goes through the extractor
1928
+ * the tar form uses, fed the tree packed back into its tar.
1929
+ */
1930
+ private async restoreTree(
1931
+ hash: string,
1932
+ tree: string,
1933
+ rows: readonly OutputFileRow[],
1934
+ projectDir: string,
1935
+ workspaceRoot: string | undefined,
1936
+ verify: (provided: ReadonlySet<string>) => void,
1937
+ accept: ((name: string) => boolean) | undefined,
1938
+ ): Promise<void> {
1939
+ const files = rows.filter((r) => !r.path.endsWith('/') && r.mode !== LINK_MODE)
1940
+ let bytes = 0
1941
+ for (const r of files) bytes += r.size
1942
+ if (bytes > this.artifactCeiling) {
1943
+ throw new CorruptArtifactError(
1944
+ hash,
1945
+ `its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
1946
+ )
1947
+ }
1948
+ if (await this.copyTree(tree, rows, files, projectDir, accept)) return
1949
+ await extractArtifactStream(
1950
+ packArtifactStream(planOfTree(tree, this.stdoutText(hash))),
1951
+ projectDir,
1952
+ workspaceRoot,
1953
+ verify,
1954
+ accept,
1955
+ workspaceRoot,
1956
+ )
1957
+ }
1958
+
1959
+ /** The copy-out: false, having written nothing, where it is not the case it serves. */
1960
+ private async copyTree(
1961
+ tree: string,
1962
+ rows: readonly OutputFileRow[],
1963
+ files: readonly OutputFileRow[],
1964
+ projectDir: string,
1965
+ accept: ((name: string) => boolean) | undefined,
1966
+ ): Promise<boolean> {
1967
+ const tops = new Set<string>()
1968
+ for (const r of rows) {
1969
+ if (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) || r.mode === LINK_MODE) return false
1970
+ if (accept !== undefined && !r.path.endsWith('/') && !accept(`outputs/${r.path}`))
1971
+ return false
1972
+ tops.add(r.path.split('/')[0]!)
1973
+ }
1974
+ const held = await Promise.all(
1975
+ [...tops].map((t) =>
1976
+ lstat(path.join(projectDir, t)).then(
1977
+ () => true,
1978
+ () => false,
1979
+ ),
1980
+ ),
1981
+ )
1982
+ if (held.includes(true)) return false
1983
+ readUmask()
1984
+ const plain = restoredMode(0o666)
1985
+ try {
1986
+ const dirs = new Set<string>()
1987
+ for (const r of rows) {
1988
+ dirs.add(path.join(projectDir, r.path.endsWith('/') ? r.path : path.dirname(r.path)))
1989
+ }
1990
+ for (const d of dirs) await mkdir(d, { recursive: true })
1991
+ await Promise.all(
1992
+ files.map(async (r) => {
1993
+ const to = path.join(projectDir, r.path)
1994
+ await Bun.write(to, Bun.file(path.join(tree, treeName(r.path))), { createPath: false })
1995
+ const mode = restoredMode(r.mode)
1996
+ if (mode !== plain) await chmod(to, mode)
1997
+ const at = new Date(r.mtimeMs)
1998
+ await utimes(to, at, at)
1999
+ }),
2000
+ )
2001
+ } catch (err) {
2002
+ await Promise.all(
2003
+ [...tops].map((t) => rm(path.join(projectDir, t), { recursive: true, force: true })),
2004
+ )
2005
+ throw err
2006
+ }
2007
+ return true
2008
+ }
2009
+
2010
+ private stdoutText(hash: string): string {
2011
+ const row = this.db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(hash) as {
2012
+ stdout: string | Uint8Array
2013
+ } | null
2014
+ if (row === null) return ''
2015
+ return typeof row.stdout === 'string' ? row.stdout : utf8Decode.decode(row.stdout)
2016
+ }
2017
+
1889
2018
  /** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
1890
2019
  private async dropEntry(hash: string): Promise<void> {
1891
2020
  await rm(this.tarPath(hash), { force: true })
2021
+ await rm(this.treePath(hash), { recursive: true, force: true })
1892
2022
  const drop = this.db.transaction(() => {
1893
2023
  this.deleteBlob.run(hash)
1894
2024
  this.deleteEntryRow.run(hash)
@@ -1924,7 +2054,7 @@ export class Cache implements CacheLayer {
1924
2054
  // outputs, so a restore that did not fail here would report a green cache
1925
2055
  // hit over an emptied output tree. The artifact existed when `get()`
1926
2056
  // 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
2057
+ // (a `vx cache clean` in another shell, here or for another workspace
1928
2058
  // on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
1929
2059
  // turns into a miss: the task runs.
1930
2060
  //
@@ -1986,6 +2116,8 @@ export class Cache implements CacheLayer {
1986
2116
  // when every artifact is a one-file `dist/` (measured: 390 vs 355 ms
1987
2117
  // per 1 000). The local artifact was validated at ingest, so a missing
1988
2118
  // declared size is allowed; the output ceiling applies to both.
2119
+ const tree =
2120
+ inline === undefined && existsSync(this.treePath(hash)) ? this.treePath(hash) : undefined
1989
2121
  const endExtract = span('restore: extract')
1990
2122
  try {
1991
2123
  // Every step here is an async filesystem call on purpose: a
@@ -1993,15 +2125,13 @@ export class Cache implements CacheLayer {
1993
2125
  // 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
1994
2126
  // four workers overlap their round trips and a blocking one stalls
1995
2127
  // 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
- )
2128
+ const accept = outputs === undefined ? undefined : declaredName(outputs)
2129
+ if (tree !== undefined) {
2130
+ await this.restoreTree(hash, tree, rows, projectDir, workspaceRoot, verify, accept)
2131
+ } else {
2132
+ const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
2133
+ await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
2134
+ }
2005
2135
  } catch (err) {
2006
2136
  // A UserError here is the extractor naming the tree's fault (an
2007
2137
  // output directory that links out of the project).
@@ -2052,7 +2182,13 @@ export class Cache implements CacheLayer {
2052
2182
  // artifact is intact, the tree is not the process's to change.
2053
2183
  // A refusal to READ the artifact is the cache directory's, not the
2054
2184
  // outputs': the old line sent the reader to the wrong tree (A-40).
2055
- if (isFsRefusal(err) && err.path === src) {
2185
+ // A tree copy's error can name its source; no read fails ENOSPC.
2186
+ if (
2187
+ isFsRefusal(err) &&
2188
+ code !== 'ENOSPC' &&
2189
+ code !== 'EDQUOT' &&
2190
+ (err.path === src || (tree !== undefined && inTree(err.path, tree)))
2191
+ ) {
2056
2192
  throw new UserError(
2057
2193
  `restore of ${hash} could not read its artifact (${code}: ${err.message}). ` +
2058
2194
  `Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is.`,
@@ -2164,23 +2300,21 @@ export class Cache implements CacheLayer {
2164
2300
  // again in `writeArtifactAndIndex` cost every save two thread-pool
2165
2301
  // round trips for an EEXIST and a stat (item 630).
2166
2302
  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
- )
2303
+ const packed = await this.packForSave(args).catch((err: unknown) => {
2304
+ // A save that could not open an output names the limit, not only
2305
+ // the file, which is fine (A-39).
2306
+ if (isOutOfFds(err)) {
2307
+ throw new UserError(
2308
+ `save of ${args.hash} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
2309
+ )
2310
+ }
2311
+ throw err
2312
+ })
2179
2313
  endPack()
2180
2314
  await this.guard(() =>
2181
2315
  this.writeArtifactAndIndex(
2182
2316
  args.hash,
2183
- 'tar' in packed ? packed.compressed : packed,
2317
+ 'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
2184
2318
  {
2185
2319
  taskId: args.entry.taskId,
2186
2320
  command: args.entry.command,
@@ -2220,11 +2354,30 @@ export class Cache implements CacheLayer {
2220
2354
  * gone; `release` unlinks the name. An inline artifact is read once into
2221
2355
  * the body, which no re-save can change.
2222
2356
  */
2223
- pinArtifact(hash: string): { body: Blob; release: () => Promise<void> } {
2357
+ async pinArtifact(hash: string): Promise<{ body: Blob; release: () => Promise<void> }> {
2224
2358
  const blob = this.guard(() => this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
2225
2359
  if (blob !== null) return { body: new Blob([blob.bytes]), release: async () => {} }
2226
2360
  const pinned = this.tempPath(hash)
2227
- linkSync(this.tarPath(hash), pinned)
2361
+ try {
2362
+ linkSync(this.tarPath(hash), pinned)
2363
+ } catch (err) {
2364
+ const tree = this.treePath(hash)
2365
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || !existsSync(tree)) throw err
2366
+ // A tree is packed into the artifact a remote expects here, in the
2367
+ // upload queue, off the save; a small one in memory.
2368
+ const plan = planOfTree(tree, this.stdoutText(hash))
2369
+ if (plan.size <= STREAM_DECODE_FROM) {
2370
+ const bytes = await zstdCompress(await packArtifactBytes(plan))
2371
+ return { body: new Blob([bytes]), release: async () => {} }
2372
+ }
2373
+ await Bun.write(
2374
+ pinned,
2375
+ new Response(packArtifactStream(plan).pipeThrough(zstdEncoder())),
2376
+ ).catch(async (e: unknown) => {
2377
+ await unlink(pinned).catch(() => undefined)
2378
+ throw e
2379
+ })
2380
+ }
2228
2381
  return { body: Bun.file(pinned), release: () => unlink(pinned).catch(() => undefined) }
2229
2382
  }
2230
2383
 
@@ -2233,7 +2386,7 @@ export class Cache implements CacheLayer {
2233
2386
  * `Response` and copies a file `Blob` without collecting either, so a
2234
2387
  * pull never holds the artifact. A body that fails mid-stream (a dropped
2235
2388
  * 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,
2389
+ * removes its own. A body of at most `cache.store.embeddings` bytes — by its size,
2237
2390
  * or counted as it streams — is read to memory and stored inline.
2238
2391
  */
2239
2392
  async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
@@ -2253,22 +2406,28 @@ export class Cache implements CacheLayer {
2253
2406
  let counted: ReadableStream<Uint8Array> | undefined
2254
2407
  let reader: ChunkReader | undefined
2255
2408
  let small: Uint8Array | undefined
2409
+ // A store of plain files holds a body up to the bulk decode's bound:
2410
+ // fetched whole, it is decoded in one call straight into the tree, with
2411
+ // no temp archive between (`writeArtifactAndIndex`).
2412
+ const held = this.store.decompress
2413
+ ? Math.max(this.store.embeddings, STREAM_DECODE_FROM)
2414
+ : this.store.embeddings
2256
2415
  try {
2257
2416
  const length =
2258
2417
  body instanceof Blob ? body.size : Number(body.headers.get('content-length') ?? 0)
2259
2418
  if (length > cap) throw past()
2260
2419
  if (body instanceof Blob) {
2261
- if (length <= INLINE_MAX) small = await body.bytes()
2420
+ if (length <= held && held > 0) small = await body.bytes()
2262
2421
  else await Bun.write(tmpPath, body)
2263
2422
  } else {
2264
2423
  let source = body.body
2265
2424
  if (source === null) small = new Uint8Array(0)
2266
- else if (length <= INLINE_MAX) {
2425
+ else if (length <= held && held > 0) {
2267
2426
  // Counted as it streams: a length the server did not send, or
2268
- // one it sent short, cannot make a large body inline.
2427
+ // one it sent short, cannot read a large body to memory.
2269
2428
  const r: ChunkReader = source.getReader()
2270
2429
  reader = r
2271
- const head = await readUpTo(r, INLINE_MAX)
2430
+ const head = await readUpTo(r, held)
2272
2431
  if (head.done) small = head.bytes
2273
2432
  else source = prepended(head.bytes, r)
2274
2433
  }
@@ -2406,6 +2565,36 @@ export class Cache implements CacheLayer {
2406
2565
  return { tmpPath }
2407
2566
  }
2408
2567
 
2568
+ /**
2569
+ * The save's local form: a small entry packed for the index when the
2570
+ * store embeds, else a `.tar.zst` (`decompress: false`) or a tree of the outputs
2571
+ * copied as they are.
2572
+ */
2573
+ private async packForSave(
2574
+ args: Parameters<Cache['packArtifact']>[0],
2575
+ ): Promise<
2576
+ | { compressed: Uint8Array; tar: Uint8Array }
2577
+ | { tmpPath: string }
2578
+ | { plan: true; saved: SavedTree }
2579
+ > {
2580
+ if (!this.store.decompress) return this.packArtifactToTemp(this.tempPath(args.hash), args)
2581
+ const plan = await this.planWithin(args)
2582
+ if (this.store.embeddings > 0 && plan.size <= this.store.embeddings) {
2583
+ const tar = await packArtifactBytes(plan)
2584
+ return { compressed: await zstdCompress(tar), tar }
2585
+ }
2586
+ const tree = this.tempTreePath(args.hash)
2587
+ try {
2588
+ const scan = await writeTree(plan, tree)
2589
+ let bytes = 0
2590
+ for (const e of scan.entries) bytes += e.size
2591
+ return { plan: true, saved: { tree, scan, bytes } }
2592
+ } catch (err) {
2593
+ await rm(tree, { recursive: true, force: true })
2594
+ throw err
2595
+ }
2596
+ }
2597
+
2409
2598
  /** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
2410
2599
  * the same hash from the same process (or from two forked workers that
2411
2600
  * happen to share a wall-clock ms) don't pick the same tmp filename and
@@ -2416,7 +2605,7 @@ export class Cache implements CacheLayer {
2416
2605
 
2417
2606
  /**
2418
2607
  * Atomically write `compressed` to `<hash>.tar.zst`, or inline into
2419
- * `artifacts` when it is at most `INLINE_MAX` bytes, and (re)build the
2608
+ * `artifacts` when it is at most `cache.store.embeddings` bytes, and (re)build the
2420
2609
  * entries + output_files SQL rows from the archive itself. Shared by
2421
2610
  * `save()` (we just packed the bytes) and `ingest()` (it streamed them
2422
2611
  * from the remote layer into a temp) — both index the identical values, because both
@@ -2424,7 +2613,7 @@ export class Cache implements CacheLayer {
2424
2613
  */
2425
2614
  private async writeArtifactAndIndex(
2426
2615
  hash: string,
2427
- compressed: Uint8Array | { tmpPath: string },
2616
+ given: Uint8Array | { tmpPath: string } | SavedTree,
2428
2617
  meta: IngestMeta,
2429
2618
  /**
2430
2619
  * The tar `compressed` was made from, when this process packed it: it is
@@ -2441,12 +2630,25 @@ export class Cache implements CacheLayer {
2441
2630
  // restore, so the size/mode/mtime fingerprint we store matches
2442
2631
  // what isOutputsCurrent will compare against post-restore.
2443
2632
  const finalPath = this.tarPath(hash)
2633
+ const saved = 'tree' in given ? given : undefined
2634
+ const compressed = 'tree' in given ? new Uint8Array(0) : given
2444
2635
  // An inline artifact is written in the rows' transaction below: no
2445
2636
  // temp, no rename.
2446
2637
  const inline =
2447
- compressed instanceof Uint8Array && compressed.byteLength <= INLINE_MAX ? compressed : null
2638
+ saved === undefined &&
2639
+ this.store.embeddings > 0 &&
2640
+ compressed instanceof Uint8Array &&
2641
+ compressed.byteLength <= this.store.embeddings
2642
+ ? compressed
2643
+ : null
2644
+ // A remote hit this store keeps as plain files is decoded once, into the
2645
+ // tree, and checked there: nothing names the tree until the rows go in.
2646
+ const oneTree =
2647
+ saved === undefined && inline === null && tar === undefined && this.store.decompress
2448
2648
  let tmpPath: string | undefined
2449
- if (inline === null && compressed instanceof Uint8Array) {
2649
+ if (saved !== undefined) {
2650
+ // The tree save wrote and scanned its own outputs.
2651
+ } else if (inline === null && compressed instanceof Uint8Array && !oneTree) {
2450
2652
  const temp = this.tempPath(hash)
2451
2653
  tmpPath = temp
2452
2654
  // The temp is written BEFORE validation so a large artifact can be
@@ -2470,9 +2672,33 @@ export class Cache implements CacheLayer {
2470
2672
  tmpPath = compressed.tmpPath
2471
2673
  }
2472
2674
  let scanned: Awaited<ReturnType<typeof scanArtifact>>
2675
+ let tree = saved?.tree
2473
2676
  try {
2474
2677
  const endScan = span('save: scan')
2475
- if (tar !== undefined) {
2678
+ if (saved !== undefined) {
2679
+ scanned = saved.scan
2680
+ } else if (oneTree) {
2681
+ const dir = this.tempTreePath(hash)
2682
+ tree = dir
2683
+ let scan: Awaited<ReturnType<typeof scanArtifact>> | undefined
2684
+ await extractArtifactStream(
2685
+ await decodedTar(
2686
+ compressed instanceof Uint8Array ? compressed : Bun.file(tmpPath!),
2687
+ hash,
2688
+ this.artifactCeiling,
2689
+ ),
2690
+ path.join(dir, 'outputs'),
2691
+ path.join(dir, WORKSPACE_OUTPUT_PREFIX),
2692
+ undefined,
2693
+ undefined,
2694
+ undefined,
2695
+ (s) => {
2696
+ scan = s
2697
+ },
2698
+ )
2699
+ scanned = scan!
2700
+ await sealTree(dir, scanned)
2701
+ } else if (tar !== undefined) {
2476
2702
  // Our own tar, in memory: read in place, on this thread.
2477
2703
  scanned = scanTarBytes(tar)
2478
2704
  } else {
@@ -2505,12 +2731,45 @@ export class Cache implements CacheLayer {
2505
2731
  ])
2506
2732
  } catch (err) {
2507
2733
  if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2734
+ if (tree !== undefined) await rm(tree, { recursive: true, force: true })
2508
2735
  if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
2509
2736
  throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
2510
2737
  }
2511
2738
  const { entries } = scanned
2512
2739
  const totalBytes =
2513
- compressed instanceof Uint8Array ? compressed.byteLength : Bun.file(tmpPath!).size
2740
+ saved !== undefined
2741
+ ? saved.bytes
2742
+ : compressed instanceof Uint8Array
2743
+ ? compressed.byteLength
2744
+ : Bun.file(tmpPath!).size
2745
+ // A remote hit this store keeps as plain files: the archive passed its
2746
+ // checks above, so it is unpacked once here and its temp goes.
2747
+ if (oneTree && tmpPath !== undefined) {
2748
+ await unlink(tmpPath).catch(() => undefined)
2749
+ tmpPath = undefined
2750
+ }
2751
+ if (tree === undefined && inline === null && this.store.decompress) {
2752
+ const dir = this.tempTreePath(hash)
2753
+ try {
2754
+ const source =
2755
+ compressed instanceof Uint8Array && compressed.byteLength > 0
2756
+ ? compressed
2757
+ : Bun.file(tmpPath!)
2758
+ await extractArtifactStream(
2759
+ await decodedTar(source, hash, this.artifactCeiling),
2760
+ path.join(dir, 'outputs'),
2761
+ path.join(dir, WORKSPACE_OUTPUT_PREFIX),
2762
+ )
2763
+ await sealTree(dir, scanned)
2764
+ } catch (err) {
2765
+ await rm(dir, { recursive: true, force: true })
2766
+ if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2767
+ throw err
2768
+ }
2769
+ if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2770
+ tmpPath = undefined
2771
+ tree = dir
2772
+ }
2514
2773
  const outputFileRows: Array<[string, number, number, number]> = []
2515
2774
  // Per-output-file fingerprint rows feed the skip-restore check.
2516
2775
  // Row paths: project entries store the bare rel (`outputs/`
@@ -2600,19 +2859,27 @@ export class Cache implements CacheLayer {
2600
2859
  // bytes are in one place. An inline save moves aside whatever file the
2601
2860
  // key has, as a file save does: one rename that finds nothing on a
2602
2861
  // first save, where asking the index first was a statement per save.
2603
- let aside: string | undefined
2604
- let renamed = false
2862
+ let aside: string[] = []
2863
+ let renamed: string | undefined
2605
2864
  const upsertBlob = this.upsertBlob
2606
2865
  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
2866
+ const treePath = this.treePath(hash)
2867
+ // Both file forms are moved aside: a key re-saved under another
2868
+ // `cache.store` leaves one live artifact.
2869
+ const moveAside = (): string[] => {
2870
+ const moved: string[] = []
2871
+ for (const [from, to] of [
2872
+ [finalPath, this.tempPath(hash)],
2873
+ [treePath, this.tempTreePath(hash)],
2874
+ ] as const) {
2875
+ try {
2876
+ renameSync(from, to)
2877
+ moved.push(to)
2878
+ } catch (err) {
2879
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
2880
+ }
2615
2881
  }
2882
+ return moved
2616
2883
  }
2617
2884
  const tx = this.db.transaction(() => {
2618
2885
  if (inline !== null) {
@@ -2620,9 +2887,10 @@ export class Cache implements CacheLayer {
2620
2887
  upsertBlob.run(hash, now, inline)
2621
2888
  } else {
2622
2889
  aside = moveAside()
2623
- renameSync(tmpPath!, finalPath)
2624
- this.artifactNames?.add(path.basename(finalPath))
2625
- renamed = true
2890
+ const live = tree !== undefined ? treePath : finalPath
2891
+ renameSync(tree ?? tmpPath!, live)
2892
+ this.artifactNames?.add(path.basename(live))
2893
+ renamed = live
2626
2894
  deleteBlob.run(hash)
2627
2895
  }
2628
2896
  insertEntry.run(
@@ -2665,13 +2933,17 @@ export class Cache implements CacheLayer {
2665
2933
  tx.immediate()
2666
2934
  } catch (err) {
2667
2935
  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)
2936
+ const left = renamed ?? tree ?? tmpPath
2937
+ if (left !== undefined) await rm(left, { recursive: true, force: true })
2938
+ for (const a of aside) await rm(a, { recursive: true, force: true })
2671
2939
  throw err
2672
2940
  }
2673
2941
  endTx()
2674
- if (aside !== undefined) await unlink(aside).catch(() => undefined)
2942
+ for (const a of aside) await rm(a, { recursive: true, force: true })
2943
+ }
2944
+
2945
+ private tempTreePath(hash: string): string {
2946
+ return `${this.treePath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
2675
2947
  }
2676
2948
 
2677
2949
  /** Apply the deferred accessed_at bumps in one statement. */
@@ -2688,7 +2960,7 @@ export class Cache implements CacheLayer {
2688
2960
  const at = new Date(now)
2689
2961
  for (const hash of this.staleTimes) {
2690
2962
  try {
2691
- utimesSync(this.tarPath(hash), at, at)
2963
+ utimesSync(this.artifactStat(hash)?.path ?? this.tarPath(hash), at, at)
2692
2964
  } catch {
2693
2965
  // Gone, or another user's file: a sweep judges its old time.
2694
2966
  }
@@ -2777,67 +3049,6 @@ export class Cache implements CacheLayer {
2777
3049
  }
2778
3050
  }
2779
3051
 
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
3052
  async prune(options: PruneOptions): Promise<PruneResult> {
2842
3053
  return this.guard(() => this.pruneIndex(options))
2843
3054
  }
@@ -2891,7 +3102,12 @@ export class Cache implements CacheLayer {
2891
3102
  // and the one verb meant to free a full disk freed nothing (A-14). An
2892
3103
  // inline artifact goes with its rows, in their transaction.
2893
3104
  await Promise.all(
2894
- hashes.filter((h) => !inline.has(h)).map((h) => rm(this.tarPath(h), { force: true })),
3105
+ hashes
3106
+ .filter((h) => !inline.has(h))
3107
+ .flatMap((h) => [
3108
+ rm(this.tarPath(h), { force: true }),
3109
+ rm(this.treePath(h), { recursive: true, force: true }),
3110
+ ]),
2895
3111
  )
2896
3112
  const { test, params } = inHashes(rows)
2897
3113
  const deleteRows = this.db.transaction(() => {
@@ -2914,7 +3130,15 @@ export class Cache implements CacheLayer {
2914
3130
  } catch (err) {
2915
3131
  if (!isIndexFull(err)) throw err
2916
3132
  }
2917
- const orphans = await this.reapOrphans(picked.files, now)
3133
+ // History and memo rows a month old go with a prune, never a run's
3134
+ // close: five DELETEs on every run's exit was its slowest step (owner,
3135
+ // 2026-10-18). `invocations` shares the window with `runs`, or `vx last`
3136
+ // would list a run whose task rows are gone.
3137
+ const cutoff = now - HISTORY_RETENTION_MS
3138
+ this.history.pruneOlderThan(cutoff)
3139
+ this.files.pruneOlderThan(cutoff)
3140
+ this.folds.prune(cutoff)
3141
+ const orphans = await this.reapOrphans(picked.files)
2918
3142
  return { evicted: victims.size, bytesFreed, ...orphans }
2919
3143
  }
2920
3144
 
@@ -2929,16 +3153,7 @@ export class Cache implements CacheLayer {
2929
3153
  */
2930
3154
  private async reapOrphans(
2931
3155
  taken: readonly RowlessFile[],
2932
- now: number,
2933
3156
  ): 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
3157
  let orphans = 0
2943
3158
  let orphanBytes = 0
2944
3159
  const blobs = taken.filter((o) => o.blob !== undefined)
@@ -2967,7 +3182,7 @@ export class Cache implements CacheLayer {
2967
3182
  try {
2968
3183
  // unlink, not `rm({ force })`: force swallows ENOENT, and a file a
2969
3184
  // concurrent prune took first must not be counted as ours.
2970
- await unlink(o.file)
3185
+ await rm(o.file, { recursive: true })
2971
3186
  orphans += 1
2972
3187
  orphanBytes += o.size
2973
3188
  } catch {
@@ -3016,7 +3231,8 @@ export class Cache implements CacheLayer {
3016
3231
  const phantoms = new Map<string, number>()
3017
3232
  const stale: string[] = []
3018
3233
  for (const r of rows) {
3019
- if (r.inline === 1 || present.has(`${r.hash}.tar.zst`)) continue
3234
+ if (r.inline === 1 || present.has(`${r.hash}.tar.zst`) || present.has(`${r.hash}.tree`))
3235
+ continue
3020
3236
  phantoms.set(r.hash, r.size_bytes)
3021
3237
  if (r.accessed_at < graceStart) stale.push(r.hash)
3022
3238
  }
@@ -3068,34 +3284,12 @@ export class Cache implements CacheLayer {
3068
3284
  }
3069
3285
 
3070
3286
  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
3287
  try {
3093
3288
  this.flushAccessed()
3094
3289
  this.outputs.flushOutputDirs()
3095
- this.folds.flush(Date.now() - 30 * 24 * 60 * 60 * 1000)
3290
+ this.folds.flush()
3096
3291
  } catch {
3097
- // Same contract as the retention prune above, which was already
3098
- // guarded while this sibling was not: `accessed_at` is LRU
3292
+ // `accessed_at` is LRU
3099
3293
  // bookkeeping, never correctness, and the run's results are
3100
3294
  // already recorded by the time we get here. Letting it throw also
3101
3295
  // SKIPPED `db.close()` below, leaking the handle. Reachable when
@@ -3109,6 +3303,21 @@ export class Cache implements CacheLayer {
3109
3303
  private tarPath(hash: string): string {
3110
3304
  return path.join(this.artifactDir, `${hash}.tar.zst`)
3111
3305
  }
3306
+
3307
+ private treePath(hash: string): string {
3308
+ return path.join(this.artifactDir, `${hash}.tree`)
3309
+ }
3310
+
3311
+ /** The key's file-backed artifact (tar or tree), the form this store writes asked first. */
3312
+ private artifactStat(hash: string): { path: string; tree: boolean; mtimeMs: number } | undefined {
3313
+ const order = this.store.decompress ? [true, false] : [false, true]
3314
+ for (const tree of order) {
3315
+ const at = tree ? this.treePath(hash) : this.tarPath(hash)
3316
+ const st = statSync(at, { throwIfNoEntry: false })
3317
+ if (st !== undefined) return { path: at, tree, mtimeMs: st.mtimeMs }
3318
+ }
3319
+ return undefined
3320
+ }
3112
3321
  }
3113
3322
 
3114
3323
  /**
@@ -3181,6 +3390,17 @@ function assertArtifactNames(
3181
3390
  }
3182
3391
  }
3183
3392
 
3393
+ /** A row's path as the tree names its file. */
3394
+ function treeName(rowPath: string): string {
3395
+ return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`
3396
+ }
3397
+
3398
+ function inTree(p: string | undefined, tree: string): boolean {
3399
+ return p !== undefined && (p === tree || p.startsWith(`${tree}/`))
3400
+ }
3401
+
3402
+ const utf8Decode = new TextDecoder('utf-8', { ignoreBOM: true })
3403
+
3184
3404
  /**
3185
3405
  * Whether an artifact entry names one of the task's declared outputs, as
3186
3406
  * the save could have packed it (`restorableOutput`: a `!` take-back, vx's