@vzn/vx 0.0.636 → 0.0.638
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 +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- 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 +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- 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 +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- 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 +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- 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 +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- 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-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- 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 +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -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/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- 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 +65 -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 +307 -215
- 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 +363 -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/skills/vx/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: vx
|
|
3
|
-
description: Run, inspect and debug tasks in a vx monorepo (vx.config.ts, vx
|
|
3
|
+
description: Run, inspect and debug tasks in a vx monorepo (vx.config.ts, .vx/workspace.ts). Use to build or test projects, find what a change affects, read why a task failed or re-ran, and query the cache as JSON.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# vx
|
|
@@ -27,10 +27,21 @@ A refusal under `--format json` is one line on stdout,
|
|
|
27
27
|
Without a TTY a missing task name never prompts: vx exits 1 and lists the
|
|
28
28
|
tasks it found.
|
|
29
29
|
|
|
30
|
+
## After editing a config
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
vx lock # after any edit to a vx.config.ts or .vx/workspace.ts
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A run reads every config from `.vx/projects.lock.json`, never the files:
|
|
37
|
+
a task you add, or an edit to one, does nothing until `vx lock`. Commit
|
|
38
|
+
the lock with the config.
|
|
39
|
+
|
|
30
40
|
## Look before running
|
|
31
41
|
|
|
32
42
|
```sh
|
|
33
|
-
vx show --format json # projects
|
|
43
|
+
vx show --format json # projects and their tasks
|
|
44
|
+
vx show app --format json # one project's tasks as a run reads them
|
|
34
45
|
vx run test --affected=origin/main --dry=json # what would run, cache hit or miss, and why
|
|
35
46
|
```
|
|
36
47
|
|
|
@@ -59,22 +70,20 @@ files, fix, and re-run the same command: cached tasks that passed replay from th
|
|
|
59
70
|
vx why app#build --format json # which input changed, down to the root cause
|
|
60
71
|
```
|
|
61
72
|
|
|
62
|
-
##
|
|
73
|
+
## Run history
|
|
63
74
|
|
|
64
75
|
```sh
|
|
65
|
-
vx
|
|
76
|
+
vx last --stats --format json # per task: p50 (the live estimate's), p99, pass and hit rate, failure mode, peak RSS
|
|
77
|
+
vx last --stats --branch main --format json # p50 from main's timings
|
|
78
|
+
vx last --stats app#build --format json # one task; a task name covers every project
|
|
79
|
+
vx last --list --format json # recent runs, newest first
|
|
66
80
|
```
|
|
67
81
|
|
|
68
|
-
##
|
|
82
|
+
## Workspace health
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`getRunHistory`, `explainCacheKey`, `whyDidThisRerun`, `getFailures`, `getTaskLog`, `getConfig`, `checkLock`, `pruneCache`, `planInit`, `searchDocs`,
|
|
74
|
-
`getWorkspaceInfo`, `runTasks`, `planTasks`. `runTasks` answers what
|
|
75
|
-
`vx run <tasks> --format json` prints, `planTasks` what
|
|
76
|
-
`vx run <tasks> --dry=json` prints, `pruneCache` evicts only with
|
|
77
|
-
`dryRun: false`; the others only read.
|
|
84
|
+
```sh
|
|
85
|
+
vx info --format json # vx and Bun versions, projects, config errors, plugins, cache
|
|
86
|
+
```
|
|
78
87
|
|
|
79
88
|
## Rules
|
|
80
89
|
|
|
@@ -93,3 +102,9 @@ same facts and runs tasks. Adoption does not add it: install
|
|
|
93
102
|
`https://raw.githubusercontent.com/vznjs/vx/main/packages/vx-docs/src/content/docs/<page>.md`:
|
|
94
103
|
`quickstart`, `guides/migrate`, `guides/configure`, `guides/agents`,
|
|
95
104
|
`guides/troubleshooting`, `guides/ci`, `guides/plugins`.
|
|
105
|
+
|
|
106
|
+
## Install
|
|
107
|
+
|
|
108
|
+
`npx skills add vznjs/vx` installs this skill. To match the vx a workspace
|
|
109
|
+
has installed, copy it from there instead:
|
|
110
|
+
`cp node_modules/@vzn/vx/skills/vx/SKILL.md .claude/skills/vx/`.
|
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
|
}
|
|
@@ -31,7 +46,7 @@ async function main(): Promise<void> {
|
|
|
31
46
|
process.exitCode = code
|
|
32
47
|
} catch (err) {
|
|
33
48
|
const {
|
|
34
|
-
|
|
49
|
+
detectColors,
|
|
35
50
|
errorCode,
|
|
36
51
|
errorDocument,
|
|
37
52
|
fsRefusalHint,
|
|
@@ -40,6 +55,8 @@ async function main(): Promise<void> {
|
|
|
40
55
|
isUserError,
|
|
41
56
|
maskedLine,
|
|
42
57
|
OUT_OF_FDS_HINT,
|
|
58
|
+
refusalNotice,
|
|
59
|
+
ui,
|
|
43
60
|
wantsJson,
|
|
44
61
|
} = await import('./util/index.js')
|
|
45
62
|
// A plugin's failure quotes what it was told (a remote's reply, a
|
|
@@ -51,12 +68,16 @@ async function main(): Promise<void> {
|
|
|
51
68
|
// on. Everything else gets the full stack so internal bugs are
|
|
52
69
|
// debuggable.
|
|
53
70
|
if (isUserError(err)) {
|
|
54
|
-
// A message that
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
71
|
+
// A message that names its verb (`vx why: …`) is that verb's; any
|
|
72
|
+
// other is the verb typed. A config refusal shows its line.
|
|
73
|
+
const named = /^(vx [a-z][\w-]*): /.exec(err.message)
|
|
74
|
+
const typed = /^[a-z][\w-]*$/.test(argv[0] ?? '') ? `vx ${argv[0]}` : 'vx'
|
|
75
|
+
const block = refusalNotice(
|
|
76
|
+
named?.[1] ?? typed,
|
|
77
|
+
named === null ? err.message : err.message.slice(named[0].length),
|
|
78
|
+
err.parts,
|
|
79
|
+
)
|
|
80
|
+
process.stderr.write(maskedLine(ui.render([block], detectColors(process.stderr))))
|
|
60
81
|
} else if (isOutOfFds(err)) {
|
|
61
82
|
say(`vx: ${err.message} — ${OUT_OF_FDS_HINT}\n`)
|
|
62
83
|
} else if (isFsRefusal(err)) {
|
|
@@ -87,7 +108,7 @@ async function main(): Promise<void> {
|
|
|
87
108
|
* config can leave a timer behind (a top-level `setInterval`, an await that
|
|
88
109
|
* hit its evaluation budget), and the loop it holds never drained: vx
|
|
89
110
|
* printed its verdict and hung for good. Only after `main` has settled, so
|
|
90
|
-
* `vx watch`,
|
|
111
|
+
* `vx watch`, a plugin's long-lived verb and a run's kept-alive tasks end when their verb
|
|
91
112
|
* does. `end(cb)` delivers a large write whole on Bun 1.4.2 (4 MiB under a
|
|
92
113
|
* slow reader; `write('', cb)` delivered 450 KB), the floor `engines.bun`
|
|
93
114
|
* 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,18 @@ 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
|
+
// v37: the store's plugin_blobs (a plugin's `ctx.cache`) and one
|
|
307
|
+
// 'remote' task_durations row per (sha, task). The cache KEY is
|
|
308
|
+
// unchanged.
|
|
309
|
+
// v38: blob_sizes moved to the store, and the store's task_outcomes
|
|
310
|
+
// holds the flaky record, so worktrees share both. The cache KEY is
|
|
311
|
+
// unchanged.
|
|
312
|
+
export const SCHEMA_VERSION = 'v38'
|
|
267
313
|
|
|
268
314
|
/**
|
|
269
315
|
* An entry's key components as its one `entry_inputs` row (v34): JSON
|
|
@@ -288,7 +334,17 @@ function encodeEntryInputs(
|
|
|
288
334
|
}
|
|
289
335
|
|
|
290
336
|
/** The tables a store holds: dropped from a workspace index that held them itself. */
|
|
291
|
-
const STORE_TABLES = [
|
|
337
|
+
const STORE_TABLES = [
|
|
338
|
+
'entry_inputs',
|
|
339
|
+
'output_files',
|
|
340
|
+
'entry_stdout',
|
|
341
|
+
'task_durations',
|
|
342
|
+
'task_outcomes',
|
|
343
|
+
'blob_sizes',
|
|
344
|
+
'plugin_blobs',
|
|
345
|
+
'store_meta',
|
|
346
|
+
'entries',
|
|
347
|
+
]
|
|
292
348
|
|
|
293
349
|
/**
|
|
294
350
|
* The inline artifacts' tables (schema.ts): dropped from a workspace index
|
|
@@ -549,13 +605,13 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
|
|
|
549
605
|
* first. An existing cache costs three calls; it cost six.
|
|
550
606
|
*/
|
|
551
607
|
function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
|
|
552
|
-
const ignore =
|
|
608
|
+
const [ignore, ignoreText] = ignoreFileFor(cacheDir)
|
|
553
609
|
try {
|
|
554
610
|
accessSync(cacheDir, constants.W_OK)
|
|
555
611
|
} catch (err) {
|
|
556
612
|
makeCacheDir(cacheDir)
|
|
557
613
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return errorText(err)
|
|
558
|
-
|
|
614
|
+
writeIgnore(ignore, ignoreText)
|
|
559
615
|
return null
|
|
560
616
|
}
|
|
561
617
|
try {
|
|
@@ -567,11 +623,7 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
|
|
|
567
623
|
if (code !== 'ENOENT') return errorText(err)
|
|
568
624
|
refuseManifestDir(cacheDir, workspaceRoot)
|
|
569
625
|
}
|
|
570
|
-
|
|
571
|
-
writeFileSync(ignore, IGNORE_ALL, { flag: 'wx' })
|
|
572
|
-
} catch (err) {
|
|
573
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
574
|
-
}
|
|
626
|
+
writeIgnore(ignore, ignoreText)
|
|
575
627
|
return null
|
|
576
628
|
}
|
|
577
629
|
|
|
@@ -630,6 +682,29 @@ function ensurePrivateDir(dir: string): string | null {
|
|
|
630
682
|
const utf8 = new TextEncoder()
|
|
631
683
|
const IGNORE_ALL = '*\n'
|
|
632
684
|
|
|
685
|
+
/**
|
|
686
|
+
* The ignore file that keeps `cacheDir` out of git, and its text. The
|
|
687
|
+
* default `.vx/cache` is named by `.vx/.gitignore` (`/cache`), committed
|
|
688
|
+
* beside the workspace config and the lock (owner, 2026-10-14); any other
|
|
689
|
+
* cache directory holds a `*` one of its own. Written only when missing:
|
|
690
|
+
* an existing file is the user's.
|
|
691
|
+
*/
|
|
692
|
+
function writeIgnore(file: string, text: string): void {
|
|
693
|
+
try {
|
|
694
|
+
writeFileSync(file, text, { flag: 'wx' })
|
|
695
|
+
} catch (err) {
|
|
696
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function ignoreFileFor(cacheDir: string): [string, string] {
|
|
701
|
+
const parent = path.dirname(cacheDir)
|
|
702
|
+
if (path.basename(cacheDir) === 'cache' && path.basename(parent) === '.vx') {
|
|
703
|
+
return [path.join(parent, '.gitignore'), DOT_VX_GITIGNORE]
|
|
704
|
+
}
|
|
705
|
+
return [path.join(cacheDir, '.gitignore'), IGNORE_ALL]
|
|
706
|
+
}
|
|
707
|
+
|
|
633
708
|
/**
|
|
634
709
|
* A first index in a directory that holds a manifest is a cache pointed at
|
|
635
710
|
* the workspace or a project (`cacheDir: ''`, `'.'`, `'packages/a'`): the
|
|
@@ -675,9 +750,17 @@ function refuseManifestDir(cacheDir: string, workspaceRoot: string | undefined):
|
|
|
675
750
|
}
|
|
676
751
|
}
|
|
677
752
|
|
|
678
|
-
|
|
753
|
+
// What marks a project or workspace directory: a vx config (workspace
|
|
754
|
+
// PROJECT_CONFIG_FILENAMES) or the workspace file, and a package manifest,
|
|
755
|
+
// which a `discover` plugin makes a project with no config.
|
|
756
|
+
const MANIFESTS = [
|
|
757
|
+
'package.json',
|
|
758
|
+
'pnpm-workspace.yaml',
|
|
759
|
+
...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `vx.config.${ext}`),
|
|
760
|
+
...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `.vx/workspace.${ext}`),
|
|
761
|
+
]
|
|
679
762
|
const OWN_DIR_HINT =
|
|
680
|
-
'Point `
|
|
763
|
+
'Point `cache.dir` in .vx/workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
|
|
681
764
|
|
|
682
765
|
function makeCacheDir(cacheDir: string): void {
|
|
683
766
|
try {
|
|
@@ -687,7 +770,7 @@ function makeCacheDir(cacheDir: string): void {
|
|
|
687
770
|
// so this is the first thing any of them says there.
|
|
688
771
|
throw new UserError(
|
|
689
772
|
`cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; make ` +
|
|
690
|
-
`the workspace writable, set \`
|
|
773
|
+
`the workspace writable, set \`cache.dir\` in .vx/workspace.ts, or pass --cache-dir <path>`,
|
|
691
774
|
)
|
|
692
775
|
}
|
|
693
776
|
}
|
|
@@ -744,7 +827,6 @@ export class Cache implements CacheLayer {
|
|
|
744
827
|
private readonly insertEntryInput: ReturnType<Database['prepare']>
|
|
745
828
|
/** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
|
|
746
829
|
private readonly files: FileHashStore
|
|
747
|
-
private readonly configEvals: ConfigEvalTable
|
|
748
830
|
private readonly outputs: OutputIndex
|
|
749
831
|
private readonly history: RunHistory
|
|
750
832
|
|
|
@@ -832,9 +914,8 @@ export class Cache implements CacheLayer {
|
|
|
832
914
|
// The directory exists before the DB opens — bun:sqlite won't create
|
|
833
915
|
// parent dirs for us. A directory this user cannot write into is a
|
|
834
916
|
// 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
|
|
917
|
+
// doctor, the watch sweep): the write axis goes off, so the file-hash
|
|
918
|
+
// memo skips its upserts instead of dying in SQLite on the first miss (an unprivileged user on
|
|
838
919
|
// a root-owned `.vx`, 2026-09-16). A run wants more than a quiet
|
|
839
920
|
// read-only cache — `assertWritable()`.
|
|
840
921
|
// A reading verb over a directory with no index yet reads an empty one
|
|
@@ -958,7 +1039,7 @@ export class Cache implements CacheLayer {
|
|
|
958
1039
|
// keeps it so).
|
|
959
1040
|
if (found !== undefined) {
|
|
960
1041
|
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;',
|
|
1042
|
+
'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.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta;',
|
|
962
1043
|
)
|
|
963
1044
|
}
|
|
964
1045
|
// The tables land under the same write lock as the stamp: made
|
|
@@ -1119,7 +1200,6 @@ export class Cache implements CacheLayer {
|
|
|
1119
1200
|
// The slices: each owns its statements over this handle and its table(s);
|
|
1120
1201
|
// the schema above is the one place every table is declared.
|
|
1121
1202
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
1122
|
-
this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
|
|
1123
1203
|
this.outputs = new OutputIndex(this.db)
|
|
1124
1204
|
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
1125
1205
|
|
|
@@ -1148,7 +1228,7 @@ export class Cache implements CacheLayer {
|
|
|
1148
1228
|
* corrupt past the ones the open reads — to the open's own refusal. The
|
|
1149
1229
|
* open reads the header and `schema_meta` alone (item 1005), so a file
|
|
1150
1230
|
* 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
|
|
1231
|
+
* task of a run failed as an "internal error", and `vx cache clean` printed
|
|
1152
1232
|
* a stack (A-8). Every other error passes through as it was.
|
|
1153
1233
|
*/
|
|
1154
1234
|
private guard<T>(op: () => T): T {
|
|
@@ -1331,28 +1411,6 @@ export class Cache implements CacheLayer {
|
|
|
1331
1411
|
.immediate()
|
|
1332
1412
|
}
|
|
1333
1413
|
|
|
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
1414
|
// --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
|
|
1357
1415
|
hashFile(filePath: string): Promise<string> {
|
|
1358
1416
|
return this.guard(() => this.files.hashFile(filePath))
|
|
@@ -1425,8 +1483,8 @@ export class Cache implements CacheLayer {
|
|
|
1425
1483
|
|
|
1426
1484
|
/**
|
|
1427
1485
|
* 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
|
|
1486
|
+
* args after `--`): xxh3 under a random salt this store keeps. `vx why`
|
|
1487
|
+
* and its JSON print it, and an unkeyed xxh3 in
|
|
1430
1488
|
* a public CI log let anyone confirm or brute-force a short secret
|
|
1431
1489
|
* (L-4). Salted, a digest still says whether a value changed, and says
|
|
1432
1490
|
* nothing without this cache's `schema_meta`. Asked for on a miss only.
|
|
@@ -1462,7 +1520,7 @@ export class Cache implements CacheLayer {
|
|
|
1462
1520
|
// the entries row. It's part of the contract so LayeredCache can
|
|
1463
1521
|
// route metadata to `ingest()` on remote-hit without a separate API.
|
|
1464
1522
|
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
1465
|
-
// Local reads disabled (e.g. `--force` / `--cache=local:w`): report a
|
|
1523
|
+
// Local reads disabled (e.g. `--force` / `--cache-policy=local:w`): report a
|
|
1466
1524
|
// miss so the task re-executes. The artifact + index are untouched.
|
|
1467
1525
|
if (!this.read) return null
|
|
1468
1526
|
return this.guard(async () => {
|
|
@@ -1552,7 +1610,7 @@ export class Cache implements CacheLayer {
|
|
|
1552
1610
|
* serve hits out of the PRE-EXISTING local cache" — it must not throw
|
|
1553
1611
|
* away bytes this run just downloaded. `LayeredCache` calls this to
|
|
1554
1612
|
* deliver an artifact it ingested from the remote; under
|
|
1555
|
-
* `--cache=local:,remote:rw` the gated `get` returned null for the row
|
|
1613
|
+
* `--cache-policy=local:,remote:rw` the gated `get` returned null for the row
|
|
1556
1614
|
* `ingest` had just written, so the task re-executed and re-uploaded on
|
|
1557
1615
|
* every single run.
|
|
1558
1616
|
*/
|
|
@@ -1688,6 +1746,16 @@ export class Cache implements CacheLayer {
|
|
|
1688
1746
|
return this.guard(() => this.hasEntry(hash))
|
|
1689
1747
|
}
|
|
1690
1748
|
|
|
1749
|
+
/**
|
|
1750
|
+
* The subset of `hashes` this store holds, `has` for each: the prefetch
|
|
1751
|
+
* pass asks the remote only about the rest (M, 2026-10-15).
|
|
1752
|
+
*/
|
|
1753
|
+
async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
|
|
1754
|
+
const out = new Set<string>()
|
|
1755
|
+
for (const hash of hashes) if ((await this.has(hash)) === 'local') out.add(hash)
|
|
1756
|
+
return out
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1691
1759
|
private async hasEntry(hash: string): Promise<'local' | 'remote' | null> {
|
|
1692
1760
|
if (!this.read) return null
|
|
1693
1761
|
const row = this.entryExists.get(hash) as { inline_at: number | null } | null
|
|
@@ -1799,7 +1867,7 @@ export class Cache implements CacheLayer {
|
|
|
1799
1867
|
// outputs, so a restore that did not fail here would report a green cache
|
|
1800
1868
|
// hit over an emptied output tree. The artifact existed when `get()`
|
|
1801
1869
|
// probed it, so its absence now means something removed it underneath us
|
|
1802
|
-
// (a `vx cache
|
|
1870
|
+
// (a `vx cache clean` in another shell, or another workspace's retention
|
|
1803
1871
|
// on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
|
|
1804
1872
|
// turns into a miss: the task runs.
|
|
1805
1873
|
//
|
|
@@ -1991,6 +2059,8 @@ export class Cache implements CacheLayer {
|
|
|
1991
2059
|
workspaceOutputFiles?: string[]
|
|
1992
2060
|
workspaceRoot?: string
|
|
1993
2061
|
inputComponents?: readonly TaskInputRow[]
|
|
2062
|
+
/** See `CacheLayer.save`. */
|
|
2063
|
+
readable?: ((real: string) => boolean) | undefined
|
|
1994
2064
|
}): Promise<void> {
|
|
1995
2065
|
// Layout (v17, extended additively for workspaceFiles): one
|
|
1996
2066
|
// `<hash>.tar.zst` per entry. Tar carries ONLY the things you'd
|
|
@@ -2005,7 +2075,7 @@ export class Cache implements CacheLayer {
|
|
|
2005
2075
|
// SQLite, not the artifact. Remote-hit ingestion takes metadata
|
|
2006
2076
|
// through `ingest()` arguments — the artifact stays clean bytes.
|
|
2007
2077
|
//
|
|
2008
|
-
// Local writes disabled (e.g. `--cache=local:,remote:rw`): produce
|
|
2078
|
+
// Local writes disabled (e.g. `--cache-policy=local:,remote:rw`): produce
|
|
2009
2079
|
// no `<hash>.tar.zst` and no index row. The LayeredCache wrapping us
|
|
2010
2080
|
// still uploads to remote — it calls `packArtifact` itself to get
|
|
2011
2081
|
// the bytes, since there's no local artifact to read off disk.
|
|
@@ -2188,6 +2258,7 @@ export class Cache implements CacheLayer {
|
|
|
2188
2258
|
outputFiles: string[]
|
|
2189
2259
|
workspaceOutputFiles?: string[]
|
|
2190
2260
|
workspaceRoot?: string
|
|
2261
|
+
readable?: ((real: string) => boolean) | undefined
|
|
2191
2262
|
}): Promise<Uint8Array> {
|
|
2192
2263
|
// stdout is ALWAYS present in the artifact, even if empty, so the
|
|
2193
2264
|
// layout is predictable: a successful read finds `stdout` and
|
|
@@ -2211,6 +2282,7 @@ export class Cache implements CacheLayer {
|
|
|
2211
2282
|
outputs: this.outputsOf(args),
|
|
2212
2283
|
exec: usageOfEntry(args.entry),
|
|
2213
2284
|
within: args.projectDir,
|
|
2285
|
+
readable: args.readable,
|
|
2214
2286
|
})
|
|
2215
2287
|
if (plan.size > this.artifactCeiling) {
|
|
2216
2288
|
throw new Error(
|
|
@@ -2601,7 +2673,7 @@ export class Cache implements CacheLayer {
|
|
|
2601
2673
|
* scan of the index and one read of the sweep's clock — never the
|
|
2602
2674
|
* sweep's readdir. Null when nothing was due or the directory is not this
|
|
2603
2675
|
* 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
|
|
2676
|
+
* `--cache-policy=local:r,remote:rw` a save writes nothing here, but every remote
|
|
2605
2677
|
* hit is still ingested (`ingest`, ungated by design), and gated on that
|
|
2606
2678
|
* axis the retention never ran while the store grew hit by hit (A-7).
|
|
2607
2679
|
*/
|
|
@@ -2887,20 +2959,19 @@ export class Cache implements CacheLayer {
|
|
|
2887
2959
|
// Run-history retention: the runs table grows by one row per
|
|
2888
2960
|
// executed task per invocation — a 2000-task repo accretes ~20k
|
|
2889
2961
|
// rows in days, inflating insert and checkpoint cost forever.
|
|
2890
|
-
// 30 days comfortably covers `vx
|
|
2962
|
+
// 30 days comfortably covers `vx info` (24 h windows) and any
|
|
2891
2963
|
// CI-side analytics consumers. The `invocations` header table (one
|
|
2892
2964
|
// row per `vx run`) is pruned on the SAME window — otherwise a
|
|
2893
2965
|
// header would outlive its `runs` rows, so `vx info`/`vx last` would
|
|
2894
2966
|
// list an invocation whose task detail is already gone, and the
|
|
2895
2967
|
// table would grow unbounded on a long-lived checkout.
|
|
2896
2968
|
// 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
|
|
2969
|
+
// month-old runs emptied the history it listed, and `vx cache clean
|
|
2898
2970
|
// --dry-run` pruned it (item 1004).
|
|
2899
2971
|
try {
|
|
2900
2972
|
if (!this.inspecting) {
|
|
2901
2973
|
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
2902
2974
|
this.history.pruneOlderThan(cutoff)
|
|
2903
|
-
this.configEvals.pruneOlderThan(cutoff)
|
|
2904
2975
|
if (this.write) this.files.pruneOlderThan(cutoff)
|
|
2905
2976
|
}
|
|
2906
2977
|
} catch {
|
|
@@ -108,6 +108,10 @@ export class ChainedCache implements CacheLayer {
|
|
|
108
108
|
return false
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
localHasMany(hashes: readonly string[]): Promise<Set<string>> {
|
|
112
|
+
return this.layers[0]!.localHasMany?.(hashes) ?? Promise.resolve(new Set())
|
|
113
|
+
}
|
|
114
|
+
|
|
111
115
|
async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
112
116
|
// The caller (remote-prefetch) treats a non-null answer as authoritative
|
|
113
117
|
// for the WHOLE chain: complement = absent, broadcast. That is sound only
|