@vzn/vx 0.0.595 → 0.0.597
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/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +170 -99
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +99 -44
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +9 -6
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +12 -6
- package/src/cli/foreign-flags.ts +57 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +10 -4
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/run.ts +73 -30
- package/src/cli/select.ts +6 -3
- package/src/cli/show.ts +8 -2
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +18 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +42 -21
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +89 -18
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +12 -1
- package/src/exec/sandbox-runtime.ts +283 -88
- package/src/exec/sandbox-violations.ts +115 -15
- package/src/graph/scheduler.ts +30 -14
- package/src/graph/task-graph.ts +265 -44
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +85 -39
- package/src/orchestrator/failure-mode.ts +43 -28
- package/src/orchestrator/framed-output.ts +8 -8
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +21 -9
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +9 -13
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +7 -23
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +4 -3
- package/src/orchestrator/run-lock.ts +25 -24
- package/src/orchestrator/run.ts +22 -5
- package/src/orchestrator/sandbox-request.ts +96 -18
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +7 -13
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +24 -4
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-eval.ts +27 -6
- package/src/workspace/config-imports.ts +8 -53
- package/src/workspace/config-schema.ts +56 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +19 -4
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +53 -24
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// once. Restore goes to the layer that produced the hit — remembered per
|
|
5
5
|
// hash — because an entry's artifact lives wherever it was found.
|
|
6
6
|
//
|
|
7
|
-
// A layer that throws in a lookup
|
|
7
|
+
// A layer that throws in a lookup (a batch row load or a has-many included)
|
|
8
|
+
// is a miss there and the walk goes on; a throwing drain is passed; one
|
|
8
9
|
// that throws in a save is skipped and the rest still save. Unisolated, a
|
|
9
10
|
// plugin layer's throw ended the walk before the local floor under it: its
|
|
10
11
|
// `get` failed the task, its `save` kept the entry from every later layer
|
|
@@ -122,14 +123,22 @@ export class ChainedCache implements CacheLayer {
|
|
|
122
123
|
const found = await this.ask(
|
|
123
124
|
layer,
|
|
124
125
|
'remoteHasMany',
|
|
125
|
-
|
|
126
|
+
// The shape check and the mark sit inside the ask: a layer that
|
|
127
|
+
// answers a non-Set or throws marking gave no answer.
|
|
128
|
+
async () => {
|
|
129
|
+
const got: unknown = (await layer.remoteHasMany?.(hashes)) ?? null
|
|
130
|
+
if (got === null) return null
|
|
131
|
+
if (!(got instanceof Set)) throw new TypeError('remoteHasMany answered a non-Set')
|
|
132
|
+
const set = got as Set<string>
|
|
133
|
+
layer.markRemoteAbsent?.(hashes.filter((h) => !set.has(h)))
|
|
134
|
+
return set
|
|
135
|
+
},
|
|
126
136
|
null,
|
|
127
137
|
)
|
|
128
138
|
if (found === null) {
|
|
129
139
|
complete = false
|
|
130
140
|
continue
|
|
131
141
|
}
|
|
132
|
-
layer.markRemoteAbsent?.(hashes.filter((h) => !found.has(h)))
|
|
133
142
|
out ??= new Set()
|
|
134
143
|
for (const h of found) out.add(h)
|
|
135
144
|
}
|
|
@@ -142,15 +151,24 @@ export class ChainedCache implements CacheLayer {
|
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
async drainUploads(): Promise<void> {
|
|
145
|
-
await Promise.all(
|
|
154
|
+
await Promise.all(
|
|
155
|
+
this.layers.map((l) =>
|
|
156
|
+
this.ask(l, 'drainUploads', async () => l.drainUploads?.(), undefined),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
146
159
|
}
|
|
147
160
|
|
|
148
161
|
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
|
|
149
162
|
const out = new Map<string, OutputFileRow[]>()
|
|
150
|
-
for (const layer of this.layers) {
|
|
151
|
-
|
|
152
|
-
|
|
163
|
+
for (const [i, layer] of this.layers.entries()) {
|
|
164
|
+
let rows: Map<string, OutputFileRow[]>
|
|
165
|
+
try {
|
|
166
|
+
rows = layer.loadOutputFilesBatch(hashes)
|
|
167
|
+
} catch (err) {
|
|
168
|
+
this.onLayerError(i, 'loadOutputFilesBatch', err)
|
|
169
|
+
continue
|
|
153
170
|
}
|
|
171
|
+
for (const [h, r] of rows) if (!out.has(h)) out.set(h, r)
|
|
154
172
|
}
|
|
155
173
|
return out
|
|
156
174
|
}
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -87,6 +87,29 @@ export function repoFacts(dir: string): RepoFacts | null {
|
|
|
87
87
|
return facts
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The refusal a run would meet at its enumeration, asked up front by a
|
|
92
|
+
* verb that waits on the user first (the picker); undefined when git
|
|
93
|
+
* tracks `dir`. Free when it does: `repoFacts` is the answer the run
|
|
94
|
+
* reuses.
|
|
95
|
+
*/
|
|
96
|
+
export function gitRefusal(dir: string): UserError | undefined {
|
|
97
|
+
if (repoFacts(dir) !== null) return undefined
|
|
98
|
+
let proc
|
|
99
|
+
try {
|
|
100
|
+
proc = Bun.spawnSync({
|
|
101
|
+
cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
|
|
102
|
+
cwd: dir,
|
|
103
|
+
stdout: 'ignore',
|
|
104
|
+
stderr: 'pipe',
|
|
105
|
+
})
|
|
106
|
+
} catch {
|
|
107
|
+
return gitSpawnRefusal(dir)
|
|
108
|
+
}
|
|
109
|
+
if (proc.exitCode === 0) return undefined
|
|
110
|
+
return notAWorkTree(dir, new TextDecoder().decode(proc.stderr).trim())
|
|
111
|
+
}
|
|
112
|
+
|
|
90
113
|
/** Variables that move where git finds the repository or its index: with any set, git is asked. */
|
|
91
114
|
const GIT_LOCATION_ENV = [
|
|
92
115
|
'GIT_INDEX_FILE',
|
package/src/cache/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export {
|
|
|
11
11
|
type CacheKeyInput,
|
|
12
12
|
type CacheLayer,
|
|
13
13
|
type CachePolicy,
|
|
14
|
+
cachesNothing,
|
|
14
15
|
EXECUTED_RUNS_SQL,
|
|
15
16
|
FULL_CACHE_POLICY,
|
|
16
17
|
type InvocationRecord,
|
|
@@ -35,6 +36,7 @@ export {
|
|
|
35
36
|
cleanOutputs,
|
|
36
37
|
cleanWorkspaceOutputs,
|
|
37
38
|
declaresInput,
|
|
39
|
+
workspaceFilesReachInto,
|
|
38
40
|
resolveInputs,
|
|
39
41
|
resolveOutputs,
|
|
40
42
|
resolveWorkspaceOutputs,
|
|
@@ -51,6 +53,7 @@ export {
|
|
|
51
53
|
} from './inputs.js'
|
|
52
54
|
export {
|
|
53
55
|
GitFilesCache,
|
|
56
|
+
gitRefusal,
|
|
54
57
|
applyGitEnumeration,
|
|
55
58
|
gitPathspecs,
|
|
56
59
|
MAX_SCOPED_PATHSPECS,
|
package/src/cache/inputs.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// be a git work tree; non-git environments are not supported.
|
|
18
18
|
|
|
19
19
|
import path from 'node:path'
|
|
20
|
-
import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync } from 'node:fs'
|
|
20
|
+
import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } from 'node:fs'
|
|
21
21
|
import { rm, rmdir } from 'node:fs/promises'
|
|
22
22
|
import type { CacheConfig, CacheInputs } from '../config.js'
|
|
23
23
|
import {
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
slashBraceExpansions,
|
|
34
34
|
splitNegations,
|
|
35
35
|
staticPrefix,
|
|
36
|
+
stripTrailingSlash,
|
|
36
37
|
taskGlob,
|
|
37
38
|
UserError,
|
|
38
39
|
} from '../util/index.js'
|
|
@@ -109,10 +110,10 @@ export interface ResolveInputsArgs {
|
|
|
109
110
|
envSource: NodeJS.ProcessEnv
|
|
110
111
|
inputs: CacheInputs | undefined
|
|
111
112
|
/** Project-relative output globs to exclude from inputs. */
|
|
112
|
-
ownOutputs: string[]
|
|
113
|
+
ownOutputs: readonly string[]
|
|
113
114
|
/** Root-relative `outputs.workspaceFiles` globs to exclude from
|
|
114
115
|
* `inputs.workspaceFiles` (a task cannot invalidate itself). */
|
|
115
|
-
ownWorkspaceOutputs?: string[]
|
|
116
|
+
ownWorkspaceOutputs?: readonly string[]
|
|
116
117
|
/** Absolute dirs of nested projects (cross-boundary isolation). */
|
|
117
118
|
nestedProjectDirs: string[]
|
|
118
119
|
/**
|
|
@@ -643,18 +644,24 @@ function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
|
|
|
643
644
|
return (namesNodeModules ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(globFor)
|
|
644
645
|
}
|
|
645
646
|
|
|
646
|
-
/**
|
|
647
|
+
/**
|
|
648
|
+
* Resolve declared output globs (project-relative) to actual produced files.
|
|
649
|
+
* An output directory linked out of the project is refused by name (X-88):
|
|
650
|
+
* dropping its files silently saved an empty entry under a green run.
|
|
651
|
+
*/
|
|
647
652
|
export async function resolveOutputs(args: {
|
|
648
653
|
projectDir: string
|
|
649
|
-
outputs: string[]
|
|
654
|
+
outputs: readonly string[]
|
|
650
655
|
nestedProjectDirs: string[]
|
|
651
656
|
}): Promise<string[]> {
|
|
652
657
|
const { positive, negative } = splitNegations(args.outputs)
|
|
653
658
|
if (positive.length === 0) return []
|
|
654
659
|
const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
|
|
660
|
+
const trees = asTrees(positive)
|
|
661
|
+
refuseDirLinkedOut(args.projectDir, trees)
|
|
655
662
|
const scanned = [
|
|
656
663
|
...(await scanUnion(
|
|
657
|
-
|
|
664
|
+
trees,
|
|
658
665
|
excludeGlobs,
|
|
659
666
|
args.projectDir,
|
|
660
667
|
inNestedProject(args.projectDir, args.nestedProjectDirs),
|
|
@@ -670,6 +677,53 @@ export async function resolveOutputs(args: {
|
|
|
670
677
|
return containedIn(args.projectDir, scanned).sort()
|
|
671
678
|
}
|
|
672
679
|
|
|
680
|
+
/**
|
|
681
|
+
* The literal head of each output glob, walked: the scan follows a link
|
|
682
|
+
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link to a
|
|
683
|
+
* FILE is the archive's to judge (L-23); a dangling one the restore's.
|
|
684
|
+
*/
|
|
685
|
+
function refuseDirLinkedOut(projectDir: string, trees: readonly string[]): void {
|
|
686
|
+
let realRoot: string | undefined
|
|
687
|
+
for (const tree of trees) {
|
|
688
|
+
let at = projectDir
|
|
689
|
+
for (const part of staticPrefix(tree).split('/')) {
|
|
690
|
+
if (part === '' || part === '.') continue
|
|
691
|
+
at = path.join(at, part)
|
|
692
|
+
if (!isInside(projectDir, at)) break
|
|
693
|
+
let st
|
|
694
|
+
try {
|
|
695
|
+
st = lstatSync(at)
|
|
696
|
+
} catch {
|
|
697
|
+
break
|
|
698
|
+
}
|
|
699
|
+
if (!st.isSymbolicLink()) continue
|
|
700
|
+
let real: string
|
|
701
|
+
try {
|
|
702
|
+
real = realpathSync(at)
|
|
703
|
+
if (!statSync(real).isDirectory()) break
|
|
704
|
+
} catch {
|
|
705
|
+
break
|
|
706
|
+
}
|
|
707
|
+
realRoot ??= realOr(projectDir)
|
|
708
|
+
if (!isInside(realRoot, real)) {
|
|
709
|
+
throw new UserError(
|
|
710
|
+
`${at} is a symbolic link to ${real}, outside ${projectDir} — vx never cleans, saves ` +
|
|
711
|
+
'or restores declared outputs through a link that leaves the project. Remove the ' +
|
|
712
|
+
'link and re-run, or stop declaring outputs under it.',
|
|
713
|
+
)
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function realOr(p: string): string {
|
|
720
|
+
try {
|
|
721
|
+
return realpathSync(p)
|
|
722
|
+
} catch {
|
|
723
|
+
return p
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
673
727
|
/**
|
|
674
728
|
* Keep only the paths that are REALLY inside `root` — lexically, and after
|
|
675
729
|
* resolving symlinks.
|
|
@@ -708,16 +762,9 @@ function containedIn(root: string, paths: readonly string[]): string[] {
|
|
|
708
762
|
// Sync, as `hashFile`'s lstat: a realpath is microseconds, and the
|
|
709
763
|
// promise round trip per call was most of this function's cost on every
|
|
710
764
|
// miss (B, 2026-09-30).
|
|
711
|
-
const
|
|
712
|
-
try {
|
|
713
|
-
return realpathSync(p)
|
|
714
|
-
} catch {
|
|
715
|
-
return null
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
const realRoot = real(root) ?? root
|
|
765
|
+
const realRoot = realOrNull(root) ?? root
|
|
719
766
|
const uniqueDirs = [...new Set(lexDirs)]
|
|
720
|
-
const resolved = uniqueDirs.map(
|
|
767
|
+
const resolved = uniqueDirs.map(realOrNull)
|
|
721
768
|
const contained = new Set<string>()
|
|
722
769
|
for (const [i, dir] of uniqueDirs.entries()) {
|
|
723
770
|
const real = resolved[i]
|
|
@@ -747,7 +794,7 @@ function isInside(dir: string, abs: string): boolean {
|
|
|
747
794
|
*/
|
|
748
795
|
export async function cleanOutputs(args: {
|
|
749
796
|
projectDir: string
|
|
750
|
-
outputs: string[]
|
|
797
|
+
outputs: readonly string[]
|
|
751
798
|
nestedProjectDirs: string[]
|
|
752
799
|
}): Promise<string[]> {
|
|
753
800
|
const files = await resolveOutputs(args)
|
|
@@ -801,7 +848,7 @@ export interface OutputStamp {
|
|
|
801
848
|
*/
|
|
802
849
|
export async function stampOutputs(args: {
|
|
803
850
|
projectDir: string
|
|
804
|
-
outputs: string[]
|
|
851
|
+
outputs: readonly string[]
|
|
805
852
|
nestedProjectDirs: string[]
|
|
806
853
|
}): Promise<Map<string, OutputStamp>> {
|
|
807
854
|
return stampFiles(await resolveOutputs(args))
|
|
@@ -810,7 +857,7 @@ export async function stampOutputs(args: {
|
|
|
810
857
|
/** `stampOutputs` for root-anchored `cache.outputs.workspaceFiles` (A-43). */
|
|
811
858
|
export async function stampWorkspaceOutputs(args: {
|
|
812
859
|
workspaceRoot: string
|
|
813
|
-
outputs: string[]
|
|
860
|
+
outputs: readonly string[]
|
|
814
861
|
}): Promise<Map<string, OutputStamp>> {
|
|
815
862
|
return stampFiles(await resolveWorkspaceOutputs(args))
|
|
816
863
|
}
|
|
@@ -841,7 +888,7 @@ function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
|
|
|
841
888
|
* file back, so no entry reproduces that run and none is saved.
|
|
842
889
|
*/
|
|
843
890
|
export async function ownOutputsSince(
|
|
844
|
-
args: { projectDir: string; outputs: string[]; nestedProjectDirs: string[] },
|
|
891
|
+
args: { projectDir: string; outputs: readonly string[]; nestedProjectDirs: string[] },
|
|
845
892
|
before: ReadonlyMap<string, OutputStamp>,
|
|
846
893
|
): Promise<string[] | undefined> {
|
|
847
894
|
return changedSince(await resolveOutputs(args), before)
|
|
@@ -849,7 +896,7 @@ export async function ownOutputsSince(
|
|
|
849
896
|
|
|
850
897
|
/** `ownOutputsSince` for root-anchored `cache.outputs.workspaceFiles` (A-43). */
|
|
851
898
|
export async function ownWorkspaceOutputsSince(
|
|
852
|
-
args: { workspaceRoot: string; outputs: string[] },
|
|
899
|
+
args: { workspaceRoot: string; outputs: readonly string[] },
|
|
853
900
|
before: ReadonlyMap<string, OutputStamp>,
|
|
854
901
|
): Promise<string[] | undefined> {
|
|
855
902
|
return changedSince(await resolveWorkspaceOutputs(args), before)
|
|
@@ -899,7 +946,7 @@ const SYNC_CLEAN_MAX = 128
|
|
|
899
946
|
* sends the reader to file a bug against a permission bit.
|
|
900
947
|
*/
|
|
901
948
|
async function removeAll(all: readonly string[], root: string): Promise<string[]> {
|
|
902
|
-
const files =
|
|
949
|
+
const files = insideRoot(all, root)
|
|
903
950
|
const refused = (f: string, err: NodeJS.ErrnoException): UserError => {
|
|
904
951
|
const rel = relPosix(root, f)
|
|
905
952
|
return new UserError(
|
|
@@ -929,21 +976,14 @@ async function removeAll(all: readonly string[], root: string): Promise<string[]
|
|
|
929
976
|
}
|
|
930
977
|
|
|
931
978
|
/**
|
|
932
|
-
* The paths whose directory
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
979
|
+
* The paths whose directory resolves inside `root`. A link inside the
|
|
980
|
+
* project is followed, as the save follows it (turborepo#13042): a clean
|
|
981
|
+
* that skipped `dist -> real-out` left the last run's files for the next
|
|
982
|
+
* entry to save (X-88). One that leaves the project is never deleted
|
|
983
|
+
* through (X-5); `cleanOutputPaths` takes recorded rows, not the resolver's
|
|
984
|
+
* contained set, so the check is here too.
|
|
938
985
|
*/
|
|
939
|
-
function
|
|
940
|
-
const real = (p: string): string | null => {
|
|
941
|
-
try {
|
|
942
|
-
return realpathSync(p)
|
|
943
|
-
} catch {
|
|
944
|
-
return null
|
|
945
|
-
}
|
|
946
|
-
}
|
|
986
|
+
function insideRoot(files: readonly string[], root: string): string[] {
|
|
947
987
|
// Resolved only once a directory exists to compare: after a clean pruned
|
|
948
988
|
// the outputs (the common restore) nothing does, and the call was most
|
|
949
989
|
// of an empty clean.
|
|
@@ -955,9 +995,8 @@ function notThroughLink(files: readonly string[], root: string): string[] {
|
|
|
955
995
|
if (ok === undefined) {
|
|
956
996
|
// A directory already gone has nothing to delete through; its path
|
|
957
997
|
// stays so the prune still reaches the parents it emptied.
|
|
958
|
-
const r =
|
|
959
|
-
ok =
|
|
960
|
-
r === null || r === path.join((realRoot ??= real(root) ?? root), path.relative(root, dir))
|
|
998
|
+
const r = realOrNull(dir)
|
|
999
|
+
ok = r === null || isInside((realRoot ??= realOrNull(root) ?? root), r)
|
|
961
1000
|
own.set(dir, ok)
|
|
962
1001
|
}
|
|
963
1002
|
return ok
|
|
@@ -1067,7 +1106,7 @@ function inScope(dir: string, scope: PruneScope): boolean {
|
|
|
1067
1106
|
*/
|
|
1068
1107
|
export async function resolveWorkspaceOutputs(args: {
|
|
1069
1108
|
workspaceRoot: string
|
|
1070
|
-
outputs: string[]
|
|
1109
|
+
outputs: readonly string[]
|
|
1071
1110
|
}): Promise<string[]> {
|
|
1072
1111
|
const { positive, negative } = splitNegations(args.outputs)
|
|
1073
1112
|
if (positive.length === 0) return []
|
|
@@ -1090,7 +1129,7 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
1090
1129
|
*/
|
|
1091
1130
|
export async function cleanWorkspaceOutputs(args: {
|
|
1092
1131
|
workspaceRoot: string
|
|
1093
|
-
outputs: string[]
|
|
1132
|
+
outputs: readonly string[]
|
|
1094
1133
|
}): Promise<string[]> {
|
|
1095
1134
|
const files = await resolveWorkspaceOutputs(args)
|
|
1096
1135
|
await pruneEmptiedDirs(
|
|
@@ -1101,8 +1140,12 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1101
1140
|
return files.map((f) => relPosix(args.workspaceRoot, f))
|
|
1102
1141
|
}
|
|
1103
1142
|
|
|
1104
|
-
function
|
|
1105
|
-
|
|
1143
|
+
function realOrNull(p: string): string | null {
|
|
1144
|
+
try {
|
|
1145
|
+
return realpathSync(p)
|
|
1146
|
+
} catch {
|
|
1147
|
+
return null
|
|
1148
|
+
}
|
|
1106
1149
|
}
|
|
1107
1150
|
|
|
1108
1151
|
// The literal-is-a-tree rule moved to `util/paths.ts` (item 442): it
|
|
@@ -1195,8 +1238,8 @@ async function assertNoInvisibleLiteralInputs(
|
|
|
1195
1238
|
interface ResolveFilesArgs {
|
|
1196
1239
|
projectDir: string
|
|
1197
1240
|
workspaceRoot: string
|
|
1198
|
-
files: string[] | undefined
|
|
1199
|
-
ownOutputs: string[]
|
|
1241
|
+
files: readonly string[] | undefined
|
|
1242
|
+
ownOutputs: readonly string[]
|
|
1200
1243
|
/** Root-relative `outputs.workspaceFiles`, and the project's root-relative directory. */
|
|
1201
1244
|
ownWorkspaceOutputs: readonly string[]
|
|
1202
1245
|
projectRel: string
|
|
@@ -1356,6 +1399,18 @@ export function declaresInput(
|
|
|
1356
1399
|
return matcher(workspaceRel)
|
|
1357
1400
|
}
|
|
1358
1401
|
|
|
1402
|
+
/**
|
|
1403
|
+
* Whether a cached task's `workspaceFiles` may name a file under `dir`
|
|
1404
|
+
* (workspace-relative): a nested repository git reports as that one path,
|
|
1405
|
+
* whose files the key folds and a match against the path alone misses.
|
|
1406
|
+
* By the entries' static prefixes, so a glob that may descend counts.
|
|
1407
|
+
*/
|
|
1408
|
+
export function workspaceFilesReachInto(cache: CacheConfig, dir: string): boolean {
|
|
1409
|
+
const ws = cache.inputs.workspaceFiles
|
|
1410
|
+
if (ws === undefined || ws.length === 0) return false
|
|
1411
|
+
return reaches(reachOf(splitNegations(ws).positive).prefixes, dir)
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1359
1414
|
function inPlan(plan: FilesPlan, rel: string): boolean {
|
|
1360
1415
|
let input = plan.verdicts.get(rel)
|
|
1361
1416
|
if (input === undefined) {
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -103,7 +103,7 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
103
103
|
// bytes on disk, the filter since removed, holds the CRLF build (A-60).
|
|
104
104
|
// v39: an entry saved while a gitlink without `.git` held files the key
|
|
105
105
|
// never listed sits under the key of that directory empty (A-61).
|
|
106
|
-
export const CACHE_VERSION = 'vx-cache-
|
|
106
|
+
export const CACHE_VERSION = 'vx-cache-v41'
|
|
107
107
|
|
|
108
108
|
/**
|
|
109
109
|
* The digest a key folds for an input gone between its enumeration and its
|
package/src/cache/layer.ts
CHANGED
|
@@ -296,7 +296,8 @@ export interface PruneOptions {
|
|
|
296
296
|
olderThanMs?: number
|
|
297
297
|
/**
|
|
298
298
|
* After applying olderThanMs, if the cache still exceeds this size in
|
|
299
|
-
* bytes, evict LRU (smallest `accessed_at` first
|
|
299
|
+
* bytes, evict LRU (smallest `accessed_at` first, a row-less artifact
|
|
300
|
+
* by its file time) until under it.
|
|
300
301
|
*/
|
|
301
302
|
maxBytes?: number
|
|
302
303
|
/**
|
|
@@ -312,10 +313,11 @@ export interface PruneResult {
|
|
|
312
313
|
/** Bytes the evicted entries occupied, per the index. */
|
|
313
314
|
bytesFreed: number
|
|
314
315
|
/**
|
|
315
|
-
* Artifacts and temp files with no index row, unlinked by the
|
|
316
|
-
*
|
|
317
|
-
* `cache.db`, a save that crashed before its rename).
|
|
318
|
-
*
|
|
316
|
+
* Artifacts and temp files with no index row, unlinked by the same
|
|
317
|
+
* policy (another vx version's open dropped the row, a deleted
|
|
318
|
+
* `cache.db`, a save that crashed before its rename). An artifact's file
|
|
319
|
+
* time plus an hour stands for its last use; a temp goes once an hour
|
|
320
|
+
* old. Files younger than one hour are never counted.
|
|
319
321
|
*/
|
|
320
322
|
orphans: number
|
|
321
323
|
/** Bytes the reaped orphans occupied on disk. */
|
|
@@ -330,7 +332,8 @@ export interface PruneResult {
|
|
|
330
332
|
* discriminator — least-invasive row format, no schema change. A
|
|
331
333
|
* project output dir literally named `workspace-outputs/` would
|
|
332
334
|
* collide with the namespace; the name is reserved, and a project
|
|
333
|
-
* output glob under it is refused at load (config-schema.ts)
|
|
335
|
+
* output glob under it is refused at load (config-schema.ts), a file
|
|
336
|
+
* another glob reaches there at save (`planArtifact`).
|
|
334
337
|
*/
|
|
335
338
|
export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
|
|
336
339
|
|
package/src/cache/policy.ts
CHANGED
|
@@ -36,6 +36,11 @@ export const FULL_CACHE_POLICY: CachePolicy = {
|
|
|
36
36
|
remoteWrite: true,
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** No axis on (`--no-cache`, `--cache=local:`): no cache answers for any task. */
|
|
40
|
+
export function cachesNothing(p: CachePolicy): boolean {
|
|
41
|
+
return !(p.localRead || p.localWrite || p.remoteRead || p.remoteWrite)
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
/**
|
|
40
45
|
* Parse a `--cache=<spec>` value into a `CachePolicy`, starting from a
|
|
41
46
|
* base (defaults to {@link FULL_CACHE_POLICY}). The spec is a
|
package/src/cache/zstd.ts
CHANGED
|
@@ -194,15 +194,21 @@ export async function decodedTar(
|
|
|
194
194
|
}
|
|
195
195
|
return oneChunk(await zstdDecompressBounded(source, hash, cap))
|
|
196
196
|
}
|
|
197
|
+
// A BunFile reads no further than the size its first stat cached, and a
|
|
198
|
+
// save or ingest of the same key may rename a longer copy over the path
|
|
199
|
+
// after that stat: the read came back cut short, the restore called the
|
|
200
|
+
// new artifact corrupt and dropped it. The size only picks the decode;
|
|
201
|
+
// each read goes through a handle that has not stat the path yet.
|
|
202
|
+
const fresh = (): Bun.BunFile => Bun.file(source.name!)
|
|
197
203
|
if (source.size <= STREAM_DECODE_FROM) {
|
|
198
|
-
const bytes = await
|
|
204
|
+
const bytes = await fresh().bytes()
|
|
199
205
|
if (assertDeclaredSize(bytes, hash, cap) === null || !isOneFrame(bytes)) {
|
|
200
|
-
return zstdDecodeStream(
|
|
206
|
+
return zstdDecodeStream(new Blob([bytes]), hash, cap)
|
|
201
207
|
}
|
|
202
208
|
return oneChunk(await zstdDecompressBounded(bytes, hash, cap))
|
|
203
209
|
}
|
|
204
|
-
assertDeclaredSize(await
|
|
205
|
-
return zstdDecodeStream(
|
|
210
|
+
assertDeclaredSize(await fresh().slice(0, 32).bytes(), hash, cap)
|
|
211
|
+
return zstdDecodeStream(fresh(), hash, cap)
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
/**
|
package/src/cli/cache.ts
CHANGED
|
@@ -85,10 +85,15 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
85
85
|
if (a === '--older-than' || a?.startsWith('--older-than=')) {
|
|
86
86
|
const v = a === '--older-than' ? args[++i] : a.slice('--older-than='.length)
|
|
87
87
|
if (v === undefined || v === '') {
|
|
88
|
-
return {
|
|
88
|
+
return {
|
|
89
|
+
error: `--older-than requires a value: a duration like 30d, 24h or 60m${seeHelp('cache')}`,
|
|
90
|
+
}
|
|
89
91
|
}
|
|
90
92
|
const ms = parseDuration(v)
|
|
91
|
-
if (ms === null)
|
|
93
|
+
if (ms === null)
|
|
94
|
+
return {
|
|
95
|
+
error: `--older-than must be a duration like 30d, 24h or 60m (got ${v})${seeHelp('cache')}`,
|
|
96
|
+
}
|
|
92
97
|
// `0d` evicts everything ever cached. That is far more often a
|
|
93
98
|
// computed-to-zero retention than an intent, and no other flag
|
|
94
99
|
// combination expresses "wipe the cache" — so refuse it and name
|
|
@@ -102,10 +107,11 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
102
107
|
} else if (a === '--max-size' || a?.startsWith('--max-size=')) {
|
|
103
108
|
const v = a === '--max-size' ? args[++i] : a.slice('--max-size='.length)
|
|
104
109
|
if (v === undefined || v === '') {
|
|
105
|
-
return { error: `--max-size requires a value
|
|
110
|
+
return { error: `--max-size requires a value: a size like 500M or 1G${seeHelp('cache')}` }
|
|
106
111
|
}
|
|
107
112
|
const bytes = parseSize(v)
|
|
108
|
-
if (bytes === null)
|
|
113
|
+
if (bytes === null)
|
|
114
|
+
return { error: `--max-size must be a size like 500M or 1G (got ${v})${seeHelp('cache')}` }
|
|
109
115
|
if (bytes === 0) {
|
|
110
116
|
return {
|
|
111
117
|
error: `--max-size 0 would evict every entry — to clear the cache, delete the cache store \`vx info\` names`,
|
|
@@ -134,13 +140,13 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
134
140
|
return {
|
|
135
141
|
error: `${refusedWord(a ?? '')}: ${a}${flagHint('cache prune', a ?? '')}${seeHelp('cache')}`,
|
|
136
142
|
}
|
|
137
|
-
if ('error' in cd) return { error: cd.error }
|
|
143
|
+
if ('error' in cd) return { error: `${cd.error}${seeHelp('cache')}` }
|
|
138
144
|
out.cacheDir = cd.cacheDir
|
|
139
145
|
i = cd.next
|
|
140
146
|
}
|
|
141
147
|
}
|
|
142
148
|
if (out.olderThanMs === undefined && out.maxBytes === undefined) {
|
|
143
|
-
return { error:
|
|
149
|
+
return { error: `needs --older-than <duration> or --max-size <size>${seeHelp('cache')}` }
|
|
144
150
|
}
|
|
145
151
|
return out
|
|
146
152
|
}
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// What a Turbo or
|
|
1
|
+
// What a Turbo, Nx or Vite Task (`vp run`) user's hands type, and what `vx run` does with it.
|
|
2
2
|
// Every flag here has one outcome: vx takes it as it is (`same`), takes it
|
|
3
3
|
// as another spelling (`alias`, rewritten before the parse), or refuses it
|
|
4
4
|
// with the vx way to say it (`refuse`). Nothing is dropped in silence: a
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// cli.md's parity section (`renderForeignFlags`), pinned by a test.
|
|
8
8
|
|
|
9
9
|
interface ForeignFlag {
|
|
10
|
-
runner: 'turbo' | 'nx'
|
|
10
|
+
runner: 'turbo' | 'nx' | 'vp'
|
|
11
11
|
/** Every spelling, first the one shown. */
|
|
12
12
|
names: readonly string[]
|
|
13
13
|
/** It takes a value: `--flag <v>` or `--flag=<v>`. */
|
|
@@ -375,7 +375,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
375
375
|
names: ['--preflight'],
|
|
376
376
|
value: false,
|
|
377
377
|
outcome: 'refuse',
|
|
378
|
-
vx: '`turboCache(
|
|
378
|
+
vx: 'set `turboCache({ preflight: true })` or `TURBO_PREFLIGHT=1`',
|
|
379
379
|
},
|
|
380
380
|
{
|
|
381
381
|
runner: 'turbo',
|
|
@@ -598,6 +598,60 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
598
598
|
outcome: 'refuse',
|
|
599
599
|
vx: 'vx has no cloud: drop it',
|
|
600
600
|
},
|
|
601
|
+
{
|
|
602
|
+
runner: 'vp',
|
|
603
|
+
names: ['-r', '--recursive'],
|
|
604
|
+
value: false,
|
|
605
|
+
outcome: 'alias',
|
|
606
|
+
vx: '`--all`',
|
|
607
|
+
to: () => ['--all'],
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
runner: 'vp',
|
|
611
|
+
names: ['-w', '--workspace-root'],
|
|
612
|
+
value: false,
|
|
613
|
+
outcome: 'alias',
|
|
614
|
+
vx: '`--filter //` (the root project)',
|
|
615
|
+
to: () => ['--filter', '//'],
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
runner: 'vp',
|
|
619
|
+
names: ['--concurrency-limit'],
|
|
620
|
+
value: true,
|
|
621
|
+
outcome: 'alias',
|
|
622
|
+
vx: '`--concurrency <n>`',
|
|
623
|
+
to: (v) => (v === undefined ? ['--concurrency'] : ['--concurrency', v]),
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
runner: 'vp',
|
|
627
|
+
names: ['--ignore-depends-on'],
|
|
628
|
+
value: false,
|
|
629
|
+
outcome: 'alias',
|
|
630
|
+
vx: '`--exclude-dependencies`',
|
|
631
|
+
to: () => ['--exclude-dependencies'],
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
runner: 'vp',
|
|
635
|
+
names: ['--fail-if-no-match'],
|
|
636
|
+
value: false,
|
|
637
|
+
outcome: 'alias',
|
|
638
|
+
vx: 'nothing: a filter that matches nothing already fails the run',
|
|
639
|
+
to: () => [],
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
runner: 'vp',
|
|
643
|
+
names: ['--log'],
|
|
644
|
+
value: true,
|
|
645
|
+
outcome: 'refuse',
|
|
646
|
+
vx: 'vx frames each task’s output: `--output-logs <mode>` sets how much',
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
runner: 'vp',
|
|
650
|
+
names: ['--last-details'],
|
|
651
|
+
value: false,
|
|
652
|
+
outcome: 'refuse',
|
|
653
|
+
vx: '`vx last` prints the last run',
|
|
654
|
+
},
|
|
601
655
|
]
|
|
602
656
|
|
|
603
657
|
/** Nx's verbs, as its users type them: `vx run` takes their flags. */
|
package/src/cli/info.ts
CHANGED
|
@@ -29,7 +29,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
|
|
|
29
29
|
}
|
|
30
30
|
const cd = parseCacheDirFlag(args, i)
|
|
31
31
|
if (cd !== null) {
|
|
32
|
-
if ('error' in cd) return { ...out, error: cd.error }
|
|
32
|
+
if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('info')}` }
|
|
33
33
|
out.cacheDir = cd.cacheDir
|
|
34
34
|
i = cd.next
|
|
35
35
|
continue
|
package/src/cli/init.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
42
42
|
if (seam === undefined || !Object.hasOwn(PLUGIN_TEMPLATES, seam)) {
|
|
43
43
|
return {
|
|
44
44
|
...out,
|
|
45
|
-
error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}`,
|
|
45
|
+
error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}${seeHelp('init')}`,
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
out.plugin = seam
|
|
@@ -56,7 +56,7 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
56
56
|
if (out.plugin !== undefined && out.mjs) {
|
|
57
57
|
return {
|
|
58
58
|
...out,
|
|
59
|
-
error:
|
|
59
|
+
error: `--mjs does not combine with --plugin: the plugin templates are TypeScript${seeHelp('init')}`,
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
return out
|