@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/inputs.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// - envValues: [name, value] pairs from parent process.env (value
|
|
5
5
|
// undefined for an unset name)
|
|
6
6
|
//
|
|
7
|
-
// `
|
|
7
|
+
// `inputs.env` is the cache-tracking axis for env vars; it's
|
|
8
8
|
// independent of `exec.env`, which controls what reaches the child.
|
|
9
9
|
//
|
|
10
10
|
// File enumeration defers to git — same as Turbo and Nx. git-inputs.ts
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import path from 'node:path'
|
|
20
20
|
import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } from 'node:fs'
|
|
21
21
|
import { rm, rmdir } from 'node:fs/promises'
|
|
22
|
-
import type {
|
|
22
|
+
import type { InputsConfig, TaskConfig } from '../config.js'
|
|
23
23
|
import {
|
|
24
24
|
anyTaskGlob,
|
|
25
25
|
asTrees,
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
isInstalledPath,
|
|
28
28
|
isLiteralPattern,
|
|
29
29
|
normalizeGlob,
|
|
30
|
+
namesNodeModules,
|
|
31
|
+
OUTPUT_NEVER,
|
|
30
32
|
outputMatcher,
|
|
31
33
|
realpathOf,
|
|
32
34
|
relPosix,
|
|
@@ -89,21 +91,8 @@ const ALWAYS_IGNORE = [
|
|
|
89
91
|
// must not select every `**` reader under --affected.
|
|
90
92
|
'**/.vx/.gitattributes',
|
|
91
93
|
'**/.vx/.gitignore',
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
// broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
|
|
95
|
-
// file that a CONCURRENT compile is mid-write, racing to EACCES/ENOENT. Always
|
|
96
|
-
// exclude it (vx is Bun-native; compiling standalone binaries is a common task).
|
|
97
|
-
'**/*.bun-build',
|
|
98
|
-
// …and, cross-compiling for a target its cache lacks, the directory it
|
|
99
|
-
// extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
|
|
100
|
-
// 60 MB mid-write under the same race (2026-09-29).
|
|
101
|
-
'**/.????????????????-????????.tmp/**',
|
|
102
|
-
// vx's own restore temp (archive.ts), live only while an artifact lands
|
|
103
|
-
// beside its target. A key taken meanwhile listed it, the rename removed
|
|
104
|
-
// it, and every task reading that directory was withheld its save
|
|
105
|
-
// (withastro/astro: 25 of 33 builds re-ran on a restore, X-209).
|
|
106
|
-
'**/.vx-tmp-*',
|
|
94
|
+
// Where restores stage (archive.ts): vx's own, never an input.
|
|
95
|
+
'**/.vx/tmp/**',
|
|
107
96
|
]
|
|
108
97
|
|
|
109
98
|
const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
@@ -121,7 +110,7 @@ export interface ResolveInputsArgs {
|
|
|
121
110
|
projectDir: string
|
|
122
111
|
workspaceRoot: string
|
|
123
112
|
envSource: NodeJS.ProcessEnv
|
|
124
|
-
inputs:
|
|
113
|
+
inputs: InputsConfig | undefined
|
|
125
114
|
/** Project-relative output globs to exclude from inputs. */
|
|
126
115
|
ownOutputs: readonly string[]
|
|
127
116
|
/** Root-relative `outputs.workspaceFiles` globs to exclude from
|
|
@@ -137,7 +126,7 @@ export interface ResolveInputsArgs {
|
|
|
137
126
|
*/
|
|
138
127
|
gitFilesCache?: GitFilesCache
|
|
139
128
|
/**
|
|
140
|
-
* Run-scoped memo for `
|
|
129
|
+
* Run-scoped memo for `inputs.runtime` command execution, keyed
|
|
141
130
|
* by `projectDir + '\0' + command`. Shared across a run's tasks so a
|
|
142
131
|
* project's command runs once even across build/test/lint and across
|
|
143
132
|
* the hash + sandbox-baseline resolveInputs calls. The key is sound
|
|
@@ -148,7 +137,7 @@ export interface ResolveInputsArgs {
|
|
|
148
137
|
*/
|
|
149
138
|
runtimeCache?: Map<string, Promise<string>>
|
|
150
139
|
/**
|
|
151
|
-
* Run-scoped memo for `
|
|
140
|
+
* Run-scoped memo for `inputs.workspaceRuntime`, keyed by command
|
|
152
141
|
* only — global dedup so a root-level probe spawns once per run.
|
|
153
142
|
*/
|
|
154
143
|
workspaceRuntimeCache?: Map<string, Promise<string>>
|
|
@@ -160,7 +149,7 @@ export interface ResolveInputsArgs {
|
|
|
160
149
|
*/
|
|
161
150
|
runtimeScope?: string
|
|
162
151
|
/**
|
|
163
|
-
* Run-scoped memo for `
|
|
152
|
+
* Run-scoped memo for `inputs.workspaceFiles`, keyed by the
|
|
164
153
|
* declaration and valid for one enumeration snapshot. A Turbo-mapped
|
|
165
154
|
* workspace gives every task the same `globalDependencies`, and
|
|
166
155
|
* resolving one literal against medusa's 24k-file enumeration per task
|
|
@@ -177,7 +166,7 @@ export type WorkspaceFilesCache = Map<
|
|
|
177
166
|
>
|
|
178
167
|
|
|
179
168
|
/**
|
|
180
|
-
* Per-run memo of `
|
|
169
|
+
* Per-run memo of `inputs.files` resolution, keyed by the project and
|
|
181
170
|
* the DECLARATION — tasks of one project that declare the same inputs and
|
|
182
171
|
* the same outputs resolve to the same list, and this repo's own config is
|
|
183
172
|
* the shape that pays for it: twelve shard tasks, each declaring the same
|
|
@@ -263,7 +252,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
263
252
|
}
|
|
264
253
|
|
|
265
254
|
/**
|
|
266
|
-
* Resolve `
|
|
255
|
+
* Resolve `inputs.workspaceFiles` — workspace-root-relative
|
|
267
256
|
* globs matched against the workspace-wide git file set (tracked +
|
|
268
257
|
* untracked-not-ignored, same visibility as project inputs).
|
|
269
258
|
*
|
|
@@ -354,10 +343,21 @@ function resolveWorkspaceFiles(args: {
|
|
|
354
343
|
const ownsAny =
|
|
355
344
|
splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
|
|
356
345
|
splitNegations(args.ownOutputs).positive.length > 0
|
|
346
|
+
const reach = ignoredReach(reachOf(positive), gitFiles)
|
|
357
347
|
const files = base.then((all) => {
|
|
358
348
|
const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
|
|
359
349
|
refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
|
|
360
|
-
return kept
|
|
350
|
+
if (reach.length === 0) return kept
|
|
351
|
+
// Outside the memo: an ignored file a task writes mid-run leaves the
|
|
352
|
+
// snapshot as it was.
|
|
353
|
+
isPositive ??= anyTaskGlob(asTrees(positive))
|
|
354
|
+
const accept = isPositive
|
|
355
|
+
const extra = ignoredInputs(
|
|
356
|
+
args.workspaceRoot,
|
|
357
|
+
reach,
|
|
358
|
+
(rel) => accept(rel) && !excludedByDeclaration(rel) && !own(rel),
|
|
359
|
+
)
|
|
360
|
+
return extra.length === 0 ? kept : mergeSorted(kept, extra)
|
|
361
361
|
})
|
|
362
362
|
return { files, listing }
|
|
363
363
|
}
|
|
@@ -369,23 +369,12 @@ async function resolveWorkspaceFilesOver(
|
|
|
369
369
|
isPositive: (rel: string) => boolean,
|
|
370
370
|
excluded: (rel: string) => boolean,
|
|
371
371
|
): Promise<string[]> {
|
|
372
|
-
// Second call site of the literal-input guard. `resolveWorkspaceFiles`
|
|
373
|
-
// carries its own copy of the filter-over-git-set design, so the same
|
|
374
|
-
// silently-folds-nothing hazard exists here — and a fix applied only to the
|
|
375
|
-
// project half would pass that half's tests while leaving this one live.
|
|
376
|
-
const unmatchedLiterals = unanswered(
|
|
377
|
-
positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
378
|
-
gitFiles,
|
|
379
|
-
)
|
|
380
372
|
const candidates: string[] = []
|
|
381
373
|
for (const rel of gitFiles) {
|
|
382
374
|
if (!isPositive(rel)) continue
|
|
383
375
|
if (excluded(rel)) continue
|
|
384
376
|
candidates.push(path.resolve(args.workspaceRoot, rel))
|
|
385
377
|
}
|
|
386
|
-
if (unmatchedLiterals.size > 0) {
|
|
387
|
-
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
|
|
388
|
-
}
|
|
389
378
|
// Same OID-trust shortcut as project files: a clean-per-status
|
|
390
379
|
// tracked file necessarily exists on disk.
|
|
391
380
|
const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
@@ -536,10 +525,10 @@ async function runRuntimeCommand(
|
|
|
536
525
|
// the shell, not the command (item 244).
|
|
537
526
|
if (isExecutableMissing(err)) {
|
|
538
527
|
throw new UserError(
|
|
539
|
-
`
|
|
528
|
+
`inputs runtime command could not run: vx runs it with sh -c and 'sh' is not on PATH (command: ${command}, cwd: ${cwd}). Install a POSIX sh and re-run.`,
|
|
540
529
|
)
|
|
541
530
|
}
|
|
542
|
-
throw new UserError(`
|
|
531
|
+
throw new UserError(`inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
|
|
543
532
|
}
|
|
544
533
|
liveProbes.add(proc)
|
|
545
534
|
let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
|
|
@@ -564,7 +553,7 @@ async function runRuntimeCommand(
|
|
|
564
553
|
const lossy = new TextDecoder()
|
|
565
554
|
const shown = `${lossy.decode(stdout)}${lossy.decode(stderr)}`.trim()
|
|
566
555
|
throw new UserError(
|
|
567
|
-
`
|
|
556
|
+
`inputs runtime command exited ${exitCode}: ${command} (cwd: ${cwd})` +
|
|
568
557
|
(shown ? `\n${shown}` : ''),
|
|
569
558
|
)
|
|
570
559
|
}
|
|
@@ -577,7 +566,7 @@ async function runRuntimeCommand(
|
|
|
577
566
|
// A lossy decode keys every invalid byte as U+FFFD: Latin-1 é and è
|
|
578
567
|
// folded the same output and replayed each other's build.
|
|
579
568
|
throw new UserError(
|
|
580
|
-
`
|
|
569
|
+
`inputs runtime command printed bytes that are not UTF-8: ${command} (cwd: ${cwd}). ` +
|
|
581
570
|
`Pipe it through a hash or od.`,
|
|
582
571
|
)
|
|
583
572
|
}
|
|
@@ -677,17 +666,6 @@ async function resolveRuntimeValues(
|
|
|
677
666
|
)
|
|
678
667
|
}
|
|
679
668
|
|
|
680
|
-
/**
|
|
681
|
-
* What no output glob reaches. An output is otherwise taken as written —
|
|
682
|
-
* `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
|
|
683
|
-
* apply here — but no task produces these, and a root project declaring
|
|
684
|
-
* `**` would otherwise wipe its repository and the cache it is restoring
|
|
685
|
-
* from (2026-09-16). `.vx` whole and each `vx.lock.json` are vx's own: as
|
|
686
|
-
* outputs, the clean deleted an uncommitted workspace config and a hit
|
|
687
|
-
* restored a lock or ignore file the key does not see over the current one.
|
|
688
|
-
*/
|
|
689
|
-
const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
|
|
690
|
-
|
|
691
669
|
/**
|
|
692
670
|
* What a set of output globs may not reach: `OUTPUT_NEVER`, and every
|
|
693
671
|
* `node_modules` unless a glob names one. `**\/*.js` meant the build's
|
|
@@ -697,8 +675,9 @@ const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
|
|
|
697
675
|
* even `.git`.
|
|
698
676
|
*/
|
|
699
677
|
function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
|
|
700
|
-
|
|
701
|
-
|
|
678
|
+
return (namesNodeModules(outputs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(
|
|
679
|
+
globFor,
|
|
680
|
+
)
|
|
702
681
|
}
|
|
703
682
|
|
|
704
683
|
/**
|
|
@@ -877,6 +856,7 @@ export async function cleanOutputs(args: {
|
|
|
877
856
|
resolved?: readonly string[] | undefined
|
|
878
857
|
}): Promise<string[]> {
|
|
879
858
|
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
859
|
+
sweepRestoreTemps(args.projectDir, args.outputs)
|
|
880
860
|
const matched = args.resolved ?? (await resolveOutputs(args))
|
|
881
861
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
882
862
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
@@ -892,6 +872,70 @@ export async function cleanOutputs(args: {
|
|
|
892
872
|
return matched.map((f) => relPosix(args.projectDir, f))
|
|
893
873
|
}
|
|
894
874
|
|
|
875
|
+
/**
|
|
876
|
+
* Remove the restore temps (`.vx-tmp-<pid>-<n>`, archive.ts) a killed run
|
|
877
|
+
* left beside an exact-file output. A restore stages in `.vx/tmp/` and
|
|
878
|
+
* writes one beside its target only where the target is on another
|
|
879
|
+
* filesystem (EXDEV); a glob over the directory takes it with the outputs,
|
|
880
|
+
* but `out/a.txt` reaches no sibling, and a partial temp stayed in `out/`
|
|
881
|
+
* through every later run (Growth's SIGKILL probe, 2026-10-16). A temp
|
|
882
|
+
* whose writer is alive, this process included, is left: it may be
|
|
883
|
+
* mid-restore.
|
|
884
|
+
*/
|
|
885
|
+
export function sweepRestoreTemps(base: string, outputs: readonly string[]): void {
|
|
886
|
+
const dirs = new Set<string>()
|
|
887
|
+
for (const g of outputs) {
|
|
888
|
+
if (g.startsWith('!') || !isLiteralPattern(g)) continue
|
|
889
|
+
dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
|
|
890
|
+
}
|
|
891
|
+
for (const dir of dirs) {
|
|
892
|
+
let names: string[]
|
|
893
|
+
try {
|
|
894
|
+
names = readdirSync(dir)
|
|
895
|
+
} catch {
|
|
896
|
+
continue
|
|
897
|
+
}
|
|
898
|
+
for (const name of names) {
|
|
899
|
+
const m = /^\.vx-tmp-([0-9a-z]+)-[0-9a-z]+$/.exec(name)
|
|
900
|
+
if (m === null) continue
|
|
901
|
+
const pid = Number.parseInt(m[1]!, 36)
|
|
902
|
+
if (pid === process.pid || writerAlive(pid)) continue
|
|
903
|
+
rmSync(path.join(dir, name), { force: true })
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Empty `.vx/tmp/` (archive.ts `STAGING`) of the staging directories
|
|
910
|
+
* (`<pid>-<n>`) a killed run left, once per run. One whose writer is alive,
|
|
911
|
+
* another run's or this one's, is left: it may be mid-restore.
|
|
912
|
+
*/
|
|
913
|
+
export function sweepStaging(workspaceRoot: string): void {
|
|
914
|
+
const top = path.join(workspaceRoot, '.vx', 'tmp')
|
|
915
|
+
let names: string[]
|
|
916
|
+
try {
|
|
917
|
+
names = readdirSync(top)
|
|
918
|
+
} catch {
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
for (const name of names) {
|
|
922
|
+
const m = /^([0-9a-z]+)-[0-9a-z]+$/.exec(name)
|
|
923
|
+
if (m === null) continue
|
|
924
|
+
const pid = Number.parseInt(m[1]!, 36)
|
|
925
|
+
if (pid === process.pid || writerAlive(pid)) continue
|
|
926
|
+
rmSync(path.join(top, name), { recursive: true, force: true })
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function writerAlive(pid: number): boolean {
|
|
931
|
+
try {
|
|
932
|
+
process.kill(pid, 0)
|
|
933
|
+
return true
|
|
934
|
+
} catch (err) {
|
|
935
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
895
939
|
/**
|
|
896
940
|
* Remove exactly these project-relative paths — the recorded rows of an
|
|
897
941
|
* ADDITIVE task's own artifact (item 588), never a glob: the glob would
|
|
@@ -1221,6 +1265,7 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1221
1265
|
resolved?: readonly string[] | undefined
|
|
1222
1266
|
}): Promise<string[]> {
|
|
1223
1267
|
const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
|
|
1268
|
+
sweepRestoreTemps(args.workspaceRoot, args.outputs)
|
|
1224
1269
|
const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
|
|
1225
1270
|
await pruneEmptiedDirs(
|
|
1226
1271
|
args.workspaceRoot,
|
|
@@ -1282,52 +1327,86 @@ function lowerBound(files: readonly string[], key: string): number {
|
|
|
1282
1327
|
}
|
|
1283
1328
|
|
|
1284
1329
|
/**
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1287
|
-
*
|
|
1288
|
-
*
|
|
1289
|
-
*
|
|
1290
|
-
*
|
|
1291
|
-
* quiet one: the run replays cached stdout, so it even LOOKS like it executed.
|
|
1292
|
-
*
|
|
1293
|
-
* Turbo honours the declaration instead (an explicit `inputs` entry overrides
|
|
1294
|
-
* gitignore). vx cannot afford to, and the reason is not cost: honouring it
|
|
1295
|
-
* would make the cache key depend on a change that `git diff` and
|
|
1296
|
-
* `git ls-files --others` CANNOT SEE, so editing that input would re-key the
|
|
1297
|
-
* task while `--affected` selected nothing. `docs/cli.md` states the invariant
|
|
1298
|
-
* those two surfaces owe each other as a principle — "input hashing sees it, so
|
|
1299
|
-
* `--affected` must too" — and it was closed for lockfiles only recently.
|
|
1300
|
-
* Honouring a gitignored input would reopen it from the other side.
|
|
1301
|
-
*
|
|
1302
|
-
* An entry that does NOT exist on disk stays silent: that is an ordinary stale
|
|
1303
|
-
* declaration, and refusing it would break every config that lists an
|
|
1304
|
-
* optional file.
|
|
1305
|
-
*
|
|
1306
|
-
* "Exists" is lstat, not `Bun.file(p).exists()`: that answers false for a
|
|
1307
|
-
* DIRECTORY, so a literal naming a gitignored `gen/` was never judged and
|
|
1308
|
-
* folded nothing in silence — the stale hit this refusal exists to stop, one
|
|
1309
|
-
* directory above where it looked (item 565, fixed in 576). A literal naming
|
|
1310
|
-
* an EMPTY directory is refused by the same rule, and that is right: git
|
|
1311
|
-
* lists no file under it, so it folds nothing whether tracked or not.
|
|
1330
|
+
* The entries a declaration names outright that git answers nothing for: a
|
|
1331
|
+
* literal, or a glob's fixed prefix (`dist` of `dist/**`), with no listed
|
|
1332
|
+
* path at or under it. Such a path is gitignored or absent, and naming it
|
|
1333
|
+
* is asking for it (owner, 2026-10-16), so {@link ignoredInputs} reads it
|
|
1334
|
+
* from disk. A broad glob (`**`, or `src/**` over a listed `src`) reaches
|
|
1335
|
+
* no ignored file: `node_modules` is never walked.
|
|
1312
1336
|
*/
|
|
1313
|
-
|
|
1314
|
-
literals:
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1337
|
+
function ignoredReach(
|
|
1338
|
+
reach: { prefixes: readonly string[]; literals: readonly string[] },
|
|
1339
|
+
listed: readonly string[],
|
|
1340
|
+
): readonly string[] {
|
|
1341
|
+
const named = outright(reach)
|
|
1342
|
+
if (named.length === 0) return []
|
|
1343
|
+
// Per listing and entries: thousands of tasks share a few declarations.
|
|
1344
|
+
let memo = reachMemo.get(listed)
|
|
1345
|
+
if (memo === undefined) reachMemo.set(listed, (memo = new Map()))
|
|
1346
|
+
const key = named.join('\0')
|
|
1347
|
+
let left = memo.get(key)
|
|
1348
|
+
if (left === undefined) memo.set(key, (left = [...unanswered(named, listed)]))
|
|
1349
|
+
return left
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const reachMemo = new WeakMap<readonly string[], Map<string, readonly string[]>>()
|
|
1353
|
+
|
|
1354
|
+
/**
|
|
1355
|
+
* A declaration's literals and globs' fixed prefixes, each once: none for a
|
|
1356
|
+
* glob that starts with a wildcard, and none that leaves the directory the
|
|
1357
|
+
* entries are relative to (a project's boundary is hard).
|
|
1358
|
+
*/
|
|
1359
|
+
function outright(reach: { prefixes: readonly string[]; literals: readonly string[] }): string[] {
|
|
1360
|
+
return [
|
|
1361
|
+
...new Set(
|
|
1362
|
+
[...reach.literals, ...reach.prefixes].filter(
|
|
1363
|
+
(p) => p !== '' && p !== '.' && p !== '..' && !p.startsWith('../') && !path.isAbsolute(p),
|
|
1364
|
+
),
|
|
1365
|
+
),
|
|
1366
|
+
]
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* The files on disk under the {@link ignoredReach} entries that `accept`
|
|
1371
|
+
* keeps, sorted and absolute. A file is hashed from disk like any untracked
|
|
1372
|
+
* one; nothing here is stored or restored. `node_modules` is no special
|
|
1373
|
+
* case (owner, 2026-10-16): an ignored path named outright is an input,
|
|
1374
|
+
* a generated client (`node_modules/.prisma/client/**`) too; skipping
|
|
1375
|
+
* installs here folded such an entry to nothing, silently. lstat, not
|
|
1376
|
+
* `exists()`: that answers false for a directory. Never memoized: a
|
|
1377
|
+
* dependency writes these mid-run.
|
|
1378
|
+
*/
|
|
1379
|
+
function ignoredInputs(
|
|
1380
|
+
root: string,
|
|
1381
|
+
reach: readonly string[],
|
|
1382
|
+
accept: (rel: string) => boolean,
|
|
1383
|
+
): string[] {
|
|
1384
|
+
const found = new Set<string>()
|
|
1385
|
+
for (const entry of reach) {
|
|
1386
|
+
const abs = path.resolve(root, entry)
|
|
1387
|
+
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
1388
|
+
if (st === undefined) continue
|
|
1389
|
+
if (!st.isDirectory()) {
|
|
1390
|
+
if ((st.isFile() || st.isSymbolicLink()) && accept(entry)) found.add(abs)
|
|
1391
|
+
continue
|
|
1392
|
+
}
|
|
1393
|
+
for (const rel of WALK.scanSync({
|
|
1394
|
+
cwd: abs,
|
|
1395
|
+
onlyFiles: false,
|
|
1396
|
+
followSymlinks: false,
|
|
1397
|
+
dot: true,
|
|
1398
|
+
})) {
|
|
1399
|
+
const full = `${entry}/${rel.split(path.sep).join('/')}`
|
|
1400
|
+
if (!accept(full)) continue
|
|
1401
|
+
const file = path.join(abs, rel)
|
|
1402
|
+
if (isInputOnDisk(file)) found.add(file)
|
|
1403
|
+
}
|
|
1328
1404
|
}
|
|
1405
|
+
return [...found].sort()
|
|
1329
1406
|
}
|
|
1330
1407
|
|
|
1408
|
+
const WALK = new Bun.Glob('**')
|
|
1409
|
+
|
|
1331
1410
|
interface ResolveFilesArgs {
|
|
1332
1411
|
projectDir: string
|
|
1333
1412
|
workspaceRoot: string
|
|
@@ -1373,7 +1452,7 @@ function refuseUndecodable(
|
|
|
1373
1452
|
if (bad.length === 0) return
|
|
1374
1453
|
const names = bad.map((abs) => JSON.stringify(relPosix(root, abs)))
|
|
1375
1454
|
throw new UserError(
|
|
1376
|
-
`
|
|
1455
|
+
`inputs.${field} matched ${names.join(', ')} in ${root}: the name is not valid UTF-8 ` +
|
|
1377
1456
|
`(shown with \ufffd), and vx cannot read a file by it. Rename it, or exclude it with a ` +
|
|
1378
1457
|
`negated glob.`,
|
|
1379
1458
|
)
|
|
@@ -1396,7 +1475,7 @@ function refuseOneAlternativeBrace(
|
|
|
1396
1475
|
if (m === null) continue
|
|
1397
1476
|
const inner = m[0].slice(1, -1)
|
|
1398
1477
|
throw new UserError(
|
|
1399
|
-
`
|
|
1478
|
+
`inputs.${field}: "${entry}" holds a brace with one alternative, which matches ` +
|
|
1400
1479
|
`"${inner}" and never a name holding "${m[0]}". Write "${entry.replace(m[0], inner)}", ` +
|
|
1401
1480
|
`or "${entry.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
|
|
1402
1481
|
)
|
|
@@ -1478,41 +1557,59 @@ function filesPlan(
|
|
|
1478
1557
|
}
|
|
1479
1558
|
|
|
1480
1559
|
/**
|
|
1481
|
-
* Whether a changed path is one of a
|
|
1560
|
+
* Whether a changed path is one of a task's declared file inputs:
|
|
1482
1561
|
* `projectRel` (the path relative to the project that owns it) against
|
|
1483
1562
|
* `files`, `workspaceRel` (relative to the workspace root) against
|
|
1484
1563
|
* `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
|
|
1485
1564
|
* the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (owner, 2026-10-04).
|
|
1486
1565
|
*/
|
|
1487
1566
|
export function declaresInput(
|
|
1488
|
-
|
|
1567
|
+
task: TaskConfig,
|
|
1489
1568
|
projectRel: string | null,
|
|
1490
1569
|
workspaceRel: string | null,
|
|
1491
1570
|
): boolean {
|
|
1571
|
+
const inputs = task.inputs
|
|
1572
|
+
if (inputs === undefined) return false
|
|
1492
1573
|
if (projectRel !== null) {
|
|
1493
1574
|
// A changed path under the task's own outputs is a committed file
|
|
1494
1575
|
// (`git diff` lists only what git tracks) or a stray `git status`
|
|
1495
1576
|
// lists: either way it may be an input, so it counts.
|
|
1496
|
-
const plan = filesPlan(
|
|
1577
|
+
const plan = filesPlan(inputs.files, task.cache?.outputs.files ?? [])
|
|
1497
1578
|
if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
|
|
1498
1579
|
}
|
|
1499
|
-
const ws =
|
|
1580
|
+
const ws = inputs.workspaceFiles
|
|
1500
1581
|
if (workspaceRel === null || ws === undefined || ws.length === 0) return false
|
|
1501
1582
|
return workspaceMatcher(ws)(workspaceRel)
|
|
1502
1583
|
}
|
|
1503
1584
|
|
|
1504
1585
|
/**
|
|
1505
|
-
* Whether a
|
|
1586
|
+
* Whether a task's `workspaceFiles` may name a file under `dir`
|
|
1506
1587
|
* (workspace-relative): a nested repository git reports as that one path,
|
|
1507
1588
|
* whose files the key folds and a match against the path alone misses.
|
|
1508
1589
|
* By the entries' static prefixes, so a glob that may descend counts.
|
|
1509
1590
|
*/
|
|
1510
|
-
export function workspaceFilesReachInto(
|
|
1511
|
-
const ws =
|
|
1591
|
+
export function workspaceFilesReachInto(task: TaskConfig, dir: string): boolean {
|
|
1592
|
+
const ws = task.inputs?.workspaceFiles
|
|
1512
1593
|
if (ws === undefined || ws.length === 0) return false
|
|
1513
1594
|
return reaches(reachOf(splitNegations(ws).positive).prefixes, dir)
|
|
1514
1595
|
}
|
|
1515
1596
|
|
|
1597
|
+
/**
|
|
1598
|
+
* The paths a task names outright (literals, and globs' fixed prefixes),
|
|
1599
|
+
* `files` relative to its project and `workspaceFiles` to the root: where a
|
|
1600
|
+
* gitignored input can be (`ignoredReach`).
|
|
1601
|
+
*/
|
|
1602
|
+
export function namedOutright(task: TaskConfig): { files: string[]; workspaceFiles: string[] } {
|
|
1603
|
+
const inputs = task.inputs
|
|
1604
|
+
const plan =
|
|
1605
|
+
inputs === undefined ? null : filesPlan(inputs.files, task.cache?.outputs.files ?? [])
|
|
1606
|
+
const ws = inputs?.workspaceFiles ?? []
|
|
1607
|
+
return {
|
|
1608
|
+
files: plan === null ? [] : outright(plan),
|
|
1609
|
+
workspaceFiles: ws.length === 0 ? [] : outright(reachOf(splitNegations(ws).positive)),
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1516
1613
|
function inPlan(plan: FilesPlan, rel: string): boolean {
|
|
1517
1614
|
let input = plan.verdicts.get(rel)
|
|
1518
1615
|
if (input === undefined) {
|
|
@@ -1577,7 +1674,14 @@ async function resolveFiles(
|
|
|
1577
1674
|
// inputs must be walked again.
|
|
1578
1675
|
if (memo !== undefined && memo.snapshot === gitFiles) {
|
|
1579
1676
|
return {
|
|
1580
|
-
files:
|
|
1677
|
+
files: withIgnored(
|
|
1678
|
+
args.projectDir,
|
|
1679
|
+
memo.result,
|
|
1680
|
+
ignoredReach(plan, gitFiles),
|
|
1681
|
+
plan,
|
|
1682
|
+
nested,
|
|
1683
|
+
ownWsOutput,
|
|
1684
|
+
),
|
|
1581
1685
|
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1582
1686
|
}
|
|
1583
1687
|
}
|
|
@@ -1635,10 +1739,7 @@ async function resolveFiles(
|
|
|
1635
1739
|
if (!input && !committed(abs)) continue
|
|
1636
1740
|
candidates.push(abs)
|
|
1637
1741
|
}
|
|
1638
|
-
const
|
|
1639
|
-
if (unmatchedLiterals.size > 0) {
|
|
1640
|
-
await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
|
|
1641
|
-
}
|
|
1742
|
+
const reach = ignoredReach(plan, gitFiles)
|
|
1642
1743
|
refuseUndecodable(candidates, undecodable, args.projectDir, 'files')
|
|
1643
1744
|
// Second pass: existence check — but ONLY for paths without a
|
|
1644
1745
|
// trusted index OID. A clean-per-status tracked file necessarily
|
|
@@ -1656,15 +1757,31 @@ async function resolveFiles(
|
|
|
1656
1757
|
break
|
|
1657
1758
|
}
|
|
1658
1759
|
}
|
|
1659
|
-
// Stored only on the way out: a declaration whose literal named an
|
|
1660
|
-
// invisible file threw above, and every task sharing it must throw too.
|
|
1661
1760
|
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
|
1662
1761
|
return {
|
|
1663
|
-
files:
|
|
1762
|
+
files: withIgnored(args.projectDir, resolved, reach, plan, nested, ownWsOutput),
|
|
1664
1763
|
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1665
1764
|
}
|
|
1666
1765
|
}
|
|
1667
1766
|
|
|
1767
|
+
/** The resolved files, plus the gitignored ones the declaration names outright. */
|
|
1768
|
+
function withIgnored(
|
|
1769
|
+
projectDir: string,
|
|
1770
|
+
resolved: readonly string[],
|
|
1771
|
+
reach: readonly string[],
|
|
1772
|
+
plan: FilesPlan,
|
|
1773
|
+
nested: (rel: string) => boolean,
|
|
1774
|
+
ownWsOutput: (rel: string) => boolean,
|
|
1775
|
+
): string[] {
|
|
1776
|
+
if (reach.length === 0) return [...resolved]
|
|
1777
|
+
const extra = ignoredInputs(
|
|
1778
|
+
projectDir,
|
|
1779
|
+
reach,
|
|
1780
|
+
(rel) => inPlan(plan, rel) && !nested(rel) && !ownWsOutput(rel),
|
|
1781
|
+
)
|
|
1782
|
+
return extra.length === 0 ? [...resolved] : mergeSorted(resolved, extra)
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1668
1785
|
function listingFor(
|
|
1669
1786
|
args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
|
|
1670
1787
|
listed: readonly string[],
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
28
28
|
// in any cache key. SCHEMA v21 drops the now-unused outputs_hash
|
|
29
29
|
// column. Early cutoff removed (an upstream that re-emits identical
|
|
30
30
|
// output still re-runs dependents) — rare, not worth the cascade.
|
|
31
|
-
// v23: fold
|
|
31
|
+
// v23: fold inputs.runtime / workspaceRuntime command output into
|
|
32
32
|
// the key (two namespaced sections after env-values). Command strings
|
|
33
33
|
// live in the resolved config (frozen by `vx lock`); the OUTPUT is
|
|
34
34
|
// resolved live every run, so it stays correct under the lock. No
|
|
@@ -144,6 +144,12 @@ export async function foldKey(
|
|
|
144
144
|
* prints nothing.
|
|
145
145
|
*/
|
|
146
146
|
digestValue: (value: string) => string = xxh3hex,
|
|
147
|
+
/**
|
|
148
|
+
* The content behind an input that is a symlink (linked-inputs.ts), folded
|
|
149
|
+
* after the link's own target string. Absent, a link folds its target
|
|
150
|
+
* string alone: a caller that keys no worktree.
|
|
151
|
+
*/
|
|
152
|
+
behind?: (link: string) => Promise<string>,
|
|
147
153
|
): Promise<string> {
|
|
148
154
|
// Seed-chained xxHash3: each step folds one field into the
|
|
149
155
|
// running digest via `xxh3(part, prevDigest)`. Equivalent to the
|
|
@@ -168,7 +174,7 @@ export async function foldKey(
|
|
|
168
174
|
// NOTE on `cap` (entry_inputs capture): the `hash` field stores a
|
|
169
175
|
// DIGEST of each component, never the raw value. For value-bearing kinds
|
|
170
176
|
// (env, runtime, ws-runtime, forward) the payload is a secret or
|
|
171
|
-
// sensitive string (API keys via
|
|
177
|
+
// sensitive string (API keys via inputs.env, runtime-command
|
|
172
178
|
// output, args after `--`), so we push `digestValue(v)` — the diff
|
|
173
179
|
// consumer only needs to know whether a component CHANGED, which a
|
|
174
180
|
// digest preserves losslessly. cache.db must never hold plaintext
|
|
@@ -268,13 +274,27 @@ export async function foldKey(
|
|
|
268
274
|
fileHashes ??= await Promise.all(
|
|
269
275
|
sortedInputs.map((f) => provided?.get(f) ?? hashFile(f).catch(absentOr)),
|
|
270
276
|
)
|
|
277
|
+
// Links are rare, so a list without one awaits nothing more.
|
|
278
|
+
let behindLinks: Map<number, string> | undefined
|
|
279
|
+
if (behind !== undefined) {
|
|
280
|
+
const links: number[] = []
|
|
281
|
+
for (let i = 0; i < fileHashes.length; i++)
|
|
282
|
+
if (fileHashes[i]!.startsWith('120000:')) links.push(i)
|
|
283
|
+
if (links.length > 0) {
|
|
284
|
+
const digests = await Promise.all(links.map((i) => behind(sortedInputs[i]!)))
|
|
285
|
+
behindLinks = new Map(links.map((i, n) => [i, digests[n]!]))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
271
288
|
for (let i = 0; i < sortedInputs.length; i++) {
|
|
272
289
|
const file = sortedInputs[i]!
|
|
273
290
|
const rel = relOf(file)
|
|
274
291
|
const oid = fileHashes[i]!
|
|
275
|
-
|
|
292
|
+
const linked = behindLinks?.get(i)
|
|
293
|
+
// A link folds what it points to in its own step, so a key without one is unchanged.
|
|
294
|
+
h = xxh3(linked === undefined ? `${rel}\0${oid}` : `${rel}\0${oid}\0${linked}`, h)
|
|
276
295
|
// The OID is already awaited — file capture is zero extra I/O.
|
|
277
296
|
if (cap) cap.push({ kind: 'file', name: rel, hash: oid })
|
|
297
|
+
if (linked !== undefined && cap) cap.push({ kind: 'linked', name: rel, hash: xxh3hex(linked) })
|
|
278
298
|
}
|
|
279
299
|
|
|
280
300
|
return h.toString(16).padStart(16, '0')
|