@vzn/vx 0.0.594 → 0.0.595
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/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +96 -90
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/inputs.ts +110 -37
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/tar-stream.ts +1 -1
- package/src/cli/cache.ts +1 -11
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +25 -4
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +29 -27
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +14 -1
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch.ts +18 -11
- package/src/config.ts +5 -5
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +5 -1
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +11 -5
- package/src/exec/sandbox-runtime.ts +20 -4
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +1 -1
- package/src/index.ts +6 -0
- package/src/orchestrator/execute-task.ts +29 -7
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/plan.ts +25 -1
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +2 -1
- package/src/orchestrator/run.ts +52 -8
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +14 -1
- package/src/util/index.ts +7 -1
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +48 -6
- package/src/workspace/migrate-scripts.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +72 -0
- package/src/workspace/workspace.ts +82 -11
package/src/cache/inputs.ts
CHANGED
|
@@ -182,11 +182,14 @@ export type ProjectFilesCache = Map<
|
|
|
182
182
|
>
|
|
183
183
|
|
|
184
184
|
export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
|
|
185
|
+
const projectRel = path.relative(args.workspaceRoot, args.projectDir).split(path.sep).join('/')
|
|
185
186
|
const { files: projectFiles, listing } = await resolveFiles({
|
|
186
187
|
projectDir: args.projectDir,
|
|
187
188
|
workspaceRoot: args.workspaceRoot,
|
|
188
189
|
files: args.inputs?.files,
|
|
189
190
|
ownOutputs: args.ownOutputs,
|
|
191
|
+
ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
|
|
192
|
+
projectRel,
|
|
190
193
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
191
194
|
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
192
195
|
...(args.projectFilesCache !== undefined ? { projectFilesCache: args.projectFilesCache } : {}),
|
|
@@ -199,6 +202,8 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
|
|
|
199
202
|
workspaceRoot: args.workspaceRoot,
|
|
200
203
|
workspaceFiles: wsDecl,
|
|
201
204
|
ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
|
|
205
|
+
ownOutputs: args.ownOutputs,
|
|
206
|
+
projectRel,
|
|
202
207
|
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
203
208
|
...(args.workspaceFilesCache !== undefined ? { memo: args.workspaceFilesCache } : {}),
|
|
204
209
|
})
|
|
@@ -258,6 +263,9 @@ function resolveWorkspaceFiles(args: {
|
|
|
258
263
|
workspaceRoot: string
|
|
259
264
|
workspaceFiles: readonly string[]
|
|
260
265
|
ownWorkspaceOutputs: readonly string[]
|
|
266
|
+
/** The task's project-relative `outputs.files`, and its project's root-relative directory. */
|
|
267
|
+
ownOutputs: readonly string[]
|
|
268
|
+
projectRel: string
|
|
261
269
|
gitFilesCache?: GitFilesCache
|
|
262
270
|
memo?: WorkspaceFilesCache
|
|
263
271
|
}): { files: Promise<string[]>; listing: InputListing } | undefined {
|
|
@@ -274,6 +282,11 @@ function resolveWorkspaceFiles(args: {
|
|
|
274
282
|
// A path the task's own outputs take back with `!` is no output, so it
|
|
275
283
|
// stays an input (A-44).
|
|
276
284
|
const ownOutput = outputMatcher(args.ownWorkspaceOutputs)
|
|
285
|
+
// Its project outputs too, which a root-anchored glob can reach as well:
|
|
286
|
+
// left in, the task's own build moved its key, and no run was ever saved.
|
|
287
|
+
let ownProjectOutput: ((rel: string) => boolean) | undefined
|
|
288
|
+
const ownProject = (rel: string): boolean =>
|
|
289
|
+
(ownProjectOutput ??= underProject(args.projectRel, outputMatcher(args.ownOutputs)))(rel)
|
|
277
290
|
const positiveGlobs = asTrees(positive).map(globFor)
|
|
278
291
|
// Workspace-wide partition, keyed by the workspace root. Populated
|
|
279
292
|
// up-front by `populateGitFilesCache(..., workspaceWide: true)` when
|
|
@@ -293,9 +306,15 @@ function resolveWorkspaceFiles(args: {
|
|
|
293
306
|
const memoKey =
|
|
294
307
|
args.memo === undefined
|
|
295
308
|
? undefined
|
|
296
|
-
: JSON.stringify([
|
|
309
|
+
: JSON.stringify([
|
|
310
|
+
positive,
|
|
311
|
+
negative,
|
|
312
|
+
args.ownWorkspaceOutputs,
|
|
313
|
+
args.ownOutputs,
|
|
314
|
+
args.projectRel,
|
|
315
|
+
])
|
|
297
316
|
let isPositive: ((rel: string) => boolean) | undefined
|
|
298
|
-
const excluded = (rel: string): boolean => isExcluded(rel) || ownOutput(rel)
|
|
317
|
+
const excluded = (rel: string): boolean => isExcluded(rel) || ownOutput(rel) || ownProject(rel)
|
|
299
318
|
const listing: InputListing = {
|
|
300
319
|
root: args.workspaceRoot,
|
|
301
320
|
listed: gitFiles,
|
|
@@ -352,6 +371,24 @@ async function resolveWorkspaceFilesOver(
|
|
|
352
371
|
return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
|
|
353
372
|
}
|
|
354
373
|
|
|
374
|
+
/** A project-relative matcher asked of root-relative paths: false outside the project. */
|
|
375
|
+
function underProject(
|
|
376
|
+
projectRel: string,
|
|
377
|
+
matches: (rel: string) => boolean,
|
|
378
|
+
): (rel: string) => boolean {
|
|
379
|
+
if (projectRel === '') return matches
|
|
380
|
+
const prefix = `${projectRel}/`
|
|
381
|
+
return (rel) => rel.startsWith(prefix) && matches(rel.slice(prefix.length))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** A root-relative matcher asked of project-relative paths. */
|
|
385
|
+
function inProject(
|
|
386
|
+
projectRel: string,
|
|
387
|
+
matches: (rel: string) => boolean,
|
|
388
|
+
): (rel: string) => boolean {
|
|
389
|
+
return projectRel === '' ? matches : (rel) => matches(`${projectRel}/${rel}`)
|
|
390
|
+
}
|
|
391
|
+
|
|
355
392
|
/** Anything at the path — file, directory, symlink to anything or to nothing. */
|
|
356
393
|
function existsOnDisk(abs: string): boolean {
|
|
357
394
|
try {
|
|
@@ -712,25 +749,13 @@ export async function cleanOutputs(args: {
|
|
|
712
749
|
projectDir: string
|
|
713
750
|
outputs: string[]
|
|
714
751
|
nestedProjectDirs: string[]
|
|
715
|
-
/**
|
|
716
|
-
* Before a miss: keep the directory each wildcard glob is rooted at
|
|
717
|
-
* (`dist` for `dist/**`). The task writes its matches under it, so a
|
|
718
|
-
* remove there bought only an rmdir and the task's mkdir: 0.4 ms of a
|
|
719
|
-
* 4.9 ms one-file miss (B-49). A restore prunes it, since the entry's
|
|
720
|
-
* shape decides there.
|
|
721
|
-
*/
|
|
722
|
-
keepGlobRoots?: boolean
|
|
723
752
|
}): Promise<string[]> {
|
|
724
753
|
const files = await resolveOutputs(args)
|
|
725
754
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
726
755
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
727
756
|
// A symlink is unlinked, never followed.
|
|
728
757
|
const removed = await removeAll(files, args.projectDir)
|
|
729
|
-
await pruneEmptiedDirs(
|
|
730
|
-
args.projectDir,
|
|
731
|
-
removed,
|
|
732
|
-
args.keepGlobRoots === true ? globRoots(args.projectDir, args.outputs) : undefined,
|
|
733
|
-
)
|
|
758
|
+
await pruneEmptiedDirs(args.projectDir, removed, pruneScope(args.projectDir, args.outputs))
|
|
734
759
|
// Project-relative posix paths of what was removed — the caller
|
|
735
760
|
// feeds these to GitFilesCache.markOutputsChanged after a restore.
|
|
736
761
|
return files.map((f) => relPosix(args.projectDir, f))
|
|
@@ -740,15 +765,20 @@ export async function cleanOutputs(args: {
|
|
|
740
765
|
* Remove exactly these project-relative paths — the recorded rows of an
|
|
741
766
|
* ADDITIVE task's own artifact (item 588), never a glob: the glob would
|
|
742
767
|
* take the upstream's files the task adds beside. Emptied directories are
|
|
743
|
-
* pruned as `cleanOutputs` prunes them,
|
|
744
|
-
* fills.
|
|
768
|
+
* pruned as `cleanOutputs` prunes them, only inside the declared `outputs`
|
|
769
|
+
* (`pruneScope`), and stop at one the upstream still fills.
|
|
745
770
|
*/
|
|
746
771
|
export async function cleanOutputPaths(args: {
|
|
747
772
|
projectDir: string
|
|
748
773
|
rels: readonly string[]
|
|
774
|
+
outputs: readonly string[]
|
|
749
775
|
}): Promise<void> {
|
|
750
776
|
const files = args.rels.map((r) => path.resolve(args.projectDir, r))
|
|
751
|
-
await pruneEmptiedDirs(
|
|
777
|
+
await pruneEmptiedDirs(
|
|
778
|
+
args.projectDir,
|
|
779
|
+
await removeAll(files, args.projectDir),
|
|
780
|
+
pruneScope(args.projectDir, args.outputs),
|
|
781
|
+
)
|
|
752
782
|
}
|
|
753
783
|
|
|
754
784
|
/**
|
|
@@ -914,7 +944,10 @@ function notThroughLink(files: readonly string[], root: string): string[] {
|
|
|
914
944
|
return null
|
|
915
945
|
}
|
|
916
946
|
}
|
|
917
|
-
|
|
947
|
+
// Resolved only once a directory exists to compare: after a clean pruned
|
|
948
|
+
// the outputs (the common restore) nothing does, and the call was most
|
|
949
|
+
// of an empty clean.
|
|
950
|
+
let realRoot: string | undefined
|
|
918
951
|
const own = new Map<string, boolean>()
|
|
919
952
|
return files.filter((f) => {
|
|
920
953
|
const dir = path.dirname(f)
|
|
@@ -923,7 +956,8 @@ function notThroughLink(files: readonly string[], root: string): string[] {
|
|
|
923
956
|
// A directory already gone has nothing to delete through; its path
|
|
924
957
|
// stays so the prune still reaches the parents it emptied.
|
|
925
958
|
const r = real(dir)
|
|
926
|
-
ok =
|
|
959
|
+
ok =
|
|
960
|
+
r === null || r === path.join((realRoot ??= real(root) ?? root), path.relative(root, dir))
|
|
927
961
|
own.set(dir, ok)
|
|
928
962
|
}
|
|
929
963
|
return ok
|
|
@@ -937,12 +971,13 @@ function notThroughLink(files: readonly string[], root: string): string[] {
|
|
|
937
971
|
* wrote `dist/out/…` and now writes `dist/out`) blocks the rename, and an
|
|
938
972
|
* empty directory is not an output anyone declared. A directory that still
|
|
939
973
|
* holds something — a stray the globs do not cover — stays, and the restore
|
|
940
|
-
* says so if it is in the way.
|
|
974
|
+
* says so if it is in the way. With a `scope`, only a directory inside it
|
|
975
|
+
* goes (`pruneScope`).
|
|
941
976
|
*/
|
|
942
977
|
async function pruneEmptiedDirs(
|
|
943
978
|
root: string,
|
|
944
979
|
removed: readonly string[],
|
|
945
|
-
|
|
980
|
+
scope?: PruneScope,
|
|
946
981
|
): Promise<void> {
|
|
947
982
|
const rootResolved = path.resolve(root)
|
|
948
983
|
// LEVEL ORDER, not a walk-up per directory. A parent is attempted only
|
|
@@ -960,7 +995,9 @@ async function pruneEmptiedDirs(
|
|
|
960
995
|
const parents = new Set<string>()
|
|
961
996
|
const dirs = [...level].filter(
|
|
962
997
|
(dir) =>
|
|
963
|
-
dir !== rootResolved &&
|
|
998
|
+
dir !== rootResolved &&
|
|
999
|
+
dir.startsWith(rootResolved + path.sep) &&
|
|
1000
|
+
(scope === undefined || inScope(dir, scope)),
|
|
964
1001
|
)
|
|
965
1002
|
const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
|
|
966
1003
|
if (dirs.length <= SYNC_CLEAN_MAX) {
|
|
@@ -983,19 +1020,42 @@ async function pruneEmptiedDirs(
|
|
|
983
1020
|
}
|
|
984
1021
|
}
|
|
985
1022
|
|
|
1023
|
+
interface PruneScope {
|
|
1024
|
+
/** Wildcard globs' roots (`dist` for `dist/**`): prune strictly below. */
|
|
1025
|
+
below: string[]
|
|
1026
|
+
/** Literal outputs (`out` as a tree): prune the path itself and below. */
|
|
1027
|
+
atOrBelow: string[]
|
|
1028
|
+
}
|
|
1029
|
+
|
|
986
1030
|
/**
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
1031
|
+
* Where a clean, before a miss or a restore, may prune: only the trees the
|
|
1032
|
+
* task declared. A directory above a glob's root or holding a literal
|
|
1033
|
+
* output (`out` for `out/a.txt`) stays: a sibling task running beside this
|
|
1034
|
+
* one may have just made it and not yet written into it, and pruning it
|
|
1035
|
+
* failed that task "Directory nonexistent". A glob's root stays: the task
|
|
1036
|
+
* writes under it, and a remove there bought only an rmdir and the task's
|
|
1037
|
+
* mkdir (B-49). A literal's own path goes: the entry or the task may need
|
|
1038
|
+
* a file there. A glob rooted at the project prunes nothing.
|
|
990
1039
|
*/
|
|
991
|
-
function
|
|
992
|
-
const
|
|
1040
|
+
function pruneScope(base: string, outputs: readonly string[]): PruneScope {
|
|
1041
|
+
const scope: PruneScope = { below: [], atOrBelow: [] }
|
|
993
1042
|
for (const g of outputs) {
|
|
994
|
-
if (g.startsWith('!')
|
|
1043
|
+
if (g.startsWith('!')) continue
|
|
1044
|
+
if (isLiteralPattern(g)) {
|
|
1045
|
+
scope.atOrBelow.push(path.resolve(base, stripTrailingSlash(normalizeGlob(g))))
|
|
1046
|
+
continue
|
|
1047
|
+
}
|
|
995
1048
|
const prefix = staticPrefix(g)
|
|
996
|
-
if (prefix !== '.')
|
|
1049
|
+
if (prefix !== '.') scope.below.push(path.resolve(base, prefix))
|
|
997
1050
|
}
|
|
998
|
-
return
|
|
1051
|
+
return scope
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function inScope(dir: string, scope: PruneScope): boolean {
|
|
1055
|
+
return (
|
|
1056
|
+
scope.below.some((r) => dir.startsWith(r + path.sep)) ||
|
|
1057
|
+
scope.atOrBelow.some((r) => dir === r || dir.startsWith(r + path.sep))
|
|
1058
|
+
)
|
|
999
1059
|
}
|
|
1000
1060
|
|
|
1001
1061
|
/**
|
|
@@ -1033,7 +1093,11 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1033
1093
|
outputs: string[]
|
|
1034
1094
|
}): Promise<string[]> {
|
|
1035
1095
|
const files = await resolveWorkspaceOutputs(args)
|
|
1036
|
-
await pruneEmptiedDirs(
|
|
1096
|
+
await pruneEmptiedDirs(
|
|
1097
|
+
args.workspaceRoot,
|
|
1098
|
+
await removeAll(files, args.workspaceRoot),
|
|
1099
|
+
pruneScope(args.workspaceRoot, args.outputs),
|
|
1100
|
+
)
|
|
1037
1101
|
return files.map((f) => relPosix(args.workspaceRoot, f))
|
|
1038
1102
|
}
|
|
1039
1103
|
|
|
@@ -1133,6 +1197,9 @@ interface ResolveFilesArgs {
|
|
|
1133
1197
|
workspaceRoot: string
|
|
1134
1198
|
files: string[] | undefined
|
|
1135
1199
|
ownOutputs: string[]
|
|
1200
|
+
/** Root-relative `outputs.workspaceFiles`, and the project's root-relative directory. */
|
|
1201
|
+
ownWorkspaceOutputs: readonly string[]
|
|
1202
|
+
projectRel: string
|
|
1136
1203
|
nestedProjectDirs: string[]
|
|
1137
1204
|
gitFilesCache?: GitFilesCache
|
|
1138
1205
|
projectFilesCache?: ProjectFilesCache
|
|
@@ -1324,6 +1391,8 @@ async function resolveFiles(
|
|
|
1324
1391
|
const { positive, negative, isExcluded, ownOutput, positiveGlobs, isPositive } = plan
|
|
1325
1392
|
|
|
1326
1393
|
const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
|
|
1394
|
+
// Its root-anchored outputs that land in the project, as `files` sees them.
|
|
1395
|
+
const ownWsOutput = inProject(args.projectRel, outputMatcher(args.ownWorkspaceOutputs))
|
|
1327
1396
|
|
|
1328
1397
|
// Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
|
|
1329
1398
|
// files, .git/info/exclude, and global excludes all participate
|
|
@@ -1335,7 +1404,7 @@ async function resolveFiles(
|
|
|
1335
1404
|
// duration of one orchestrator run.
|
|
1336
1405
|
// Everything below the snapshot that decides the result: the project, what
|
|
1337
1406
|
// it declares, what it excludes as its own outputs, and the boundaries.
|
|
1338
|
-
const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${args.nestedProjectDirs.join('\u0001')}`
|
|
1407
|
+
const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${args.ownWorkspaceOutputs.join('\u0001')}\0${args.nestedProjectDirs.join('\u0001')}`
|
|
1339
1408
|
let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
|
|
1340
1409
|
let undecodable = args.gitFilesCache?.undecodableNames
|
|
1341
1410
|
if (gitFiles !== undefined) {
|
|
@@ -1346,7 +1415,7 @@ async function resolveFiles(
|
|
|
1346
1415
|
if (memo !== undefined && memo.snapshot === gitFiles) {
|
|
1347
1416
|
return {
|
|
1348
1417
|
files: [...memo.result],
|
|
1349
|
-
listing: listingFor(args.projectDir, gitFiles, plan, nested),
|
|
1418
|
+
listing: listingFor(args.projectDir, gitFiles, plan, nested, ownWsOutput),
|
|
1350
1419
|
}
|
|
1351
1420
|
}
|
|
1352
1421
|
}
|
|
@@ -1397,7 +1466,7 @@ async function resolveFiles(
|
|
|
1397
1466
|
input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
1398
1467
|
verdicts.set(rel, input)
|
|
1399
1468
|
}
|
|
1400
|
-
if (!input || nested(rel)) continue
|
|
1469
|
+
if (!input || nested(rel) || ownWsOutput(rel)) continue
|
|
1401
1470
|
candidates.push(base === undefined ? path.resolve(args.projectDir, rel) : base + rel)
|
|
1402
1471
|
}
|
|
1403
1472
|
const unmatchedLiterals = unanswered(plan.literals, gitFiles)
|
|
@@ -1424,7 +1493,10 @@ async function resolveFiles(
|
|
|
1424
1493
|
// Stored only on the way out: a declaration whose literal named an
|
|
1425
1494
|
// invisible file threw above, and every task sharing it must throw too.
|
|
1426
1495
|
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
|
1427
|
-
return {
|
|
1496
|
+
return {
|
|
1497
|
+
files: [...resolved],
|
|
1498
|
+
listing: listingFor(args.projectDir, gitFiles, plan, nested, ownWsOutput),
|
|
1499
|
+
}
|
|
1428
1500
|
}
|
|
1429
1501
|
|
|
1430
1502
|
function listingFor(
|
|
@@ -1432,11 +1504,12 @@ function listingFor(
|
|
|
1432
1504
|
listed: readonly string[],
|
|
1433
1505
|
plan: FilesPlan,
|
|
1434
1506
|
nested: (rel: string) => boolean,
|
|
1507
|
+
ownWsOutput: (rel: string) => boolean,
|
|
1435
1508
|
): InputListing {
|
|
1436
1509
|
return {
|
|
1437
1510
|
root,
|
|
1438
1511
|
listed,
|
|
1439
|
-
isInput: (rel) => inPlan(plan, rel),
|
|
1512
|
+
isInput: (rel) => inPlan(plan, rel) && !ownWsOutput(rel),
|
|
1440
1513
|
nested,
|
|
1441
1514
|
prefixes: plan.prefixes,
|
|
1442
1515
|
literals: plan.literals,
|
|
@@ -185,7 +185,27 @@ class ScopedRemote implements RemoteCacheLayer {
|
|
|
185
185
|
}
|
|
186
186
|
|
|
187
187
|
async has(hash: string): Promise<boolean> {
|
|
188
|
-
return
|
|
188
|
+
return this.fallThrough(
|
|
189
|
+
() => this.remote.has(this.scoped(hash)),
|
|
190
|
+
() => this.remote.has(hash),
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The scope's answer, else the trusted one. A scope lookup that fails
|
|
196
|
+
* still asks the trusted key, so a hit there is not lost; its error is
|
|
197
|
+
* rethrown (and warned) when the trusted key fails or has nothing too.
|
|
198
|
+
*/
|
|
199
|
+
private async fallThrough<T>(own: () => Promise<T>, trusted: () => Promise<T>): Promise<T> {
|
|
200
|
+
let found: T
|
|
201
|
+
try {
|
|
202
|
+
found = await own()
|
|
203
|
+
} catch (err) {
|
|
204
|
+
const fallback = await trusted().catch(() => undefined)
|
|
205
|
+
if (!fallback) throw err
|
|
206
|
+
return fallback
|
|
207
|
+
}
|
|
208
|
+
return found || trusted()
|
|
189
209
|
}
|
|
190
210
|
|
|
191
211
|
private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
@@ -207,7 +227,10 @@ class ScopedRemote implements RemoteCacheLayer {
|
|
|
207
227
|
hash: string,
|
|
208
228
|
): Promise<{ body: Blob | Response; durationMs: number | undefined } | null> {
|
|
209
229
|
if (this.trustedOnly.has(hash)) return this.remote.get(hash)
|
|
210
|
-
return
|
|
230
|
+
return this.fallThrough(
|
|
231
|
+
() => this.remote.get(this.scoped(hash)),
|
|
232
|
+
() => this.remote.get(hash),
|
|
233
|
+
)
|
|
211
234
|
}
|
|
212
235
|
|
|
213
236
|
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
|
package/src/cache/tar-stream.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// short entry. Non-regular entries are reported with their type and their
|
|
12
12
|
// bodies skipped; the caller decides what to materialise (nothing but `0`).
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
interface TarEntry {
|
|
15
15
|
name: string
|
|
16
16
|
size: number
|
|
17
17
|
/** POSIX typeflag: '0' regular, '5' directory, '2' symlink, … */
|
package/src/cli/cache.ts
CHANGED
|
@@ -185,19 +185,9 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
185
185
|
printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
|
|
186
186
|
return 0
|
|
187
187
|
}
|
|
188
|
-
if (parsed.dryRun === true) {
|
|
189
|
-
const earlier = await Cache.orphansBeforeReset(dir)
|
|
190
|
-
if (earlier !== null) {
|
|
191
|
-
warnToStderr(
|
|
192
|
-
`[vx] the cache index is schema ${earlier.found} from another vx version: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
|
|
193
|
-
)
|
|
194
|
-
printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true, json)
|
|
195
|
-
return 0
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
188
|
const cache =
|
|
199
189
|
parsed.dryRun === true
|
|
200
|
-
? new Cache(dir, undefined, undefined, undefined, '
|
|
190
|
+
? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
|
|
201
191
|
: new Cache(dir, undefined, root, undefined, 'open', storeRoot)
|
|
202
192
|
// A prune deletes rows and artifacts; a cache this user cannot write is
|
|
203
193
|
// refused up front with the directory named, as a run refuses it, rather
|
package/src/cli/completions.ts
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
|
|
9
9
|
import { nearest } from '../util/index.js'
|
|
10
10
|
|
|
11
|
+
/** A flag whose value is one of a fixed set; the tests hold each to its parser. */
|
|
12
|
+
export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
|
|
13
|
+
'--format': ['pretty', 'json'],
|
|
14
|
+
'--output-logs': ['full', 'errors-only', 'hash-only', 'none'],
|
|
15
|
+
'--download': ['all', 'toplevel', 'none'],
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
export type CompletionShell = 'bash' | 'zsh' | 'fish'
|
|
12
19
|
const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
|
|
13
20
|
|
|
@@ -36,6 +43,14 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
|
|
|
36
43
|
...((CORE_VERBS as readonly string[]).includes(v) ? verbFlags(v) : ['--help']),
|
|
37
44
|
],
|
|
38
45
|
}))
|
|
46
|
+
// `vx run --output-logs <Tab>` offered the flags again; a flag with a
|
|
47
|
+
// fixed value set offers that set.
|
|
48
|
+
const values = table.flatMap((t) =>
|
|
49
|
+
t.words.flatMap((w) => {
|
|
50
|
+
const vs = FLAG_VALUES[w]
|
|
51
|
+
return vs === undefined ? [] : [{ verb: t.verb, flag: w, values: vs.join(' ') }]
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
39
54
|
switch (shell) {
|
|
40
55
|
case 'bash':
|
|
41
56
|
return [
|
|
@@ -43,10 +58,23 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
|
|
|
43
58
|
'_vx() {',
|
|
44
59
|
' local cur="${COMP_WORDS[COMP_CWORD]}"',
|
|
45
60
|
' local verb="${COMP_WORDS[1]}"',
|
|
61
|
+
' local prev="${COMP_WORDS[COMP_CWORD-1]}"',
|
|
46
62
|
' if [ "$COMP_CWORD" -eq 1 ]; then',
|
|
47
63
|
` COMPREPLY=($(compgen -W "${verbs.join(' ')}" -- "$cur"))`,
|
|
48
64
|
' return',
|
|
49
65
|
' fi',
|
|
66
|
+
// bash splits `--format=js` at the `=` (COMP_WORDBREAKS).
|
|
67
|
+
' if [ "$cur" = "=" ]; then',
|
|
68
|
+
' cur=""',
|
|
69
|
+
' elif [ "$prev" = "=" ]; then',
|
|
70
|
+
' prev="${COMP_WORDS[COMP_CWORD-2]}"',
|
|
71
|
+
' fi',
|
|
72
|
+
' case "$verb $prev" in',
|
|
73
|
+
...values.map(
|
|
74
|
+
(v) =>
|
|
75
|
+
` "${v.verb} ${v.flag}") COMPREPLY=($(compgen -W "${v.values}" -- "$cur")); return ;;`,
|
|
76
|
+
),
|
|
77
|
+
' esac',
|
|
50
78
|
' case "$verb" in',
|
|
51
79
|
...table.map(
|
|
52
80
|
(t) => ` ${t.verb}) COMPREPLY=($(compgen -W "${t.words.join(' ')}" -- "$cur")) ;;`,
|
|
@@ -66,6 +94,9 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
|
|
|
66
94
|
` compadd -- ${verbs.join(' ')}`,
|
|
67
95
|
' return',
|
|
68
96
|
' fi',
|
|
97
|
+
' case "${words[2]} ${words[CURRENT-1]}" in',
|
|
98
|
+
...values.map((v) => ` "${v.verb} ${v.flag}") compadd -- ${v.values}; return ;;`),
|
|
99
|
+
' esac',
|
|
69
100
|
' case "${words[2]}" in',
|
|
70
101
|
...table.map((t) => ` ${t.verb}) compadd -- ${t.words.join(' ')} ;;`),
|
|
71
102
|
' esac',
|
|
@@ -86,11 +117,14 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
|
|
|
86
117
|
'complete -c vx -f',
|
|
87
118
|
...verbs.map((v) => `complete -c vx -n __fish_use_subcommand -a ${v}`),
|
|
88
119
|
...table.flatMap((t) =>
|
|
89
|
-
t.words.map((w) =>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
120
|
+
t.words.map((w) => {
|
|
121
|
+
const on = `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}"`
|
|
122
|
+
if (!w.startsWith('--')) return `${on} -a ${w}`
|
|
123
|
+
const vs = FLAG_VALUES[w]
|
|
124
|
+
return vs === undefined
|
|
125
|
+
? `${on} -l ${w.slice(2)}`
|
|
126
|
+
: `${on} -l ${w.slice(2)} -x -a "${vs.join(' ')}"`
|
|
127
|
+
}),
|
|
94
128
|
),
|
|
95
129
|
'',
|
|
96
130
|
].join('\n')
|
|
@@ -111,7 +145,9 @@ export async function completionsCmd(
|
|
|
111
145
|
return 1
|
|
112
146
|
}
|
|
113
147
|
const one = shell !== undefined && args.length === 1
|
|
114
|
-
|
|
148
|
+
// One edit: shell names are three or four letters, so two edits took
|
|
149
|
+
// `tcsh` to `bash`, a different shell.
|
|
150
|
+
const best = one ? nearest(shell, SHELLS, 1) : undefined
|
|
115
151
|
process.stderr.write(
|
|
116
152
|
`vx completions: expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}\n`,
|
|
117
153
|
)
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -36,6 +36,17 @@ const list = (v: string | undefined): string[] =>
|
|
|
36
36
|
.map((s) => s.trim())
|
|
37
37
|
.filter((s) => s !== '')
|
|
38
38
|
|
|
39
|
+
// Nx's `--projects` labels (find-matching-projects, 23.2.1): `name:` and
|
|
40
|
+
// `directory:` say which match to make; vx's filter says it by form. `tag:`
|
|
41
|
+
// is a filter form already.
|
|
42
|
+
const nxPattern = (p: string): string => {
|
|
43
|
+
const neg = p.startsWith('!') ? '!' : ''
|
|
44
|
+
const s = neg === '' ? p : p.slice(1)
|
|
45
|
+
if (s.startsWith('name:')) return `${neg}${s.slice(5)}`
|
|
46
|
+
if (s.startsWith('directory:')) return `${neg}./${s.slice(10)}`
|
|
47
|
+
return p
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
const GRAPH_IMAGE = /\.(svg|png|jpe?g|pdf|json|html|mermaid)$/i
|
|
40
51
|
|
|
41
52
|
export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
@@ -142,7 +153,14 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
142
153
|
outcome: 'same',
|
|
143
154
|
vx: '`--output-logs full|errors-only|hash-only|none`',
|
|
144
155
|
},
|
|
145
|
-
{
|
|
156
|
+
{
|
|
157
|
+
runner: 'turbo',
|
|
158
|
+
names: ['--no-cache'],
|
|
159
|
+
value: false,
|
|
160
|
+
outcome: 'same',
|
|
161
|
+
// Turbo 2.11's own help: "Equivalent to `--cache=local:r,remote:r`".
|
|
162
|
+
vx: "`--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache local:r,remote:r`",
|
|
163
|
+
},
|
|
146
164
|
{
|
|
147
165
|
runner: 'turbo',
|
|
148
166
|
names: ['--cache'],
|
|
@@ -407,12 +425,15 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
407
425
|
names: ['-p', '--projects'],
|
|
408
426
|
value: true,
|
|
409
427
|
outcome: 'alias',
|
|
410
|
-
vx: "`--filter <pattern>`, one per project; a list opening with `!` starts from all (`--filter '*'`)",
|
|
428
|
+
vx: "`--filter <pattern>`, one per project (`directory:<d>` is `./<d>`, `name:<n>` is `<n>`); a list opening with `!` starts from all (`--filter '*'`)",
|
|
411
429
|
// Nx reads a leading exclusion as "all projects but": `-p '!b,a'` is
|
|
412
430
|
// every project except b, where vx's filters alone would select a.
|
|
413
431
|
to: (v) => {
|
|
414
432
|
const ps = list(v)
|
|
415
|
-
return [...(ps[0]?.startsWith('!') ? ['*'] : []), ...ps].flatMap((p) => [
|
|
433
|
+
return [...(ps[0]?.startsWith('!') ? ['*'] : []), ...ps].flatMap((p) => [
|
|
434
|
+
'--filter',
|
|
435
|
+
nxPattern(p),
|
|
436
|
+
])
|
|
416
437
|
},
|
|
417
438
|
},
|
|
418
439
|
{
|
|
@@ -421,7 +442,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
421
442
|
value: true,
|
|
422
443
|
outcome: 'alias',
|
|
423
444
|
vx: "`--filter '!<pattern>'`, one per project",
|
|
424
|
-
to: (v) => list(v).flatMap((p) => ['--filter', `!${p}`]),
|
|
445
|
+
to: (v) => list(v).flatMap((p) => ['--filter', `!${nxPattern(p)}`]),
|
|
425
446
|
},
|
|
426
447
|
{
|
|
427
448
|
runner: 'nx',
|
package/src/cli/help.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
111
111
|
' vx lock [--check]',
|
|
112
112
|
' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
|
|
113
113
|
' vx upgrade [TAG]',
|
|
114
|
-
' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
|
|
114
|
+
' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
|
|
115
115
|
' vx info [--format pretty|json] [--cache-dir <path>]',
|
|
116
116
|
' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
|
|
117
117
|
' vx last [RUNID] [--list[=N]] [--failed] [--format pretty|json] [--cache-dir <path>]',
|
|
@@ -140,8 +140,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
140
140
|
' to its flags; unnamed layers keep their value. Examples:',
|
|
141
141
|
' local:rw,remote:r (remote read-only), remote: (remote off),',
|
|
142
142
|
' local:r (local read-only).',
|
|
143
|
-
' --cache-dir <path> Cache directory override (cwd-relative). Beats
|
|
144
|
-
' cacheDir field
|
|
143
|
+
' --cache-dir <path> Cache directory override (cwd-relative). Beats VX_CACHE_DIR,',
|
|
144
|
+
' the workspace cacheDir field and the ~/.vx/<id>/cache default.',
|
|
145
145
|
' --retry <n> Re-run a failed task up to <n> more times (default for tasks',
|
|
146
146
|
" without their own exec.retries; explicit config wins). Doesn't",
|
|
147
147
|
' affect cache keys.',
|
|
@@ -209,6 +209,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
209
209
|
' evaluation, not the lock), one block per task.',
|
|
210
210
|
" vx show <pkg>#<task> Print a single task's resolved config.",
|
|
211
211
|
' vx show <task> That task in every project declaring it.',
|
|
212
|
+
" --filter <pattern> The list (or a task's projects) as `vx run --filter` selects.",
|
|
213
|
+
' --affected[=<ref>] Only what `vx run --affected` selects: changed and dependents.',
|
|
212
214
|
' --format <fmt> pretty (default) | json.',
|
|
213
215
|
' vx info Workspace doctor: vx/bun/git versions, project +',
|
|
214
216
|
' task counts, cache dir/entries/size, recent runs,',
|
package/src/cli/init.ts
CHANGED
|
@@ -369,7 +369,7 @@ function namelessNotes(root: string, dirs: readonly string[]): string[] {
|
|
|
369
369
|
const withScripts = dirs
|
|
370
370
|
.filter((dir) => {
|
|
371
371
|
const scripts = (
|
|
372
|
-
JSON.parse(readText(path.join(dir, 'package.json')) || '{}') as {
|
|
372
|
+
JSON.parse(readText(path.join(dir, 'package.json')).replace(/^\uFEFF/, '') || '{}') as {
|
|
373
373
|
scripts?: unknown
|
|
374
374
|
}
|
|
375
375
|
).scripts
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -29,6 +29,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
29
29
|
let miss = 0
|
|
30
30
|
let nocache = 0
|
|
31
31
|
let noop = 0
|
|
32
|
+
let idle = 0
|
|
32
33
|
for (const t of real) {
|
|
33
34
|
// `@noop`: the run succeeds it without running, whatever its key says.
|
|
34
35
|
const skipped = t.executor === 'noop'
|
|
@@ -56,6 +57,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
56
57
|
else if (t.cacheStatus === 'hit-remote') remote++
|
|
57
58
|
else if (t.cacheStatus === 'miss') miss++
|
|
58
59
|
else if (t.cacheStatus === 'no-cache') nocache++
|
|
60
|
+
else if (t.cacheStatus === 'not-started') idle++
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
const hitParts: string[] = []
|
|
@@ -67,6 +69,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
67
69
|
if (miss > 0) summary.push(`${miss} would run`)
|
|
68
70
|
if (nocache > 0) summary.push(`${nocache} no-cache`)
|
|
69
71
|
if (noop > 0) summary.push(`${noop} noop`)
|
|
72
|
+
if (idle > 0) summary.push(`${idle} not started`)
|
|
70
73
|
lines.push('')
|
|
71
74
|
lines.push(summary.join(', ') + '.')
|
|
72
75
|
|
|
@@ -199,6 +202,8 @@ function symbolFor(s: CacheStatus): string {
|
|
|
199
202
|
return '·'
|
|
200
203
|
case 'group':
|
|
201
204
|
return '○'
|
|
205
|
+
case 'not-started':
|
|
206
|
+
return '−'
|
|
202
207
|
}
|
|
203
208
|
}
|
|
204
209
|
|
|
@@ -214,6 +219,8 @@ function describe(s: CacheStatus): string {
|
|
|
214
219
|
return 'no-cache (would exec)'
|
|
215
220
|
case 'group':
|
|
216
221
|
return 'group task'
|
|
222
|
+
case 'not-started':
|
|
223
|
+
return 'not started (unneeded)'
|
|
217
224
|
}
|
|
218
225
|
}
|
|
219
226
|
|
|
@@ -229,5 +236,7 @@ function dotColor(s: CacheStatus): string {
|
|
|
229
236
|
return '#e5e7eb' // gray-200
|
|
230
237
|
case 'group':
|
|
231
238
|
return '#f5d0fe' // fuchsia-200
|
|
239
|
+
case 'not-started':
|
|
240
|
+
return '#ffffff'
|
|
232
241
|
}
|
|
233
242
|
}
|