@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.
- package/launcher.cjs +9 -2
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +18 -7
- package/src/cache/cache.ts +137 -101
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +33 -17
- package/src/cache/inputs.ts +157 -54
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +4 -2
- package/src/cli/cache.ts +4 -16
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +56 -6
- package/src/cli/help.ts +27 -8
- package/src/cli/info.ts +4 -6
- package/src/cli/init.ts +4 -4
- package/src/cli/last.ts +49 -20
- package/src/cli/lock.ts +3 -3
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +80 -50
- package/src/cli/select.ts +44 -5
- package/src/cli/show.ts +47 -9
- package/src/cli/upgrade.ts +2 -3
- package/src/cli/watch-filter.ts +21 -7
- package/src/cli/watch-fs.ts +32 -8
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +40 -15
- package/src/cli/why.ts +4 -13
- package/src/cli/workspace-config.ts +1 -1
- package/src/config.ts +5 -5
- package/src/exec/env.ts +8 -5
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +126 -47
- package/src/exec/sandbox-deny-scan.ts +2 -2
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +89 -23
- package/src/exec/sandbox-violations.ts +23 -9
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +14 -4
- package/src/graph/task-graph.ts +3 -2
- package/src/index.ts +9 -0
- package/src/orchestrator/affected-tasks.ts +34 -10
- package/src/orchestrator/doctor.ts +2 -1
- package/src/orchestrator/execute-task.ts +43 -10
- package/src/orchestrator/hit-restore.ts +9 -5
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +45 -49
- package/src/orchestrator/logger.ts +4 -1
- package/src/orchestrator/miss-save.ts +10 -3
- package/src/orchestrator/placement.ts +17 -3
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +41 -3
- package/src/orchestrator/plugin.ts +11 -14
- package/src/orchestrator/projects.ts +18 -9
- package/src/orchestrator/run-context.ts +2 -2
- package/src/orchestrator/run-lock.ts +14 -4
- package/src/orchestrator/run.ts +94 -16
- package/src/orchestrator/sandbox-request.ts +6 -10
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +47 -19
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-eval.ts +4 -0
- package/src/workspace/config-imports.ts +2 -2
- package/src/workspace/config-schema.ts +47 -10
- package/src/workspace/filter.ts +5 -4
- package/src/workspace/migrate-scripts.ts +23 -6
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +89 -2
- 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.
|
|
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.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// `
|
|
30
|
-
//
|
|
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
|
package/src/cache/archive.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
package/src/cache/cache.ts
CHANGED
|
@@ -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).
|
|
460
|
-
*
|
|
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
|
-
|
|
464
|
-
|
|
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 ${
|
|
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.
|
|
469
|
-
|
|
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
|
|
629
|
-
*
|
|
630
|
-
*
|
|
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.
|
|
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
|
|
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 =
|
|
661
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
995
|
-
(
|
|
996
|
-
|
|
997
|
-
.
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
const now = (
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
}
|