@vzn/vx 0.0.639 → 0.0.641
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 +1 -1
- package/docs/caching.md +158 -106
- package/docs/cli.md +80 -45
- package/docs/execution.md +11 -10
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +252 -190
- package/docs/security.md +5 -4
- package/launcher.sh +9 -2
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +248 -41
- package/src/cache/cache.ts +46 -19
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +2 -2
- package/src/cache/index.ts +6 -0
- package/src/cache/inputs.ts +228 -111
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +15 -8
- package/src/cache/layered-cache.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/scoped-cache.ts +8 -2
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +7 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +24 -1
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +9 -6
- package/src/cli/upgrade.ts +132 -21
- package/src/cli/watch-filter.ts +23 -12
- package/src/cli/watch-fs.ts +2 -1
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +74 -2
- package/src/cli/why.ts +5 -3
- package/src/config.ts +19 -15
- package/src/exec/executor.ts +4 -4
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +8 -1
- package/src/graph/scheduler.ts +41 -15
- package/src/graph/task-graph.ts +135 -73
- package/src/handoff.ts +5 -0
- package/src/index.ts +2 -2
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-task.ts +29 -25
- package/src/orchestrator/hit-restore.ts +101 -27
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +9 -1
- package/src/orchestrator/persistent-task.ts +3 -3
- package/src/orchestrator/placement.ts +2 -2
- package/src/orchestrator/plan.ts +1 -1
- package/src/orchestrator/plugin-host.ts +66 -27
- package/src/orchestrator/plugin.ts +12 -2
- package/src/orchestrator/prepare.ts +12 -5
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/run-report.ts +145 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +76 -44
- package/src/orchestrator/sandbox-fix.ts +1 -1
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/stable-keys.ts +59 -26
- package/src/orchestrator/steps.ts +26 -6
- package/src/orchestrator/task-hash.ts +31 -9
- package/src/orchestrator/telemetry-host.ts +3 -3
- package/src/orchestrator/telemetry.ts +9 -4
- package/src/orchestrator/upstream.ts +4 -181
- package/src/report/framed-output.ts +1 -1
- package/src/report/logger.ts +5 -2
- package/src/report/status-line.ts +7 -3
- package/src/report/summary.ts +6 -4
- package/src/settings.ts +11 -17
- package/src/util/index.ts +3 -0
- package/src/util/paths.ts +39 -5
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/src/workspace/project-loader.ts +5 -4
package/src/cache/cache.ts
CHANGED
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
isFsRefusal,
|
|
61
61
|
isOutOfFds,
|
|
62
62
|
OUT_OF_FDS_HINT,
|
|
63
|
-
|
|
63
|
+
restorableOutput,
|
|
64
64
|
relPosix,
|
|
65
65
|
span,
|
|
66
66
|
splitTaskId,
|
|
@@ -112,6 +112,7 @@ import { FileHashStore } from './file-hashes.js'
|
|
|
112
112
|
import { OutputIndex } from './output-index.js'
|
|
113
113
|
import { RunHistory } from './run-history.js'
|
|
114
114
|
import { CACHE_VERSION, foldKey } from './key-fold.js'
|
|
115
|
+
import { behindLink } from './linked-inputs.js'
|
|
115
116
|
|
|
116
117
|
/**
|
|
117
118
|
* An artifact or temp file without an `entries` row is never reaped
|
|
@@ -1493,11 +1494,13 @@ export class Cache implements CacheLayer {
|
|
|
1493
1494
|
}
|
|
1494
1495
|
|
|
1495
1496
|
key(input: CacheKeyInput): Promise<string> {
|
|
1497
|
+
const hashFile = (f: string): Promise<string> => this.hashFile(f)
|
|
1496
1498
|
return foldKey(
|
|
1497
1499
|
input,
|
|
1498
|
-
|
|
1500
|
+
hashFile,
|
|
1499
1501
|
(f) => this.relFor(input.workspaceRoot, f),
|
|
1500
1502
|
(v) => this.digestValue(v),
|
|
1503
|
+
(link) => behindLink(link, hashFile),
|
|
1501
1504
|
)
|
|
1502
1505
|
}
|
|
1503
1506
|
|
|
@@ -1839,9 +1842,14 @@ export class Cache implements CacheLayer {
|
|
|
1839
1842
|
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
|
|
1840
1843
|
return this.outputs.outputDirsCurrent(projectDir, rows)
|
|
1841
1844
|
}
|
|
1842
|
-
async restoreOutputs(
|
|
1845
|
+
async restoreOutputs(
|
|
1846
|
+
hash: string,
|
|
1847
|
+
projectDir: string,
|
|
1848
|
+
workspaceRoot?: string,
|
|
1849
|
+
outputs?: DeclaredOutputs,
|
|
1850
|
+
): Promise<void> {
|
|
1843
1851
|
try {
|
|
1844
|
-
await this.restoreOutputsOnce(hash, projectDir, workspaceRoot)
|
|
1852
|
+
await this.restoreOutputsOnce(hash, projectDir, workspaceRoot, outputs)
|
|
1845
1853
|
} catch (err) {
|
|
1846
1854
|
if (!(err instanceof CorruptArtifactError)) throw err
|
|
1847
1855
|
// Bad bytes under a good key failed the task on every run until
|
|
@@ -1876,7 +1884,8 @@ export class Cache implements CacheLayer {
|
|
|
1876
1884
|
private async restoreOutputsOnce(
|
|
1877
1885
|
hash: string,
|
|
1878
1886
|
projectDir: string,
|
|
1879
|
-
workspaceRoot
|
|
1887
|
+
workspaceRoot: string | undefined,
|
|
1888
|
+
outputs: DeclaredOutputs | undefined,
|
|
1880
1889
|
): Promise<void> {
|
|
1881
1890
|
// In-process extraction — no fork+exec on the hot path
|
|
1882
1891
|
// (~5-10ms reclaimed per hit vs the prior subprocess `tar -xf`).
|
|
@@ -1964,7 +1973,14 @@ export class Cache implements CacheLayer {
|
|
|
1964
1973
|
// four workers overlap their round trips and a blocking one stalls
|
|
1965
1974
|
// the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
|
|
1966
1975
|
const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
|
|
1967
|
-
await extractArtifactStream(
|
|
1976
|
+
await extractArtifactStream(
|
|
1977
|
+
tar,
|
|
1978
|
+
projectDir,
|
|
1979
|
+
workspaceRoot,
|
|
1980
|
+
verify,
|
|
1981
|
+
outputs === undefined ? undefined : declaredName(outputs),
|
|
1982
|
+
workspaceRoot,
|
|
1983
|
+
)
|
|
1968
1984
|
} catch (err) {
|
|
1969
1985
|
// A UserError here is the extractor naming the tree's fault (an
|
|
1970
1986
|
// output directory that links out of the project).
|
|
@@ -1978,8 +1994,8 @@ export class Cache implements CacheLayer {
|
|
|
1978
1994
|
throw new CorruptArtifactError(hash, err.message, err)
|
|
1979
1995
|
// What is on disk, not what is in the archive: a directory standing
|
|
1980
1996
|
// where the entry holds a file, or a file where it needs a directory.
|
|
1981
|
-
// The
|
|
1982
|
-
// directories that removal emptied, so this is a path it left: one
|
|
1997
|
+
// The restore removes the FILES the output globs select that block it
|
|
1998
|
+
// and the directories that removal emptied, so this is a path it left: one
|
|
1983
1999
|
// the globs do not cover, or an empty directory where a file goes,
|
|
1984
2000
|
// which `dist/**` covers and the clean does not take (item 1094).
|
|
1985
2001
|
// Named as such, not as a corrupt artifact. A
|
|
@@ -1998,7 +2014,7 @@ export class Cache implements CacheLayer {
|
|
|
1998
2014
|
: 'a file stands where the entry needs a directory'
|
|
1999
2015
|
throw new UserError(
|
|
2000
2016
|
`restore of ${hash} into ${projectDir} was blocked by what is on disk (${msg.startsWith(code) ? msg : `${code}: ${msg}`}): ${what}. ` +
|
|
2001
|
-
`
|
|
2017
|
+
`A restore replaces what the output globs select, not this — remove it and re-run.`,
|
|
2002
2018
|
)
|
|
2003
2019
|
}
|
|
2004
2020
|
// A legal name under a destination deep enough that the two together
|
|
@@ -2046,7 +2062,7 @@ export class Cache implements CacheLayer {
|
|
|
2046
2062
|
// on one workspace, a `vx watch` beside a `vx run` — a clean landing
|
|
2047
2063
|
// 4–32 ms into a 2,000-file restore reproduces it, 2026-09-16). The
|
|
2048
2064
|
// artifact is intact; the tree was not ours alone.
|
|
2049
|
-
if (code === 'ENOENT' && (err as Error).message
|
|
2065
|
+
if (code === 'ENOENT' && /\.vx-tmp-|[\\/]\.vx[\\/]tmp[\\/]/.test((err as Error).message)) {
|
|
2050
2066
|
throw new UserError(
|
|
2051
2067
|
`restore of ${hash} into ${projectDir} was interrupted: a file it had just written vanished (${(err as Error).message}). ` +
|
|
2052
2068
|
`Another vx run is using this workspace — re-run once it is done.`,
|
|
@@ -3080,16 +3096,9 @@ function assertArtifactNames(
|
|
|
3080
3096
|
}
|
|
3081
3097
|
}
|
|
3082
3098
|
if (declared === undefined) return
|
|
3083
|
-
|
|
3084
|
-
const isFile = outputMatcher(declared.files)
|
|
3085
|
-
const isWsFile = outputMatcher(declared.workspaceFiles)
|
|
3099
|
+
const ok = declaredName(declared)
|
|
3086
3100
|
for (const name of names) {
|
|
3087
|
-
|
|
3088
|
-
? isFile(name.slice('outputs/'.length))
|
|
3089
|
-
: name.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
3090
|
-
? isWsFile(name.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
3091
|
-
: true
|
|
3092
|
-
if (!ok) {
|
|
3101
|
+
if (!ok(name)) {
|
|
3093
3102
|
throw new CorruptArtifactError(
|
|
3094
3103
|
hash,
|
|
3095
3104
|
`artifact carries ${name}, which is not one of the task's declared outputs`,
|
|
@@ -3098,6 +3107,24 @@ function assertArtifactNames(
|
|
|
3098
3107
|
}
|
|
3099
3108
|
}
|
|
3100
3109
|
|
|
3110
|
+
/**
|
|
3111
|
+
* Whether an artifact entry names one of the task's declared outputs, as
|
|
3112
|
+
* the save could have packed it (`restorableOutput`: a `!` take-back, vx's
|
|
3113
|
+
* own files and an unnamed `node_modules` are none). Asked at ingest and
|
|
3114
|
+
* again at every restore, so an entry that reached the local store by any
|
|
3115
|
+
* route still writes nothing it should not (2026-10-16).
|
|
3116
|
+
*/
|
|
3117
|
+
function declaredName(declared: DeclaredOutputs): (name: string) => boolean {
|
|
3118
|
+
const isFile = restorableOutput(declared.files)
|
|
3119
|
+
const isWsFile = restorableOutput(declared.workspaceFiles)
|
|
3120
|
+
return (name) =>
|
|
3121
|
+
name.startsWith('outputs/')
|
|
3122
|
+
? isFile(name.slice('outputs/'.length))
|
|
3123
|
+
: name.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
3124
|
+
? isWsFile(name.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
3125
|
+
: true
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3101
3128
|
/** What `ingest` reads a remote body through. */
|
|
3102
3129
|
interface ChunkReader {
|
|
3103
3130
|
read(): Promise<{ done: true; value?: undefined } | { done: false; value: Uint8Array }>
|
package/src/cache/file-hashes.ts
CHANGED
|
@@ -163,14 +163,10 @@ export class FileHashStore {
|
|
|
163
163
|
// a more useful error.
|
|
164
164
|
return await this.hashFileFromDisk(filePath)
|
|
165
165
|
}
|
|
166
|
-
// A symlink
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// cannot see. A link to a file inside the project tracks that file's
|
|
171
|
-
// content only when the file matches the task's globs too. No
|
|
172
|
-
// memo: readlink is one syscall, and the row would be keyed on the
|
|
173
|
-
// link's own stat, not its target's.
|
|
166
|
+
// A symlink's identity is git's: the blob of its TARGET STRING, its
|
|
167
|
+
// mode-120000 index OID, so a retarget moves it. What it points to
|
|
168
|
+
// folds after it (linked-inputs.ts). No memo: readlink is one syscall,
|
|
169
|
+
// and the row would be keyed on the link's own stat, not its target's.
|
|
174
170
|
if (st.isSymbolicLink()) {
|
|
175
171
|
return fileIdentity(
|
|
176
172
|
'120000',
|
|
@@ -257,12 +253,11 @@ export class FileHashStore {
|
|
|
257
253
|
for (const p of paths) {
|
|
258
254
|
if (stats.has(p) || out.has(p)) continue
|
|
259
255
|
try {
|
|
260
|
-
// lstat, not stat: `hashFile`
|
|
256
|
+
// lstat, not stat: `hashFile` identifies a symlink as git does —
|
|
261
257
|
// the blob of its TARGET STRING — and this form promises the same
|
|
262
258
|
// digest. Following the link here instead hashed the target's
|
|
263
259
|
// BYTES, so the same path identified differently depending on
|
|
264
|
-
// which entry point the caller used
|
|
265
|
-
// `git diff` and `--affected` cannot see.
|
|
260
|
+
// which entry point the caller used.
|
|
266
261
|
const st = lstatSync(p)
|
|
267
262
|
if (st.isSymbolicLink()) {
|
|
268
263
|
// No memo, for `hashFile`'s reason: the row would be keyed on
|
|
@@ -367,7 +362,7 @@ export class FileHashStore {
|
|
|
367
362
|
if (!isPermissionError(err)) throw err
|
|
368
363
|
throw new UserError(
|
|
369
364
|
`${filePath} is not readable by this user (${err.code}), and vx reads it to derive a ` +
|
|
370
|
-
'cache key. Make it readable, or, for a task input, take it out of
|
|
365
|
+
'cache key. Make it readable, or, for a task input, take it out of inputs.files.',
|
|
371
366
|
)
|
|
372
367
|
}
|
|
373
368
|
return this.hashBytes(bytes, filePath)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// How far the workspace's file system clock runs behind this process's. A
|
|
2
|
+
// racy window (`racyWindowMs`) compares a file's stamp, on the clock of the
|
|
3
|
+
// file system that holds it, with a moment vx read from `Date.now()`, as if
|
|
4
|
+
// they were one clock. A file system behind it (a WSL2 VM drifted from the
|
|
5
|
+
// Windows host that stamps `/mnt/c`, a Docker Desktop bind mount, an NFS
|
|
6
|
+
// server) dated an edit made while a command ran before the command, and the
|
|
7
|
+
// entry filed the edit's output under the key of the bytes before it. A
|
|
8
|
+
// clock ahead only makes a stamp look newer, which every window reads the
|
|
9
|
+
// safe way.
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A lag at or under this is the kernel's coarse tick, which stamps a write
|
|
16
|
+
* up to ~10 ms behind `Date.now()` on every box and which each window
|
|
17
|
+
* already holds; widening by it withheld saves after a fast upstream.
|
|
18
|
+
*/
|
|
19
|
+
const FS_CLOCK_TICK_MS = 20
|
|
20
|
+
|
|
21
|
+
let lagMs = 0
|
|
22
|
+
|
|
23
|
+
/** The lag every racy window is widened by: 0 unless measured past the tick. */
|
|
24
|
+
export function fsClockLag(): number {
|
|
25
|
+
return lagMs
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Widen every racy window by `ms`, when it is past the tick. */
|
|
29
|
+
export function setFsClockLag(ms: number): void {
|
|
30
|
+
lagMs = ms > FS_CLOCK_TICK_MS ? ms : 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a stamp's own precision may hide (see `racyWindowMs`): a stamp with
|
|
35
|
+
* no sub-second part stands for any moment in its second, an even one for
|
|
36
|
+
* FAT's two.
|
|
37
|
+
*/
|
|
38
|
+
export function stampGrainMs(stampMs: number): number {
|
|
39
|
+
if (stampMs % 2000 === 0) return 2000
|
|
40
|
+
return stampMs % 1000 === 0 ? 1000 : 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Measure the lag from a file written and removed in `dir` (the run's cache
|
|
45
|
+
* dir: on the workspace's file system by default, and ignored by git and
|
|
46
|
+
* `vx watch`), and apply it. Once a run: one write, one stat, one unlink. A
|
|
47
|
+
* directory that cannot take the file measures no lag.
|
|
48
|
+
*/
|
|
49
|
+
export function measureFsClockLag(dir: string): void {
|
|
50
|
+
const stamp = path.join(dir, `.vx-clock-${process.pid}`)
|
|
51
|
+
let ms = 0
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(dir, { recursive: true })
|
|
54
|
+
writeFileSync(stamp, '')
|
|
55
|
+
const at = statSync(stamp).mtimeMs
|
|
56
|
+
ms = Date.now() - at - stampGrainMs(at)
|
|
57
|
+
unlinkSync(stamp)
|
|
58
|
+
} catch {
|
|
59
|
+
// unmeasured: no lag, as before
|
|
60
|
+
}
|
|
61
|
+
setFsClockLag(ms)
|
|
62
|
+
}
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -1262,7 +1262,7 @@ function stripPrefixFromSet(paths: Set<string>, prefix: string): Set<string> {
|
|
|
1262
1262
|
* directly — clean-tree input hashing costs zero reads/stats/SQLite.
|
|
1263
1263
|
*
|
|
1264
1264
|
* `workspaceWide`: set when any loaded task declares
|
|
1265
|
-
* `
|
|
1265
|
+
* `inputs.workspaceFiles`. Disables pathspec scoping (those
|
|
1266
1266
|
* globs must see every file from the root) and additionally stores a
|
|
1267
1267
|
* workspace-wide partition keyed by `workspaceRoot` (files + trusted
|
|
1268
1268
|
* OIDs), which `resolveWorkspaceFiles` consumes. When false, the
|
|
@@ -1839,7 +1839,7 @@ export async function applyGitEnumeration(
|
|
|
1839
1839
|
// workspace's git holds as ONE entry (a gitlink, `dir/` when untracked)
|
|
1840
1840
|
// — is listed through its own git above (`expandNestedRepos`), but under
|
|
1841
1841
|
// a pathspec naming a project inside it the workspace's git lists
|
|
1842
|
-
// nothing at all; so is a directory ignored outright. Storing the empty slice made the key never move: `
|
|
1842
|
+
// nothing at all; so is a directory ignored outright. Storing the empty slice made the key never move: `inputs
|
|
1843
1843
|
// matched no files`, then a stale hit under a green run once the source
|
|
1844
1844
|
// changed (2026-09-16). No partition instead: `resolveFiles` spawns
|
|
1845
1845
|
// `git ls-files` in the project's own directory, which a nested
|
package/src/cache/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export {
|
|
|
12
12
|
type CacheEntry,
|
|
13
13
|
type CacheKeyInput,
|
|
14
14
|
type CacheLayer,
|
|
15
|
+
type RemoteTraffic,
|
|
15
16
|
type CachePolicy,
|
|
16
17
|
cachesNothing,
|
|
17
18
|
EXECUTED_RUNS_SQL,
|
|
@@ -34,6 +35,8 @@ export {
|
|
|
34
35
|
vxCacheEntries,
|
|
35
36
|
} from './cache.js'
|
|
36
37
|
export { ABSENT_INPUT, absentOr, CACHE_VERSION } from './key-fold.js'
|
|
38
|
+
export { measureFsClockLag, setFsClockLag } from './fs-clock.js'
|
|
39
|
+
export { filesBehind } from './linked-inputs.js'
|
|
37
40
|
export {
|
|
38
41
|
addedInput,
|
|
39
42
|
type InputListing,
|
|
@@ -41,10 +44,13 @@ export {
|
|
|
41
44
|
committedFiles,
|
|
42
45
|
cleanWorkspaceOutputs,
|
|
43
46
|
declaresInput,
|
|
47
|
+
namedOutright,
|
|
44
48
|
workspaceFilesReachInto,
|
|
45
49
|
resolveInputs,
|
|
46
50
|
resolveOutputs,
|
|
47
51
|
resolveWorkspaceOutputs,
|
|
52
|
+
sweepRestoreTemps,
|
|
53
|
+
sweepStaging,
|
|
48
54
|
asTrees,
|
|
49
55
|
type ProjectFilesCache,
|
|
50
56
|
type WorkspaceFilesCache,
|