@vzn/vx 0.0.636 → 0.0.637
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/README.md +39 -11
- package/docs/caching.md +213 -207
- package/docs/cli.md +699 -655
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +26 -25
- package/docs/schema.md +376 -198
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -63
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +103 -149
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +24 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +158 -63
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +113 -72
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +121 -95
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
package/src/bin.ts
CHANGED
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env -S bun --no-env-file --no-install
|
|
2
2
|
// Wrapped in an explicit async main so `bun build --compile` accepts
|
|
3
3
|
// the file. The compile target doesn't allow top-level await.
|
|
4
|
+
import { handOff, localVx, vxPath } from './handoff.js'
|
|
5
|
+
|
|
4
6
|
let settled = false
|
|
5
7
|
|
|
6
8
|
async function main(): Promise<void> {
|
|
7
9
|
const argv = process.argv.slice(2)
|
|
8
10
|
try {
|
|
11
|
+
// Inside the try: `process.cwd()` throws EMFILE when out of descriptors,
|
|
12
|
+
// and that is a one-line UserError below, not a stack.
|
|
13
|
+
const local = await localVx(
|
|
14
|
+
process.cwd(),
|
|
15
|
+
process.execPath,
|
|
16
|
+
Bun.main.startsWith('/$bunfs') || (process.argv[1] ?? '').startsWith('/$bunfs'),
|
|
17
|
+
)
|
|
18
|
+
if (local !== null) await handOff(local, argv)
|
|
9
19
|
// `vx --version` needs one leaf module; the dispatcher and the util
|
|
10
20
|
// barrel it pulled in were 20 ms of its 28 (2026-10-01).
|
|
11
21
|
if (argv.length === 1 && (argv[0] === '--version' || argv[0] === 'version')) {
|
|
12
22
|
// `Bun.stdout`, not `process.stdout`: the Node stream's first touch is
|
|
13
23
|
// ~7 ms, half of this verb (2026-10-02). A reader gone (EPIPE) is no
|
|
14
24
|
// failure of the line.
|
|
15
|
-
|
|
25
|
+
// Handed off, it says which vx ran and which one handed it the call.
|
|
26
|
+
const from = process.env['VX_HANDED_OFF']
|
|
27
|
+
const where = from === undefined ? '' : ` (${vxPath()}, handed off from ${from})`
|
|
28
|
+
await Bun.write(Bun.stdout, `vx ${(await import('./version.js')).VERSION}${where}\n`).catch(
|
|
29
|
+
() => {},
|
|
30
|
+
)
|
|
16
31
|
settled = true
|
|
17
32
|
return
|
|
18
33
|
}
|
|
@@ -87,7 +102,7 @@ async function main(): Promise<void> {
|
|
|
87
102
|
* config can leave a timer behind (a top-level `setInterval`, an await that
|
|
88
103
|
* hit its evaluation budget), and the loop it holds never drained: vx
|
|
89
104
|
* printed its verdict and hung for good. Only after `main` has settled, so
|
|
90
|
-
* `vx watch`,
|
|
105
|
+
* `vx watch`, a plugin's long-lived verb and a run's kept-alive tasks end when their verb
|
|
91
106
|
* does. `end(cb)` delivers a large write whole on Bun 1.4.2 (4 MiB under a
|
|
92
107
|
* slow reader; `write('', cb)` delivered 450 KB), the floor `engines.bun`
|
|
93
108
|
* holds. A stream whose end never calls back leaves the loop to drain. A
|
package/src/cache/archive.ts
CHANGED
|
@@ -230,6 +230,12 @@ interface PackArgs {
|
|
|
230
230
|
* output of the same artifact is packed wherever it is.
|
|
231
231
|
*/
|
|
232
232
|
within?: string
|
|
233
|
+
/**
|
|
234
|
+
* For a sandboxed task: whether it could read this canonical path. A
|
|
235
|
+
* symlinked output inside `within` whose target it could not read is
|
|
236
|
+
* refused like one outside it.
|
|
237
|
+
*/
|
|
238
|
+
readable?: ((real: string) => boolean) | undefined
|
|
233
239
|
}
|
|
234
240
|
|
|
235
241
|
/** The largest second count ustar's 12-byte mtime field holds: 11 octal digits. */
|
|
@@ -321,6 +327,11 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
|
|
|
321
327
|
)
|
|
322
328
|
}
|
|
323
329
|
}
|
|
330
|
+
if (args.readable !== undefined && !args.readable(src) && !(await ownOutputs()).has(src)) {
|
|
331
|
+
throw new UserError(
|
|
332
|
+
`${shown} is a symlink to ${src}, which the task's sandbox does not let it read: vx packs a symlinked output as its target's bytes — emit a file there, grant the target in exec.sandbox.allow.read, or take it back with a '!' entry`,
|
|
333
|
+
)
|
|
334
|
+
}
|
|
324
335
|
st = await statOf(src).catch(dangling)
|
|
325
336
|
}
|
|
326
337
|
if (!st.isFile()) {
|
package/src/cache/cache.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
mkdirSync,
|
|
44
44
|
openSync,
|
|
45
45
|
readdirSync,
|
|
46
|
+
readFileSync,
|
|
46
47
|
renameSync,
|
|
47
48
|
statSync,
|
|
48
49
|
utimesSync,
|
|
@@ -53,6 +54,7 @@ import path from 'node:path'
|
|
|
53
54
|
import { createArtifactTables, createTables, inHashes, lazyStatement } from './schema.js'
|
|
54
55
|
import {
|
|
55
56
|
UserError,
|
|
57
|
+
DOT_VX_GITIGNORE,
|
|
56
58
|
formatBytes,
|
|
57
59
|
isDiskFull,
|
|
58
60
|
isFsRefusal,
|
|
@@ -105,7 +107,6 @@ import {
|
|
|
105
107
|
zstdCompress,
|
|
106
108
|
zstdEncoder,
|
|
107
109
|
} from './zstd.js'
|
|
108
|
-
import { ConfigEvalTable } from './config-evals.js'
|
|
109
110
|
import { FileHashStore } from './file-hashes.js'
|
|
110
111
|
import { OutputIndex } from './output-index.js'
|
|
111
112
|
import { RunHistory } from './run-history.js'
|
|
@@ -148,6 +149,40 @@ const ACCESS_REFRESH_MS = 60 * 60 * 1000
|
|
|
148
149
|
*/
|
|
149
150
|
const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.tar\.zst(\.tmp-\d+-\d+-[0-9a-z]*)?$/
|
|
150
151
|
|
|
152
|
+
/** What vx writes in a cache directory besides its artifacts. */
|
|
153
|
+
const VX_CACHE_NAMES = new Set([
|
|
154
|
+
'cache.db',
|
|
155
|
+
'cache.db-wal',
|
|
156
|
+
'cache.db-shm',
|
|
157
|
+
'cache.db-journal',
|
|
158
|
+
'runs',
|
|
159
|
+
'failures',
|
|
160
|
+
'lockfile-claims',
|
|
161
|
+
])
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The entries of `cacheDir` vx wrote: the index, its artifacts, run
|
|
165
|
+
* history, and the `*` ignore file unless the user changed it. A clear of
|
|
166
|
+
* a directory the user named deletes these and nothing else (item 968).
|
|
167
|
+
*/
|
|
168
|
+
export function vxCacheEntries(cacheDir: string): string[] {
|
|
169
|
+
let names: string[]
|
|
170
|
+
try {
|
|
171
|
+
names = readdirSync(cacheDir)
|
|
172
|
+
} catch {
|
|
173
|
+
return []
|
|
174
|
+
}
|
|
175
|
+
return names.filter((name) => {
|
|
176
|
+
if (VX_CACHE_NAMES.has(name) || VX_ARTIFACT_NAME.test(name)) return true
|
|
177
|
+
if (name !== '.gitignore') return false
|
|
178
|
+
try {
|
|
179
|
+
return readFileSync(path.join(cacheDir, name), 'utf8') === IGNORE_ALL
|
|
180
|
+
} catch {
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
151
186
|
/** The `schema_meta` key holding when the orphan sweep last ran (ms epoch). */
|
|
152
187
|
const SWEPT_AT = 'orphans_swept_at'
|
|
153
188
|
|
|
@@ -167,7 +202,7 @@ function unreadableIndexError(dbFile: string, err: unknown): UserError {
|
|
|
167
202
|
return new UserError(
|
|
168
203
|
`the cache index ${dbFile} is not a database SQLite can read (${(err as Error).message}) — ` +
|
|
169
204
|
`it holds nothing a run cannot rebuild: remove it with its -wal and -shm files and ` +
|
|
170
|
-
`re-run, and \`vx cache
|
|
205
|
+
`re-run, and \`vx cache clean\` reclaims the artifacts it indexed`,
|
|
171
206
|
)
|
|
172
207
|
}
|
|
173
208
|
|
|
@@ -263,7 +298,12 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
|
|
|
263
298
|
// unchanged (CACHE_VERSION not bumped).
|
|
264
299
|
// v34: entry_inputs holds one row per entry, its key components as one
|
|
265
300
|
// JSON array, not a row per component. The cache KEY is unchanged.
|
|
266
|
-
|
|
301
|
+
// v35: config_evals and config_closures are gone: runs read configs from
|
|
302
|
+
// the lock, and `vx lock` evaluates every one. The cache KEY is
|
|
303
|
+
// unchanged.
|
|
304
|
+
// v36: the store's task_durations: each executed success's time, tagged
|
|
305
|
+
// by branch, read by the forecast. The cache KEY is unchanged.
|
|
306
|
+
export const SCHEMA_VERSION = 'v36'
|
|
267
307
|
|
|
268
308
|
/**
|
|
269
309
|
* An entry's key components as its one `entry_inputs` row (v34): JSON
|
|
@@ -288,7 +328,14 @@ function encodeEntryInputs(
|
|
|
288
328
|
}
|
|
289
329
|
|
|
290
330
|
/** The tables a store holds: dropped from a workspace index that held them itself. */
|
|
291
|
-
const STORE_TABLES = [
|
|
331
|
+
const STORE_TABLES = [
|
|
332
|
+
'entry_inputs',
|
|
333
|
+
'output_files',
|
|
334
|
+
'entry_stdout',
|
|
335
|
+
'task_durations',
|
|
336
|
+
'store_meta',
|
|
337
|
+
'entries',
|
|
338
|
+
]
|
|
292
339
|
|
|
293
340
|
/**
|
|
294
341
|
* The inline artifacts' tables (schema.ts): dropped from a workspace index
|
|
@@ -549,13 +596,13 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
|
|
|
549
596
|
* first. An existing cache costs three calls; it cost six.
|
|
550
597
|
*/
|
|
551
598
|
function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
|
|
552
|
-
const ignore =
|
|
599
|
+
const [ignore, ignoreText] = ignoreFileFor(cacheDir)
|
|
553
600
|
try {
|
|
554
601
|
accessSync(cacheDir, constants.W_OK)
|
|
555
602
|
} catch (err) {
|
|
556
603
|
makeCacheDir(cacheDir)
|
|
557
604
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return errorText(err)
|
|
558
|
-
|
|
605
|
+
writeIgnore(ignore, ignoreText)
|
|
559
606
|
return null
|
|
560
607
|
}
|
|
561
608
|
try {
|
|
@@ -567,11 +614,7 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
|
|
|
567
614
|
if (code !== 'ENOENT') return errorText(err)
|
|
568
615
|
refuseManifestDir(cacheDir, workspaceRoot)
|
|
569
616
|
}
|
|
570
|
-
|
|
571
|
-
writeFileSync(ignore, IGNORE_ALL, { flag: 'wx' })
|
|
572
|
-
} catch (err) {
|
|
573
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
574
|
-
}
|
|
617
|
+
writeIgnore(ignore, ignoreText)
|
|
575
618
|
return null
|
|
576
619
|
}
|
|
577
620
|
|
|
@@ -630,6 +673,29 @@ function ensurePrivateDir(dir: string): string | null {
|
|
|
630
673
|
const utf8 = new TextEncoder()
|
|
631
674
|
const IGNORE_ALL = '*\n'
|
|
632
675
|
|
|
676
|
+
/**
|
|
677
|
+
* The ignore file that keeps `cacheDir` out of git, and its text. The
|
|
678
|
+
* default `.vx/cache` is named by `.vx/.gitignore` (`/cache`), committed
|
|
679
|
+
* beside the workspace config and the lock (owner, 2026-10-14); any other
|
|
680
|
+
* cache directory holds a `*` one of its own. Written only when missing:
|
|
681
|
+
* an existing file is the user's.
|
|
682
|
+
*/
|
|
683
|
+
function writeIgnore(file: string, text: string): void {
|
|
684
|
+
try {
|
|
685
|
+
writeFileSync(file, text, { flag: 'wx' })
|
|
686
|
+
} catch (err) {
|
|
687
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function ignoreFileFor(cacheDir: string): [string, string] {
|
|
692
|
+
const parent = path.dirname(cacheDir)
|
|
693
|
+
if (path.basename(cacheDir) === 'cache' && path.basename(parent) === '.vx') {
|
|
694
|
+
return [path.join(parent, '.gitignore'), DOT_VX_GITIGNORE]
|
|
695
|
+
}
|
|
696
|
+
return [path.join(cacheDir, '.gitignore'), IGNORE_ALL]
|
|
697
|
+
}
|
|
698
|
+
|
|
633
699
|
/**
|
|
634
700
|
* A first index in a directory that holds a manifest is a cache pointed at
|
|
635
701
|
* the workspace or a project (`cacheDir: ''`, `'.'`, `'packages/a'`): the
|
|
@@ -675,9 +741,17 @@ function refuseManifestDir(cacheDir: string, workspaceRoot: string | undefined):
|
|
|
675
741
|
}
|
|
676
742
|
}
|
|
677
743
|
|
|
678
|
-
|
|
744
|
+
// What marks a project or workspace directory: a vx config (workspace
|
|
745
|
+
// PROJECT_CONFIG_FILENAMES) or the workspace file, and a package manifest,
|
|
746
|
+
// which a `discover` plugin makes a project with no config.
|
|
747
|
+
const MANIFESTS = [
|
|
748
|
+
'package.json',
|
|
749
|
+
'pnpm-workspace.yaml',
|
|
750
|
+
...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `vx.config.${ext}`),
|
|
751
|
+
...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `.vx/workspace.${ext}`),
|
|
752
|
+
]
|
|
679
753
|
const OWN_DIR_HINT =
|
|
680
|
-
'Point `
|
|
754
|
+
'Point `cache.dir` in .vx/workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
|
|
681
755
|
|
|
682
756
|
function makeCacheDir(cacheDir: string): void {
|
|
683
757
|
try {
|
|
@@ -687,7 +761,7 @@ function makeCacheDir(cacheDir: string): void {
|
|
|
687
761
|
// so this is the first thing any of them says there.
|
|
688
762
|
throw new UserError(
|
|
689
763
|
`cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; make ` +
|
|
690
|
-
`the workspace writable, set \`
|
|
764
|
+
`the workspace writable, set \`cache.dir\` in .vx/workspace.ts, or pass --cache-dir <path>`,
|
|
691
765
|
)
|
|
692
766
|
}
|
|
693
767
|
}
|
|
@@ -744,7 +818,6 @@ export class Cache implements CacheLayer {
|
|
|
744
818
|
private readonly insertEntryInput: ReturnType<Database['prepare']>
|
|
745
819
|
/** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
|
|
746
820
|
private readonly files: FileHashStore
|
|
747
|
-
private readonly configEvals: ConfigEvalTable
|
|
748
821
|
private readonly outputs: OutputIndex
|
|
749
822
|
private readonly history: RunHistory
|
|
750
823
|
|
|
@@ -832,9 +905,8 @@ export class Cache implements CacheLayer {
|
|
|
832
905
|
// The directory exists before the DB opens — bun:sqlite won't create
|
|
833
906
|
// parent dirs for us. A directory this user cannot write into is a
|
|
834
907
|
// read-only cache for every reader (`vx show`, `why`, `last`, the
|
|
835
|
-
// doctor, the watch sweep): the write axis goes off, so the
|
|
836
|
-
//
|
|
837
|
-
// instead of dying in SQLite on the first miss (an unprivileged user on
|
|
908
|
+
// doctor, the watch sweep): the write axis goes off, so the file-hash
|
|
909
|
+
// memo skips its upserts instead of dying in SQLite on the first miss (an unprivileged user on
|
|
838
910
|
// a root-owned `.vx`, 2026-09-16). A run wants more than a quiet
|
|
839
911
|
// read-only cache — `assertWritable()`.
|
|
840
912
|
// A reading verb over a directory with no index yet reads an empty one
|
|
@@ -958,7 +1030,7 @@ export class Cache implements CacheLayer {
|
|
|
958
1030
|
// keeps it so).
|
|
959
1031
|
if (found !== undefined) {
|
|
960
1032
|
this.db.exec(
|
|
961
|
-
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.store_meta;',
|
|
1033
|
+
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.store_meta;',
|
|
962
1034
|
)
|
|
963
1035
|
}
|
|
964
1036
|
// The tables land under the same write lock as the stamp: made
|
|
@@ -1119,7 +1191,6 @@ export class Cache implements CacheLayer {
|
|
|
1119
1191
|
// The slices: each owns its statements over this handle and its table(s);
|
|
1120
1192
|
// the schema above is the one place every table is declared.
|
|
1121
1193
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
1122
|
-
this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
|
|
1123
1194
|
this.outputs = new OutputIndex(this.db)
|
|
1124
1195
|
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
1125
1196
|
|
|
@@ -1148,7 +1219,7 @@ export class Cache implements CacheLayer {
|
|
|
1148
1219
|
* corrupt past the ones the open reads — to the open's own refusal. The
|
|
1149
1220
|
* open reads the header and `schema_meta` alone (item 1005), so a file
|
|
1150
1221
|
* corrupt deeper answered the first lookup that reached the bad page: every
|
|
1151
|
-
* task of a run failed as an "internal error", and `vx cache
|
|
1222
|
+
* task of a run failed as an "internal error", and `vx cache clean` printed
|
|
1152
1223
|
* a stack (A-8). Every other error passes through as it was.
|
|
1153
1224
|
*/
|
|
1154
1225
|
private guard<T>(op: () => T): T {
|
|
@@ -1331,28 +1402,6 @@ export class Cache implements CacheLayer {
|
|
|
1331
1402
|
.immediate()
|
|
1332
1403
|
}
|
|
1333
1404
|
|
|
1334
|
-
// --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
|
|
1335
|
-
getConfigEval(key: string): string | null {
|
|
1336
|
-
return this.guard(() => this.configEvals.getConfigEval(key))
|
|
1337
|
-
}
|
|
1338
|
-
getConfigClosures(configPaths: readonly string[]): Map<string, string[]> {
|
|
1339
|
-
return this.guard(() => this.configEvals.getConfigClosures(configPaths))
|
|
1340
|
-
}
|
|
1341
|
-
putConfigClosure(configPath: string, files: readonly string[]): void {
|
|
1342
|
-
return this.guard(() => this.configEvals.putConfigClosure(configPath, files))
|
|
1343
|
-
}
|
|
1344
|
-
getConfigEvals(keys: readonly string[]): Map<string, string> {
|
|
1345
|
-
return this.guard(() => this.configEvals.getConfigEvals(keys))
|
|
1346
|
-
}
|
|
1347
|
-
putConfigEval(key: string, json: string): void {
|
|
1348
|
-
return this.guard(() => this.configEvals.putConfigEval(key, json))
|
|
1349
|
-
}
|
|
1350
|
-
putConfigEvals(entries: ReadonlyArray<readonly [string, string]>): void {
|
|
1351
|
-
return this.guard(() => this.configEvals.putConfigEvals(entries))
|
|
1352
|
-
}
|
|
1353
|
-
putConfigClosures(entries: ReadonlyArray<readonly [string, readonly string[]]>): void {
|
|
1354
|
-
return this.guard(() => this.configEvals.putConfigClosures(entries))
|
|
1355
|
-
}
|
|
1356
1405
|
// --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
|
|
1357
1406
|
hashFile(filePath: string): Promise<string> {
|
|
1358
1407
|
return this.guard(() => this.files.hashFile(filePath))
|
|
@@ -1425,8 +1474,8 @@ export class Cache implements CacheLayer {
|
|
|
1425
1474
|
|
|
1426
1475
|
/**
|
|
1427
1476
|
* The digest a value-bearing row holds (an env value, a runtime output,
|
|
1428
|
-
* args after `--`): xxh3 under a random salt this store keeps. `vx why
|
|
1429
|
-
* its JSON
|
|
1477
|
+
* args after `--`): xxh3 under a random salt this store keeps. `vx why`
|
|
1478
|
+
* and its JSON print it, and an unkeyed xxh3 in
|
|
1430
1479
|
* a public CI log let anyone confirm or brute-force a short secret
|
|
1431
1480
|
* (L-4). Salted, a digest still says whether a value changed, and says
|
|
1432
1481
|
* nothing without this cache's `schema_meta`. Asked for on a miss only.
|
|
@@ -1462,7 +1511,7 @@ export class Cache implements CacheLayer {
|
|
|
1462
1511
|
// the entries row. It's part of the contract so LayeredCache can
|
|
1463
1512
|
// route metadata to `ingest()` on remote-hit without a separate API.
|
|
1464
1513
|
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
1465
|
-
// Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
|
|
1514
|
+
// Local reads disabled (e.g. `--force` / `--cache-policy=local:w`): report a
|
|
1466
1515
|
// miss so the task re-executes. The artifact + index are untouched.
|
|
1467
1516
|
if (!this.read) return null
|
|
1468
1517
|
return this.guard(async () => {
|
|
@@ -1552,7 +1601,7 @@ export class Cache implements CacheLayer {
|
|
|
1552
1601
|
* serve hits out of the PRE-EXISTING local cache" — it must not throw
|
|
1553
1602
|
* away bytes this run just downloaded. `LayeredCache` calls this to
|
|
1554
1603
|
* deliver an artifact it ingested from the remote; under
|
|
1555
|
-
* `--cache=local:,remote:rw` the gated `get` returned null for the row
|
|
1604
|
+
* `--cache-policy=local:,remote:rw` the gated `get` returned null for the row
|
|
1556
1605
|
* `ingest` had just written, so the task re-executed and re-uploaded on
|
|
1557
1606
|
* every single run.
|
|
1558
1607
|
*/
|
|
@@ -1799,7 +1848,7 @@ export class Cache implements CacheLayer {
|
|
|
1799
1848
|
// outputs, so a restore that did not fail here would report a green cache
|
|
1800
1849
|
// hit over an emptied output tree. The artifact existed when `get()`
|
|
1801
1850
|
// probed it, so its absence now means something removed it underneath us
|
|
1802
|
-
// (a `vx cache
|
|
1851
|
+
// (a `vx cache clean` in another shell, or another workspace's retention
|
|
1803
1852
|
// on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
|
|
1804
1853
|
// turns into a miss: the task runs.
|
|
1805
1854
|
//
|
|
@@ -1991,6 +2040,8 @@ export class Cache implements CacheLayer {
|
|
|
1991
2040
|
workspaceOutputFiles?: string[]
|
|
1992
2041
|
workspaceRoot?: string
|
|
1993
2042
|
inputComponents?: readonly TaskInputRow[]
|
|
2043
|
+
/** See `CacheLayer.save`. */
|
|
2044
|
+
readable?: ((real: string) => boolean) | undefined
|
|
1994
2045
|
}): Promise<void> {
|
|
1995
2046
|
// Layout (v17, extended additively for workspaceFiles): one
|
|
1996
2047
|
// `<hash>.tar.zst` per entry. Tar carries ONLY the things you'd
|
|
@@ -2005,7 +2056,7 @@ export class Cache implements CacheLayer {
|
|
|
2005
2056
|
// SQLite, not the artifact. Remote-hit ingestion takes metadata
|
|
2006
2057
|
// through `ingest()` arguments — the artifact stays clean bytes.
|
|
2007
2058
|
//
|
|
2008
|
-
// Local writes disabled (e.g. `--cache=local:,remote:rw`): produce
|
|
2059
|
+
// Local writes disabled (e.g. `--cache-policy=local:,remote:rw`): produce
|
|
2009
2060
|
// no `<hash>.tar.zst` and no index row. The LayeredCache wrapping us
|
|
2010
2061
|
// still uploads to remote — it calls `packArtifact` itself to get
|
|
2011
2062
|
// the bytes, since there's no local artifact to read off disk.
|
|
@@ -2188,6 +2239,7 @@ export class Cache implements CacheLayer {
|
|
|
2188
2239
|
outputFiles: string[]
|
|
2189
2240
|
workspaceOutputFiles?: string[]
|
|
2190
2241
|
workspaceRoot?: string
|
|
2242
|
+
readable?: ((real: string) => boolean) | undefined
|
|
2191
2243
|
}): Promise<Uint8Array> {
|
|
2192
2244
|
// stdout is ALWAYS present in the artifact, even if empty, so the
|
|
2193
2245
|
// layout is predictable: a successful read finds `stdout` and
|
|
@@ -2211,6 +2263,7 @@ export class Cache implements CacheLayer {
|
|
|
2211
2263
|
outputs: this.outputsOf(args),
|
|
2212
2264
|
exec: usageOfEntry(args.entry),
|
|
2213
2265
|
within: args.projectDir,
|
|
2266
|
+
readable: args.readable,
|
|
2214
2267
|
})
|
|
2215
2268
|
if (plan.size > this.artifactCeiling) {
|
|
2216
2269
|
throw new Error(
|
|
@@ -2601,7 +2654,7 @@ export class Cache implements CacheLayer {
|
|
|
2601
2654
|
* scan of the index and one read of the sweep's clock — never the
|
|
2602
2655
|
* sweep's readdir. Null when nothing was due or the directory is not this
|
|
2603
2656
|
* handle's to write. The local WRITE axis does not gate it: under
|
|
2604
|
-
* `--cache=local:r,remote:rw` a save writes nothing here, but every remote
|
|
2657
|
+
* `--cache-policy=local:r,remote:rw` a save writes nothing here, but every remote
|
|
2605
2658
|
* hit is still ingested (`ingest`, ungated by design), and gated on that
|
|
2606
2659
|
* axis the retention never ran while the store grew hit by hit (A-7).
|
|
2607
2660
|
*/
|
|
@@ -2887,20 +2940,19 @@ export class Cache implements CacheLayer {
|
|
|
2887
2940
|
// Run-history retention: the runs table grows by one row per
|
|
2888
2941
|
// executed task per invocation — a 2000-task repo accretes ~20k
|
|
2889
2942
|
// rows in days, inflating insert and checkpoint cost forever.
|
|
2890
|
-
// 30 days comfortably covers `vx
|
|
2943
|
+
// 30 days comfortably covers `vx info` (24 h windows) and any
|
|
2891
2944
|
// CI-side analytics consumers. The `invocations` header table (one
|
|
2892
2945
|
// row per `vx run`) is pruned on the SAME window — otherwise a
|
|
2893
2946
|
// header would outlive its `runs` rows, so `vx info`/`vx last` would
|
|
2894
2947
|
// list an invocation whose task detail is already gone, and the
|
|
2895
2948
|
// table would grow unbounded on a long-lived checkout.
|
|
2896
2949
|
// A reading verb and a dry run delete nothing: `vx last --list` over
|
|
2897
|
-
// month-old runs emptied the history it listed, and `vx cache
|
|
2950
|
+
// month-old runs emptied the history it listed, and `vx cache clean
|
|
2898
2951
|
// --dry-run` pruned it (item 1004).
|
|
2899
2952
|
try {
|
|
2900
2953
|
if (!this.inspecting) {
|
|
2901
2954
|
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
2902
2955
|
this.history.pruneOlderThan(cutoff)
|
|
2903
|
-
this.configEvals.pruneOlderThan(cutoff)
|
|
2904
2956
|
if (this.write) this.files.pruneOlderThan(cutoff)
|
|
2905
2957
|
}
|
|
2906
2958
|
} catch {
|