@vzn/vx 0.0.591 → 0.0.595

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 (76) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +18 -7
  5. package/src/cache/cache.ts +137 -101
  6. package/src/cache/config-evals.ts +19 -4
  7. package/src/cache/git-inputs.ts +33 -17
  8. package/src/cache/inputs.ts +157 -54
  9. package/src/cache/layered-cache.ts +25 -2
  10. package/src/cache/tar-stream.ts +4 -2
  11. package/src/cli/cache.ts +4 -16
  12. package/src/cli/completions.ts +42 -6
  13. package/src/cli/foreign-flags.ts +56 -6
  14. package/src/cli/help.ts +27 -8
  15. package/src/cli/info.ts +4 -6
  16. package/src/cli/init.ts +4 -4
  17. package/src/cli/last.ts +49 -20
  18. package/src/cli/lock.ts +3 -3
  19. package/src/cli/plan-format.ts +9 -0
  20. package/src/cli/plugin-templates.ts +2 -2
  21. package/src/cli/run.ts +80 -50
  22. package/src/cli/select.ts +44 -5
  23. package/src/cli/show.ts +47 -9
  24. package/src/cli/upgrade.ts +2 -3
  25. package/src/cli/watch-filter.ts +21 -7
  26. package/src/cli/watch-fs.ts +32 -8
  27. package/src/cli/watch-judge.ts +1 -1
  28. package/src/cli/watch.ts +40 -15
  29. package/src/cli/why.ts +4 -13
  30. package/src/cli/workspace-config.ts +1 -1
  31. package/src/config.ts +5 -5
  32. package/src/exec/env.ts +8 -5
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +5 -1
  35. package/src/exec/runner.ts +126 -47
  36. package/src/exec/sandbox-deny-scan.ts +2 -2
  37. package/src/exec/sandbox-paths.ts +11 -5
  38. package/src/exec/sandbox-runtime.ts +89 -23
  39. package/src/exec/sandbox-violations.ts +23 -9
  40. package/src/graph/index.ts +1 -0
  41. package/src/graph/scheduler.ts +14 -4
  42. package/src/graph/task-graph.ts +3 -2
  43. package/src/index.ts +9 -0
  44. package/src/orchestrator/affected-tasks.ts +34 -10
  45. package/src/orchestrator/doctor.ts +2 -1
  46. package/src/orchestrator/execute-task.ts +43 -10
  47. package/src/orchestrator/hit-restore.ts +9 -5
  48. package/src/orchestrator/index.ts +1 -0
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +45 -49
  51. package/src/orchestrator/logger.ts +4 -1
  52. package/src/orchestrator/miss-save.ts +10 -3
  53. package/src/orchestrator/placement.ts +17 -3
  54. package/src/orchestrator/plan.ts +25 -1
  55. package/src/orchestrator/plugin-host.ts +41 -3
  56. package/src/orchestrator/plugin.ts +11 -14
  57. package/src/orchestrator/projects.ts +18 -9
  58. package/src/orchestrator/run-context.ts +2 -2
  59. package/src/orchestrator/run-lock.ts +14 -4
  60. package/src/orchestrator/run.ts +94 -16
  61. package/src/orchestrator/sandbox-request.ts +6 -10
  62. package/src/orchestrator/signals.ts +5 -2
  63. package/src/orchestrator/summary.ts +14 -1
  64. package/src/util/index.ts +7 -1
  65. package/src/util/secret-mask.ts +36 -0
  66. package/src/util/settle.ts +15 -0
  67. package/src/workspace/affected.ts +47 -19
  68. package/src/workspace/config-cache.ts +25 -4
  69. package/src/workspace/config-eval.ts +4 -0
  70. package/src/workspace/config-imports.ts +2 -2
  71. package/src/workspace/config-schema.ts +47 -10
  72. package/src/workspace/filter.ts +5 -4
  73. package/src/workspace/migrate-scripts.ts +23 -6
  74. package/src/workspace/package-graph.ts +15 -3
  75. package/src/workspace/project-loader.ts +89 -2
  76. package/src/workspace/workspace.ts +121 -14
package/launcher.cjs CHANGED
@@ -37,8 +37,15 @@ const SUPPORTED = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64']
37
37
  const key = `${process.platform}-${process.arch}`
38
38
  const args = process.argv.slice(2)
39
39
 
40
+ // The prebuilt Linux binaries link glibc. On musl (Alpine) the loader they
41
+ // name is absent, execve fails with ENOENT on a file that exists, and the
42
+ // user read "failed to launch (spawn … ENOENT)". Treated as no binary here,
43
+ // so Bun runs the source when present and the error names the cause.
44
+ const GLIBC_LOADER = { x64: '/lib64/ld-linux-x86-64.so.2', arm64: '/lib/ld-linux-aarch64.so.1' }
45
+ const noGlibc = process.platform === 'linux' && !existsSync(GLIBC_LOADER[process.arch] ?? '/')
46
+
40
47
  function platformBinary() {
41
- if (!SUPPORTED.includes(key)) return undefined
48
+ if (!SUPPORTED.includes(key) || noGlibc) return undefined
42
49
  const platformPkg = `${name}-${key}`
43
50
  try {
44
51
  // No `exports` restriction on the platform packages, so package.json
@@ -119,7 +126,7 @@ if (bin !== undefined) {
119
126
  } else {
120
127
  const supported = SUPPORTED.join(', ')
121
128
  process.stderr.write(
122
- `${base}: no prebuilt binary for ${key}.\n` +
129
+ `${base}: no prebuilt binary for ${key}${noGlibc ? ' without glibc (musl)' : ''}.\n` +
123
130
  ` Supported platforms: ${supported}.\n` +
124
131
  ` If your platform should be supported, reinstall so npm fetches the\n` +
125
132
  ` matching ${name}-${key} optionalDependency, or install Bun (>=1.4) to\n` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.591",
3
+ "version": "0.0.595",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.591",
25
- "@vzn/vx-linux-arm64": "0.0.591",
26
- "@vzn/vx-darwin-x64": "0.0.591",
27
- "@vzn/vx-darwin-arm64": "0.0.591"
24
+ "@vzn/vx-linux-x64": "0.0.595",
25
+ "@vzn/vx-linux-arm64": "0.0.595",
26
+ "@vzn/vx-darwin-x64": "0.0.595",
27
+ "@vzn/vx-darwin-arm64": "0.0.595"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/bin.ts CHANGED
@@ -24,17 +24,10 @@ async function main(): Promise<void> {
24
24
  // costs). Registered before any verb runs; the façade loads on first use.
25
25
  registerCoreAlias(() => import('./index.js') as Promise<Record<string, unknown>>)
26
26
  const code = await run(argv)
27
- // NOTHING calls `process.exit` here, and that is the fix rather than a
28
- // simplification. Bun drops what a pipe has not yet taken when
29
- // `process.exit` follows a large write: 300 KB written then exit
30
- // delivered 64 KiB (128 KiB after a tick), and `vx history --format
31
- // json` on a 300-project workspace was cut mid-string (2026-09-15).
32
- // `stdout.end(cb)` fixed that on 1.4.2 — and on 1.3.11 the callback
33
- // still fires early: 2 MiB written, 214 KB delivered (2026-09-20).
34
- // Setting the code and letting the loop drain is the one form that
35
- // does not depend on when a runtime decides a pipe is flushed; it
36
- // costs a hang if a verb leaves a handle open, which the suite's
37
- // several hundred spawns of this binary would show at once.
27
+ // Not `process.exit` here: Bun drops what a pipe has not yet taken
28
+ // when it follows a large write (300 KB written, 64 KiB delivered;
29
+ // `vx history --format json` was cut mid-string, 2026-09-15). The code
30
+ // is set, and `exitOnceFlushed` exits after both streams have ended.
38
31
  process.exitCode = code
39
32
  } catch (err) {
40
33
  const { fsRefusalHint, isFsRefusal, isOutOfFds, isUserError, OUT_OF_FDS_HINT } =
@@ -65,6 +58,28 @@ async function main(): Promise<void> {
65
58
  process.exitCode = 1
66
59
  }
67
60
  settled = true
61
+ await exitOnceFlushed()
62
+ }
63
+
64
+ /**
65
+ * Exit once stdout and stderr have ended, with the code the verb set. A
66
+ * config can leave a timer behind (a top-level `setInterval`, an await that
67
+ * hit its evaluation budget), and the loop it holds never drained: vx
68
+ * printed its verdict and hung for good. Only after `main` has settled, so
69
+ * `vx watch`, `vx mcp` and a run's kept-alive tasks end when their verb
70
+ * does. `end(cb)` delivers a large write whole on Bun 1.4.2 (4 MiB under a
71
+ * slow reader; `write('', cb)` delivered 450 KB), the floor `engines.bun`
72
+ * holds. A stream whose end never calls back leaves the loop to drain. A
73
+ * signal's handler exits by itself, with the signal's code.
74
+ */
75
+ async function exitOnceFlushed(): Promise<void> {
76
+ if ((await import('./util/index.js')).exitClaimedBySignal()) return
77
+ let open = 2
78
+ const ended = (): void => {
79
+ if (--open === 0) process.exit()
80
+ }
81
+ process.stdout.end(ended)
82
+ process.stderr.end(ended)
68
83
  }
69
84
 
70
85
  // The loop drained while the verb was still pending: something it awaits
@@ -168,7 +168,7 @@ export interface ExecUsage {
168
168
  }
169
169
 
170
170
  /** One regular file in an artifact, with its restore metadata resolved. */
171
- export interface ArchiveEntry {
171
+ interface ArchiveEntry {
172
172
  /** Full archive-relative name, e.g. `outputs/dist/index.js`. */
173
173
  name: string
174
174
  size: number
@@ -192,7 +192,7 @@ export class ArchiveSecurityError extends Error {
192
192
  }
193
193
 
194
194
  /** What `packArtifactStream` packs: stdout plus archive name → absolute source path. */
195
- export interface PackArgs {
195
+ interface PackArgs {
196
196
  /** The cache key, recorded in the sidecar (item 943); ingest refuses an artifact without it. */
197
197
  key?: string
198
198
  stdout: string
@@ -563,6 +563,8 @@ interface Staged {
563
563
  target: string
564
564
  /** The topmost directory `mkdir -p` created for it, pruned on abort. */
565
565
  created: string | undefined
566
+ /** Written by Bun's file writer, which creates with 0664 where `writeFile` uses 0666 (before the umask). */
567
+ streamed: boolean
566
568
  }
567
569
 
568
570
  /**
@@ -761,7 +763,8 @@ class Extractor {
761
763
  path.dirname(target),
762
764
  `.vx-tmp-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}`,
763
765
  )
764
- this.staged.push({ name, tmp, target, created })
766
+ const streamed = !(body instanceof Uint8Array)
767
+ this.staged.push({ name, tmp, target, created, streamed })
765
768
  if (body instanceof Uint8Array) {
766
769
  this.inflightBytes += body.byteLength
767
770
  // `writeFile`, not `Bun.write`: for a buffer this size `Bun.write`
@@ -803,17 +806,23 @@ class Extractor {
803
806
  * (measured 2026-09-11 on payload's ui, 4,069 files: 892 ms → see the
804
807
  * archive bench). A yield every `COMMIT_BATCH` files keeps the other
805
808
  * restores' round trips flowing. The chmod is skipped when the mode is
806
- * the one the temp file was created with — the common case.
809
+ * the one the temp file was created with — the common case. The two
810
+ * writers create with different modes (under umask 000: 0666 and 0664),
811
+ * so each is stat'ed once.
807
812
  */
808
813
  async commit(
809
814
  metaFor: (name: string) => [mode: number, mtimeMs: number | undefined],
810
815
  ): Promise<void> {
811
816
  await this.drain()
812
- const first = this.staged[0]
813
- const createdMode = first === undefined ? -1 : statSync(first.tmp).mode & 0o777
817
+ const createdModes = new Map<boolean, number>()
814
818
  let n = 0
815
819
  for (const s of this.staged) {
816
820
  const [mode, mtimeMs] = metaFor(s.name)
821
+ let createdMode = createdModes.get(s.streamed)
822
+ if (createdMode === undefined) {
823
+ createdMode = statSync(s.tmp).mode & 0o777
824
+ createdModes.set(s.streamed, createdMode)
825
+ }
817
826
  if ((mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
818
827
  if (mtimeMs !== undefined) {
819
828
  // A Date, not seconds: Bun reads a negative number of seconds as
@@ -837,7 +846,9 @@ class Extractor {
837
846
  await Promise.allSettled(this.inflight)
838
847
  this.inflight = []
839
848
  await Promise.all(this.staged.map((s) => unlink(s.tmp).catch(() => undefined)))
840
- for (const s of this.staged) {
849
+ // Newest first: a later entry's chain can sit inside an earlier one's
850
+ // (`dist/` then `dist/sub/`), never the reverse.
851
+ for (const s of this.staged.toReversed()) {
841
852
  if (s.created === undefined) continue
842
853
  let dir = path.dirname(s.target)
843
854
  const top = path.resolve(s.created)
@@ -42,7 +42,9 @@ import {
42
42
  lstatSync,
43
43
  mkdirSync,
44
44
  openSync,
45
+ readdirSync,
45
46
  renameSync,
47
+ utimesSync,
46
48
  writeFileSync,
47
49
  } from 'node:fs'
48
50
  import { readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
@@ -241,6 +243,30 @@ export const SCHEMA_VERSION = 'v32'
241
243
  /** The tables a store holds: dropped from a workspace index that held them itself. */
242
244
  const STORE_TABLES = ['entry_inputs', 'output_files', 'entry_stdout', 'store_meta', 'entries']
243
245
 
246
+ /**
247
+ * An index that records a schema other than this vx's: the one an opener
248
+ * that writes resets. Reads the recorded version alone and touches
249
+ * nothing; an unreadable file is left to the open to refuse.
250
+ */
251
+ function indexOfAnotherSchema(dbFile: string): boolean {
252
+ let found: string | undefined
253
+ try {
254
+ const db = new Database(dbFile, { readonly: true })
255
+ try {
256
+ found = (
257
+ db.query("SELECT value FROM schema_meta WHERE key = 'version'").get() as {
258
+ value: string
259
+ } | null
260
+ )?.value
261
+ } finally {
262
+ closeDb(db)
263
+ }
264
+ } catch {
265
+ return false
266
+ }
267
+ return found !== undefined && found !== SCHEMA_VERSION
268
+ }
269
+
244
270
  /** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
245
271
  const SELECT_ENTRY =
246
272
  "SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
@@ -373,7 +399,7 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
373
399
  * it there", and the ignore file is created exclusively instead of probed
374
400
  * first. An existing cache costs three calls; it cost six.
375
401
  */
376
- function openCacheDir(cacheDir: string): string | null {
402
+ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
377
403
  const ignore = path.join(cacheDir, '.gitignore')
378
404
  try {
379
405
  accessSync(cacheDir, constants.W_OK)
@@ -390,7 +416,7 @@ function openCacheDir(cacheDir: string): string | null {
390
416
  // A FILE at `cacheDir` passed the `access` above; mkdir names it.
391
417
  if (code === 'ENOTDIR') makeCacheDir(cacheDir)
392
418
  if (code !== 'ENOENT') return errorText(err)
393
- refuseManifestDir(cacheDir)
419
+ refuseManifestDir(cacheDir, workspaceRoot)
394
420
  }
395
421
  try {
396
422
  writeFileSync(ignore, IGNORE_ALL, { flag: 'wx' })
@@ -456,21 +482,50 @@ const IGNORE_ALL = '*\n'
456
482
  * the workspace or a project (`cacheDir: ''`, `'.'`, `'packages/a'`): the
457
483
  * `*` ignore file above hid every file in it from git, so its inputs
458
484
  * matched nothing and a changed source replayed the old output, and the
459
- * artifacts landed among the sources (item 997). Asked only when there is
460
- * no index yet, so an open cache pays nothing for it.
485
+ * artifacts landed among the sources (item 997). A directory that holds
486
+ * projects (`'packages'`) or the workspace (`'..'`, `'/'`) does the same
487
+ * one level up. Asked only when there is no index yet, so an open cache
488
+ * pays nothing for it.
461
489
  */
462
- function refuseManifestDir(cacheDir: string): void {
463
- for (const manifest of ['package.json', 'pnpm-workspace.yaml']) {
464
- if (!existsSync(path.join(cacheDir, manifest))) continue
490
+ function refuseManifestDir(cacheDir: string, workspaceRoot: string | undefined): void {
491
+ const manifestIn = (dir: string): string | undefined =>
492
+ MANIFESTS.find((m) => existsSync(path.join(dir, m)))
493
+ const own = manifestIn(cacheDir)
494
+ if (own !== undefined) {
465
495
  throw new UserError(
466
- `cache directory ${cacheDir} holds a ${manifest}: it is the workspace's or a project's own ` +
496
+ `cache directory ${cacheDir} holds a ${own}: it is the workspace's or a project's own ` +
467
497
  `directory, and vx would keep its index there under a \`*\` .gitignore that hides every file ` +
468
- `in it from git and from the cache keys. Point \`cacheDir\` in vx.workspace.ts (or ` +
469
- `--cache-dir) at a directory of its own, such as .vx/cache.`,
498
+ `in it from git and from the cache keys. ${OWN_DIR_HINT}`,
499
+ )
500
+ }
501
+ let held: string | undefined
502
+ if (workspaceRoot !== undefined) {
503
+ const rel = path.relative(cacheDir, workspaceRoot)
504
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) held = workspaceRoot
505
+ }
506
+ if (held === undefined) {
507
+ for (const entry of readdirSync(cacheDir, { withFileTypes: true })) {
508
+ if (!entry.isDirectory()) continue
509
+ const sub = path.join(cacheDir, entry.name)
510
+ if (manifestIn(sub) !== undefined) {
511
+ held = sub
512
+ break
513
+ }
514
+ }
515
+ }
516
+ if (held !== undefined) {
517
+ throw new UserError(
518
+ `cache directory ${cacheDir} holds ${held}, a project or workspace directory, and vx would keep ` +
519
+ `its index there under a \`*\` .gitignore that hides it from git and from the cache keys. ` +
520
+ OWN_DIR_HINT,
470
521
  )
471
522
  }
472
523
  }
473
524
 
525
+ const MANIFESTS = ['package.json', 'pnpm-workspace.yaml']
526
+ const OWN_DIR_HINT =
527
+ 'Point `cacheDir` in vx.workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
528
+
474
529
  function makeCacheDir(cacheDir: string): void {
475
530
  try {
476
531
  mkdirSync(cacheDir, { recursive: true })
@@ -578,41 +633,6 @@ export class Cache implements CacheLayer {
578
633
  return new Cache(cacheDir, undefined, undefined, undefined, 'inspect')
579
634
  }
580
635
 
581
- /**
582
- * What a real prune reaps from an index of an EARLIER schema, which it
583
- * resets first, leaving every aged artifact row-less: a dry run that
584
- * refused it could not preview the biggest prune there is, the one
585
- * after an upgrade (item 1083). Reads the recorded version alone and
586
- * touches nothing. Null for an absent, current, newer or unreadable
587
- * index; `Cache.inspect` answers those.
588
- */
589
- static async orphansBeforeReset(
590
- cacheDir: string,
591
- ): Promise<{ found: string; orphans: number; orphanBytes: number } | null> {
592
- const dbFile = path.join(cacheDir, 'cache.db')
593
- if (!existsSync(dbFile)) return null
594
- let found: string | undefined
595
- try {
596
- const db = new Database(dbFile, { readonly: true })
597
- try {
598
- found = (
599
- db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as {
600
- value: string
601
- } | null
602
- )?.value
603
- } finally {
604
- closeDb(db)
605
- }
606
- } catch {
607
- return null
608
- }
609
- if (found === undefined || found === SCHEMA_VERSION) return null
610
- const aged = await scanOrphanFiles(cacheDir, () => new Set())
611
- let orphanBytes = 0
612
- for (const o of aged) orphanBytes += o.size
613
- return { found, orphans: aged.length, orphanBytes }
614
- }
615
-
616
636
  constructor(
617
637
  private readonly cacheDir: string,
618
638
  localPolicy: { read: boolean; write: boolean } = { read: true, write: true },
@@ -625,23 +645,25 @@ export class Cache implements CacheLayer {
625
645
  */
626
646
  private readonly artifactCeiling: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
627
647
  /**
628
- * `'inspect'`: a reading verb (`why`, `last`, `info`, a dry prune). It
629
- * never resets the index: a schema it cannot read is refused, named,
630
- * and left as it was.
648
+ * `'inspect'`: a reading verb (`why`, `last`, `info`). It never resets
649
+ * the index: a schema it cannot read is refused, named, and left as it
650
+ * was. `'preview'`: a dry prune, which reads such an index as the reset
651
+ * the real prune does first leaves it, empty, with the store still
652
+ * reached, so it names what that prune takes (item 1083).
631
653
  */
632
- mode: 'open' | 'inspect' = 'open',
654
+ mode: 'open' | 'inspect' | 'preview' = 'open',
633
655
  /**
634
656
  * The shared store's directory, unversioned: every key is seeded with
635
657
  * `CACHE_VERSION`, so two vx versions never read each other's entries,
636
658
  * and the store's own schema is `store_meta.schema` (`matchStoreSchema`). Or
637
659
  * `null` for an index that holds its entries itself (a `cacheDir`).
638
660
  * Undefined follows the layout the index records: a reading verb, a
639
- * plugin's handle. An `'inspect'` open reads it only where the index
661
+ * plugin's handle. A reading open reads it only where the index
640
662
  * records none (deleted, or never written beside a run's store).
641
663
  */
642
664
  storeRoot?: string | null,
643
665
  ) {
644
- this.inspecting = mode === 'inspect'
666
+ this.inspecting = mode !== 'open'
645
667
  this.read = localPolicy.read
646
668
  // The directory exists before the DB opens — bun:sqlite won't create
647
669
  // parent dirs for us. A directory this user cannot write into is a
@@ -657,8 +679,10 @@ export class Cache implements CacheLayer {
657
679
  // said "no recorded runs yet" (item 900).
658
680
  const dbFile = path.join(cacheDir, 'cache.db')
659
681
  this.dbFile = dbFile
660
- const absent = mode === 'inspect' && !existsSync(dbFile)
661
- this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir)
682
+ const absent =
683
+ mode !== 'open' &&
684
+ (!existsSync(dbFile) || (mode === 'preview' && indexOfAnotherSchema(dbFile)))
685
+ this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir, repoDir)
662
686
  this.write = localPolicy.write && this.writeBlocked === null
663
687
  try {
664
688
  this.db = new Database(absent ? ':memory:' : dbFile, { create: true })
@@ -749,7 +773,7 @@ export class Cache implements CacheLayer {
749
773
  // 2026-10-06; a newer one used to be refused, item 896). A reading verb
750
774
  // leaves it as it was.
751
775
  const refuseUnreadable = (found: string): void => {
752
- if (mode === 'inspect') {
776
+ if (mode !== 'open') {
753
777
  throw new UserError(
754
778
  `the cache at ${cacheDir} holds index schema ${found} from another vx version; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here, and this verb leaves it untouched. The next \`vx run\` resets it`,
755
779
  )
@@ -803,7 +827,7 @@ export class Cache implements CacheLayer {
803
827
  )?.value,
804
828
  )
805
829
  let storeDir =
806
- mode === 'inspect'
830
+ mode !== 'open'
807
831
  ? (recorded ?? (typeof storeRoot === 'string' ? storeRoot : undefined))
808
832
  : storeRoot === undefined
809
833
  ? recorded
@@ -847,17 +871,10 @@ export class Cache implements CacheLayer {
847
871
  this.storeDir = storeDir
848
872
  this.artifactDir = storeDir ?? cacheDir
849
873
  if (storeDir !== undefined) {
850
- this.attachStore(storeDir, mode === 'inspect')
874
+ this.attachStore(storeDir, mode !== 'open')
851
875
  this.storeReset = this.matchStoreSchema(mode === 'open' && this.writeBlocked === null)
852
- }
853
-
854
- createTables(this.db, storeDir === undefined ? 'main' : 'store')
855
- if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
856
- this.db
857
- .prepare(
858
- "INSERT INTO store.store_meta(key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
859
- )
860
- .run(SCHEMA_VERSION)
876
+ } else {
877
+ createTables(this.db, 'main')
861
878
  }
862
879
  if (mode === 'open' && this.writeBlocked === null && storeDir !== recorded) {
863
880
  if (storeDir === undefined)
@@ -988,50 +1005,63 @@ export class Cache implements CacheLayer {
988
1005
  * reads it: a store another `SCHEMA_VERSION` wrote has its tables dropped
989
1006
  * (owner, 2026-10-06), the artifacts kept. Each is indexed again from its
990
1007
  * own bytes on its next hit (`adopt`). A reading verb reads such a store
991
- * as empty and changes nothing.
1008
+ * as empty and changes nothing. Creates the store's tables either way.
992
1009
  */
993
1010
  private matchStoreSchema(writable: boolean): SchemaReset | null {
994
- const tables = new Set(
995
- (
996
- this.db
997
- .prepare("SELECT name FROM store.sqlite_master WHERE type = 'table'")
998
- .all() as Array<{
999
- name: string
1000
- }>
1001
- ).map((r) => r.name),
1002
- )
1003
- if (!tables.has('entries')) return null
1004
- const found = tables.has('store_meta')
1005
- ? (
1006
- this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
1007
- value: string
1008
- } | null
1009
- )?.value
1010
- : undefined
1011
- if (found === SCHEMA_VERSION) return null
1011
+ const read = (): { has: boolean; found: string | undefined } => {
1012
+ const tables = new Set(
1013
+ (
1014
+ this.db
1015
+ .prepare("SELECT name FROM store.sqlite_master WHERE type = 'table'")
1016
+ .all() as Array<{
1017
+ name: string
1018
+ }>
1019
+ ).map((r) => r.name),
1020
+ )
1021
+ const found = tables.has('store_meta')
1022
+ ? (
1023
+ this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
1024
+ value: string
1025
+ } | null
1026
+ )?.value
1027
+ : undefined
1028
+ return { has: tables.has('entries'), found }
1029
+ }
1030
+ const seen = read()
1031
+ if (seen.has && seen.found === SCHEMA_VERSION) {
1032
+ createTables(this.db, 'store')
1033
+ return null
1034
+ }
1012
1035
  if (!writable) {
1013
- this.db.exec('DETACH DATABASE store')
1014
- this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
1036
+ if (seen.has) {
1037
+ this.db.exec('DETACH DATABASE store')
1038
+ this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
1039
+ }
1040
+ createTables(this.db, 'store')
1015
1041
  return null
1016
1042
  }
1017
- let reset: SchemaReset | null = null
1018
- this.db
1019
- .transaction(() => {
1020
- // Re-read under the write lock: another workspace's open may have
1021
- // reset it first.
1022
- const now = (
1023
- this.db.prepare("SELECT value FROM store.store_meta WHERE key = 'schema'").get() as {
1024
- value: string
1025
- } | null
1026
- )?.value
1027
- if (now === SCHEMA_VERSION) return
1028
- for (const t of STORE_TABLES) {
1029
- if (t !== 'store_meta') this.db.exec(`DROP TABLE IF EXISTS store.${t}`)
1043
+ // Re-read, drop, create and stamp under one write lock: as separate
1044
+ // transactions, another vx's open landing after the drop made its own
1045
+ // tables, and this one's `CREATE IF NOT EXISTS` kept them under its stamp.
1046
+ return this.db
1047
+ .transaction((): SchemaReset | null => {
1048
+ const now = read()
1049
+ let reset: SchemaReset | null = null
1050
+ if (now.has && now.found !== SCHEMA_VERSION) {
1051
+ for (const t of STORE_TABLES) {
1052
+ if (t !== 'store_meta') this.db.exec(`DROP TABLE IF EXISTS store.${t}`)
1053
+ }
1054
+ reset = { from: now.found ?? 'an earlier schema', to: SCHEMA_VERSION }
1030
1055
  }
1031
- reset = { from: found ?? 'an earlier schema', to: SCHEMA_VERSION }
1056
+ createTables(this.db, 'store')
1057
+ this.db
1058
+ .prepare(
1059
+ "INSERT INTO store.store_meta(key, value) VALUES ('schema', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
1060
+ )
1061
+ .run(SCHEMA_VERSION)
1062
+ return reset
1032
1063
  })
1033
1064
  .immediate()
1034
- return reset
1035
1065
  }
1036
1066
 
1037
1067
  // --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
@@ -1192,6 +1222,12 @@ export class Cache implements CacheLayer {
1192
1222
  // A second name for the same bytes: the index step renames it over
1193
1223
  // the artifact inside its write transaction, as for a save.
1194
1224
  linkSync(finalPath, tmpPath)
1225
+ // The link shares the artifact's inode and so its mtime, which an
1226
+ // artifact that needs adopting has had for hours: another process's
1227
+ // orphan sweep read the temp as a crashed save's and took it, and the
1228
+ // artifact with it, while this adopt was scanning them.
1229
+ const now = new Date()
1230
+ utimesSync(tmpPath, now, now)
1195
1231
  } catch {
1196
1232
  return false
1197
1233
  }
@@ -6,6 +6,9 @@
6
6
  import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
7
  import { lazyStatement } from './schema.js'
8
8
 
9
+ const CONFIG_EVALS_SWEPT_AT = 'config_evals_swept_at'
10
+ const DAY_MS = 24 * 60 * 60 * 1000
11
+
9
12
  export class ConfigEvalTable {
10
13
  private readonly selectConfigEval: ReturnType<Database['prepare']>
11
14
  private readonly insertConfigEval: ReturnType<Database['prepare']>
@@ -106,10 +109,22 @@ export class ConfigEvalTable {
106
109
  /**
107
110
  * Retention: a row not WRITTEN since `cutoff`. A hit does not refresh it
108
111
  * (that would be a write per config on every warm run), so a config that
109
- * hit for thirty days is evaluated once more and stored again.
112
+ * hit for thirty days is evaluated once more and stored again. No index
113
+ * covers `created_at`, so each sweep reads every row with its JSON (1–6 ms
114
+ * at 8,000 rows, 2 MB); it runs once a day, on its own clock in
115
+ * `schema_meta`, as the file-hash sweep does.
110
116
  */
111
- pruneOlderThan(cutoff: number): void {
112
- this.db.prepare('DELETE FROM config_evals WHERE created_at < ?').run(cutoff)
113
- this.db.prepare('DELETE FROM config_closures WHERE created_at < ?').run(cutoff)
117
+ pruneOlderThan(cutoff: number, now: number = Date.now()): void {
118
+ const last = this.db
119
+ .prepare('SELECT value FROM schema_meta WHERE key = ?')
120
+ .get(CONFIG_EVALS_SWEPT_AT) as { value: string } | null
121
+ if (last !== null && now - Number(last.value) < DAY_MS) return
122
+ this.db.transaction(() => {
123
+ this.db.prepare('DELETE FROM config_evals WHERE created_at < ?').run(cutoff)
124
+ this.db.prepare('DELETE FROM config_closures WHERE created_at < ?').run(cutoff)
125
+ this.db
126
+ .prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
127
+ .run(CONFIG_EVALS_SWEPT_AT, String(now))
128
+ })()
114
129
  }
115
130
  }