@vzn/vx 0.0.635 → 0.0.637
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 +47 -19
- package/docs/caching.md +222 -210
- package/docs/cli.md +699 -664
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +31 -32
- package/docs/schema.md +418 -202
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +13 -4
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -61
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +123 -144
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +191 -71
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +25 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +192 -70
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +43 -28
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +114 -73
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +127 -88
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +251 -241
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +22 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cache/inputs.ts
CHANGED
|
@@ -58,7 +58,7 @@ export {
|
|
|
58
58
|
type GitEnumeration,
|
|
59
59
|
} from './git-inputs.js'
|
|
60
60
|
|
|
61
|
-
// vx
|
|
61
|
+
// .vx/projects.lock.json is committed (so git enumerates it) but it's vx's own
|
|
62
62
|
// frozen-config metadata — never a task input. Excluded globally so a
|
|
63
63
|
// re-lock can't bust every cache key the way a tracked source file
|
|
64
64
|
// would. (Literal pattern, not the workspace `LOCKFILE_NAME` constant:
|
|
@@ -79,9 +79,17 @@ const ALWAYS_IGNORE = [
|
|
|
79
79
|
// it. Kept: the day an enumeration stops going through git, this is
|
|
80
80
|
// what keeps a repository's object store out of a cache key.
|
|
81
81
|
'**/.git/**',
|
|
82
|
-
|
|
82
|
+
// vx's machine-local state; the workspace config beside it is a source.
|
|
83
|
+
'**/.vx/cache/**',
|
|
83
84
|
'**/*.tsbuildinfo',
|
|
84
|
-
'
|
|
85
|
+
'**/.vx/projects.lock.json',
|
|
86
|
+
// `vx lock`'s review copy of a project's resolved config: the config is
|
|
87
|
+
// keyed already, and a re-lock must not move every `**` reader's key.
|
|
88
|
+
'**/vx.lock.json',
|
|
89
|
+
// What `vx lock` and `vx init` write beside the lock: a re-lock or init
|
|
90
|
+
// must not select every `**` reader under --affected.
|
|
91
|
+
'**/.vx/.gitattributes',
|
|
92
|
+
'**/.vx/.gitignore',
|
|
85
93
|
// `bun build --compile` writes a transient `.<hash>-<n>.bun-build` intermediate
|
|
86
94
|
// in the cwd. It never rests on disk, so it can't be a real input — but a
|
|
87
95
|
// broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
|
|
@@ -313,7 +321,13 @@ function resolveWorkspaceFiles(args: {
|
|
|
313
321
|
let isPositive: ((rel: string) => boolean) | undefined
|
|
314
322
|
const excludedByDeclaration = (rel: string): boolean =>
|
|
315
323
|
(isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
|
|
316
|
-
|
|
324
|
+
// A committed file stays an input whatever output reaches it (`committedFiles`).
|
|
325
|
+
let committed: ((abs: string) => boolean) | undefined
|
|
326
|
+
const own = (rel: string): boolean =>
|
|
327
|
+
(ownOutput(rel) || ownProject(rel)) &&
|
|
328
|
+
!(committed ??= committedFiles(args.gitFilesCache, args.workspaceRoot))(
|
|
329
|
+
path.join(args.workspaceRoot, rel),
|
|
330
|
+
)
|
|
317
331
|
const listing: InputListing = {
|
|
318
332
|
root: args.workspaceRoot,
|
|
319
333
|
listed: gitFiles,
|
|
@@ -667,11 +681,13 @@ async function resolveRuntimeValues(
|
|
|
667
681
|
/**
|
|
668
682
|
* What no output glob reaches. An output is otherwise taken as written —
|
|
669
683
|
* `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
|
|
670
|
-
* apply here — but
|
|
671
|
-
*
|
|
672
|
-
*
|
|
684
|
+
* apply here — but no task produces these, and a root project declaring
|
|
685
|
+
* `**` would otherwise wipe its repository and the cache it is restoring
|
|
686
|
+
* from (2026-09-16). `.vx` whole and each `vx.lock.json` are vx's own: as
|
|
687
|
+
* outputs, the clean deleted an uncommitted workspace config and a hit
|
|
688
|
+
* restored a lock or ignore file the key does not see over the current one.
|
|
673
689
|
*/
|
|
674
|
-
const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**']
|
|
690
|
+
const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
|
|
675
691
|
|
|
676
692
|
/**
|
|
677
693
|
* What a set of output globs may not reach: `OUTPUT_NEVER`, and every
|
|
@@ -719,6 +735,20 @@ export async function resolveOutputs(args: {
|
|
|
719
735
|
return containedIn(args.projectDir, scanned).sort()
|
|
720
736
|
}
|
|
721
737
|
|
|
738
|
+
/**
|
|
739
|
+
* Whether git tracks a file under `dir`. No clean deletes a committed (or
|
|
740
|
+
* `git add`ed) file an output glob reaches (owner, 2026-10-14: "never
|
|
741
|
+
* delete committed file"); the task may rewrite it and a restore writes the
|
|
742
|
+
* bytes it left. It stays in the task's key, so those bytes follow from
|
|
743
|
+
* the key. Turbo and Nx never clean an output at all.
|
|
744
|
+
*/
|
|
745
|
+
export function committedFiles(
|
|
746
|
+
cache: GitFilesCache | undefined,
|
|
747
|
+
dir: string,
|
|
748
|
+
): (abs: string) => boolean {
|
|
749
|
+
return (cache ?? new GitFilesCache()).committedIn(dir)
|
|
750
|
+
}
|
|
751
|
+
|
|
722
752
|
/**
|
|
723
753
|
* The literal head of each output glob, walked: the scan follows a link
|
|
724
754
|
* there (`dist -> ../elsewhere` for `dist/**`) and only there. A link to a
|
|
@@ -838,6 +868,7 @@ export async function cleanOutputs(args: {
|
|
|
838
868
|
projectDir: string
|
|
839
869
|
outputs: readonly string[]
|
|
840
870
|
nestedProjectDirs: string[]
|
|
871
|
+
committed?: (abs: string) => boolean
|
|
841
872
|
/**
|
|
842
873
|
* What `resolveOutputs` returned for these same arguments, when the
|
|
843
874
|
* caller walked the tree with no await since: a hit that found its tree
|
|
@@ -846,15 +877,20 @@ export async function cleanOutputs(args: {
|
|
|
846
877
|
*/
|
|
847
878
|
resolved?: readonly string[] | undefined
|
|
848
879
|
}): Promise<string[]> {
|
|
849
|
-
const
|
|
880
|
+
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
881
|
+
const matched = args.resolved ?? (await resolveOutputs(args))
|
|
850
882
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
851
883
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
852
884
|
// A symlink is unlinked, never followed.
|
|
853
|
-
const removed = await removeAll(
|
|
885
|
+
const removed = await removeAll(
|
|
886
|
+
matched.filter((f) => !committed(f)),
|
|
887
|
+
args.projectDir,
|
|
888
|
+
)
|
|
854
889
|
await pruneEmptiedDirs(args.projectDir, removed, pruneScope(args.projectDir, args.outputs))
|
|
855
|
-
// Project-relative posix paths
|
|
856
|
-
//
|
|
857
|
-
|
|
890
|
+
// Project-relative posix paths the outputs reach, for the caller's
|
|
891
|
+
// GitFilesCache.markOutputsChanged: a committed one kept here is the
|
|
892
|
+
// task's to rewrite or remove, so its index OID no longer vouches for it.
|
|
893
|
+
return matched.map((f) => relPosix(args.projectDir, f))
|
|
858
894
|
}
|
|
859
895
|
|
|
860
896
|
/**
|
|
@@ -868,8 +904,10 @@ export async function cleanOutputPaths(args: {
|
|
|
868
904
|
projectDir: string
|
|
869
905
|
rels: readonly string[]
|
|
870
906
|
outputs: readonly string[]
|
|
907
|
+
committed?: (abs: string) => boolean
|
|
871
908
|
}): Promise<void> {
|
|
872
|
-
const
|
|
909
|
+
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
910
|
+
const files = args.rels.map((r) => path.resolve(args.projectDir, r)).filter((f) => !committed(f))
|
|
873
911
|
await pruneEmptiedDirs(
|
|
874
912
|
args.projectDir,
|
|
875
913
|
await removeAll(files, args.projectDir),
|
|
@@ -980,6 +1018,29 @@ function changedSince(
|
|
|
980
1018
|
return kept < before.size ? undefined : own
|
|
981
1019
|
}
|
|
982
1020
|
|
|
1021
|
+
/**
|
|
1022
|
+
* Is any file `before` stamped still there, unchanged? Under
|
|
1023
|
+
* `cache.outputs.clean: false` such a file is one the run found and never
|
|
1024
|
+
* wrote, so no entry may claim it as the run's output.
|
|
1025
|
+
*/
|
|
1026
|
+
export function anyUntouched(before: ReadonlyMap<string, OutputStamp>): boolean {
|
|
1027
|
+
for (const [f, was] of before) {
|
|
1028
|
+
try {
|
|
1029
|
+
const st = lstatSync(f)
|
|
1030
|
+
if (
|
|
1031
|
+
st.size === was.size &&
|
|
1032
|
+
st.mtimeMs === was.mtimeMs &&
|
|
1033
|
+
st.ino === was.ino &&
|
|
1034
|
+
st.ctimeMs === was.ctimeMs
|
|
1035
|
+
)
|
|
1036
|
+
return true
|
|
1037
|
+
} catch {
|
|
1038
|
+
// Removed by the run: nothing of it is left to claim.
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return false
|
|
1042
|
+
}
|
|
1043
|
+
|
|
983
1044
|
/**
|
|
984
1045
|
* Up to this many paths, a clean removes synchronously: the threadpool round
|
|
985
1046
|
* trip per `rm`/`rmdir` cost more than the unlink itself, 0.30 ms against
|
|
@@ -1179,16 +1240,21 @@ export async function resolveWorkspaceOutputs(args: {
|
|
|
1179
1240
|
export async function cleanWorkspaceOutputs(args: {
|
|
1180
1241
|
workspaceRoot: string
|
|
1181
1242
|
outputs: readonly string[]
|
|
1243
|
+
committed?: (abs: string) => boolean
|
|
1182
1244
|
/** `cleanOutputs`'s `resolved`, from `resolveWorkspaceOutputs`. */
|
|
1183
1245
|
resolved?: readonly string[] | undefined
|
|
1184
1246
|
}): Promise<string[]> {
|
|
1185
|
-
const
|
|
1247
|
+
const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
|
|
1248
|
+
const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
|
|
1186
1249
|
await pruneEmptiedDirs(
|
|
1187
1250
|
args.workspaceRoot,
|
|
1188
|
-
await removeAll(
|
|
1251
|
+
await removeAll(
|
|
1252
|
+
matched.filter((f) => !committed(f)),
|
|
1253
|
+
args.workspaceRoot,
|
|
1254
|
+
),
|
|
1189
1255
|
pruneScope(args.workspaceRoot, args.outputs),
|
|
1190
1256
|
)
|
|
1191
|
-
return
|
|
1257
|
+
return matched.map((f) => relPosix(args.workspaceRoot, f))
|
|
1192
1258
|
}
|
|
1193
1259
|
|
|
1194
1260
|
function realOrNull(p: string): string | null {
|
|
@@ -1380,6 +1446,12 @@ interface FilesPlan {
|
|
|
1380
1446
|
* `package.json`), so each is matched once per plan.
|
|
1381
1447
|
*/
|
|
1382
1448
|
verdicts: Map<string, boolean>
|
|
1449
|
+
/**
|
|
1450
|
+
* Whether a path is an input but for the task's own outputs: one git
|
|
1451
|
+
* tracks stays an input (`committedFiles`), so the key sees an edit to a
|
|
1452
|
+
* committed file an output glob reaches.
|
|
1453
|
+
*/
|
|
1454
|
+
ownOnly: Map<string, boolean>
|
|
1383
1455
|
}
|
|
1384
1456
|
|
|
1385
1457
|
/**
|
|
@@ -1423,6 +1495,7 @@ function filesPlan(
|
|
|
1423
1495
|
isPositive: anyTaskGlob(asTrees(positive)),
|
|
1424
1496
|
...reachOf(positive),
|
|
1425
1497
|
verdicts: new Map<string, boolean>(),
|
|
1498
|
+
ownOnly: new Map<string, boolean>(),
|
|
1426
1499
|
}
|
|
1427
1500
|
filesPlans.set(key, plan)
|
|
1428
1501
|
return plan
|
|
@@ -1432,8 +1505,8 @@ function filesPlan(
|
|
|
1432
1505
|
* Whether a changed path is one of a cached task's declared file inputs:
|
|
1433
1506
|
* `projectRel` (the path relative to the project that owns it) against
|
|
1434
1507
|
* `files`, `workspaceRel` (relative to the workspace root) against
|
|
1435
|
-
* `workspaceFiles`; null skips that half, by the globs
|
|
1436
|
-
* the
|
|
1508
|
+
* `workspaceFiles`; null skips that half, by the globs and `!` exclusions (a path under
|
|
1509
|
+
* the task's own outputs counts: a committed one is keyed). `--affected` seeds a task with it (owner, 2026-10-04).
|
|
1437
1510
|
*/
|
|
1438
1511
|
export function declaresInput(
|
|
1439
1512
|
cache: CacheConfig,
|
|
@@ -1441,13 +1514,15 @@ export function declaresInput(
|
|
|
1441
1514
|
workspaceRel: string | null,
|
|
1442
1515
|
): boolean {
|
|
1443
1516
|
if (projectRel !== null) {
|
|
1517
|
+
// A changed path under the task's own outputs is a committed file
|
|
1518
|
+
// (`git diff` lists only what git tracks) or a stray `git status`
|
|
1519
|
+
// lists: either way it may be an input, so it counts.
|
|
1444
1520
|
const plan = filesPlan(cache.inputs.files, cache.outputs.files)
|
|
1445
|
-
if (plan !== null && inPlan(plan, projectRel)) return true
|
|
1521
|
+
if (plan !== null && (inPlan(plan, projectRel) || ownOnly(plan, projectRel))) return true
|
|
1446
1522
|
}
|
|
1447
1523
|
const ws = cache.inputs.workspaceFiles
|
|
1448
1524
|
if (workspaceRel === null || ws === undefined || ws.length === 0) return false
|
|
1449
|
-
|
|
1450
|
-
return matcher(workspaceRel)
|
|
1525
|
+
return workspaceMatcher(ws)(workspaceRel)
|
|
1451
1526
|
}
|
|
1452
1527
|
|
|
1453
1528
|
/**
|
|
@@ -1471,20 +1546,26 @@ function inPlan(plan: FilesPlan, rel: string): boolean {
|
|
|
1471
1546
|
return input
|
|
1472
1547
|
}
|
|
1473
1548
|
|
|
1549
|
+
/** `FilesPlan.ownOnly`, memoised: asked only of a path `inPlan` refused. */
|
|
1550
|
+
function ownOnly(plan: FilesPlan, rel: string): boolean {
|
|
1551
|
+
let own = plan.ownOnly.get(rel)
|
|
1552
|
+
if (own === undefined) {
|
|
1553
|
+
own = plan.ownOutput(rel) && plan.isPositive(rel) && !plan.isExcluded(rel)
|
|
1554
|
+
plan.ownOnly.set(rel, own)
|
|
1555
|
+
}
|
|
1556
|
+
return own
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1474
1559
|
const workspaceMatchers = new Map<string, (rel: string) => boolean>()
|
|
1475
1560
|
|
|
1476
|
-
function workspaceMatcher(
|
|
1477
|
-
|
|
1478
|
-
ownOutputs: readonly string[],
|
|
1479
|
-
): (rel: string) => boolean {
|
|
1480
|
-
const key = JSON.stringify([decl, ownOutputs])
|
|
1561
|
+
function workspaceMatcher(decl: readonly string[]): (rel: string) => boolean {
|
|
1562
|
+
const key = JSON.stringify(decl)
|
|
1481
1563
|
let m = workspaceMatchers.get(key)
|
|
1482
1564
|
if (m !== undefined) return m
|
|
1483
1565
|
const { positive, negative } = splitNegations(decl)
|
|
1484
1566
|
const isPositive = anyTaskGlob(asTrees(positive))
|
|
1485
1567
|
const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
|
|
1486
|
-
|
|
1487
|
-
m = (rel) => isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
1568
|
+
m = (rel) => isPositive(rel) && !isExcluded(rel)
|
|
1488
1569
|
workspaceMatchers.set(key, m)
|
|
1489
1570
|
return m
|
|
1490
1571
|
}
|
|
@@ -1521,7 +1602,7 @@ async function resolveFiles(
|
|
|
1521
1602
|
if (memo !== undefined && memo.snapshot === gitFiles) {
|
|
1522
1603
|
return {
|
|
1523
1604
|
files: [...memo.result],
|
|
1524
|
-
listing: listingFor(args
|
|
1605
|
+
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1525
1606
|
}
|
|
1526
1607
|
}
|
|
1527
1608
|
}
|
|
@@ -1566,14 +1647,17 @@ async function resolveFiles(
|
|
|
1566
1647
|
: undefined
|
|
1567
1648
|
const candidates: string[] = []
|
|
1568
1649
|
const verdicts = plan.verdicts
|
|
1650
|
+
const committed = committedFiles(args.gitFilesCache, args.projectDir)
|
|
1569
1651
|
for (const rel of gitFiles) {
|
|
1570
1652
|
let input = verdicts.get(rel)
|
|
1571
1653
|
if (input === undefined) {
|
|
1572
1654
|
input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
|
|
1573
1655
|
verdicts.set(rel, input)
|
|
1574
1656
|
}
|
|
1575
|
-
if (!input || nested(rel) || ownWsOutput(rel)) continue
|
|
1576
|
-
|
|
1657
|
+
if ((!input && !ownOnly(plan, rel)) || nested(rel) || ownWsOutput(rel)) continue
|
|
1658
|
+
const abs = base === undefined ? path.resolve(args.projectDir, rel) : base + rel
|
|
1659
|
+
if (!input && !committed(abs)) continue
|
|
1660
|
+
candidates.push(abs)
|
|
1577
1661
|
}
|
|
1578
1662
|
const unmatchedLiterals = unanswered(plan.literals, gitFiles)
|
|
1579
1663
|
if (unmatchedLiterals.size > 0) {
|
|
@@ -1601,21 +1685,27 @@ async function resolveFiles(
|
|
|
1601
1685
|
args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
|
|
1602
1686
|
return {
|
|
1603
1687
|
files: [...resolved],
|
|
1604
|
-
listing: listingFor(args
|
|
1688
|
+
listing: listingFor(args, gitFiles, plan, nested, ownWsOutput),
|
|
1605
1689
|
}
|
|
1606
1690
|
}
|
|
1607
1691
|
|
|
1608
1692
|
function listingFor(
|
|
1609
|
-
|
|
1693
|
+
args: { projectDir: string; gitFilesCache?: GitFilesCache | undefined },
|
|
1610
1694
|
listed: readonly string[],
|
|
1611
1695
|
plan: FilesPlan,
|
|
1612
1696
|
nested: (rel: string) => boolean,
|
|
1613
1697
|
ownWsOutput: (rel: string) => boolean,
|
|
1614
1698
|
): InputListing {
|
|
1699
|
+
const root = args.projectDir
|
|
1700
|
+
let committed: ((abs: string) => boolean) | undefined
|
|
1615
1701
|
return {
|
|
1616
1702
|
root,
|
|
1617
1703
|
listed,
|
|
1618
|
-
isInput: (rel) =>
|
|
1704
|
+
isInput: (rel) =>
|
|
1705
|
+
(inPlan(plan, rel) ||
|
|
1706
|
+
(ownOnly(plan, rel) &&
|
|
1707
|
+
(committed ??= committedFiles(args.gitFilesCache, root))(path.join(root, rel)))) &&
|
|
1708
|
+
!ownWsOutput(rel),
|
|
1619
1709
|
nested,
|
|
1620
1710
|
prefixes: plan.prefixes,
|
|
1621
1711
|
literals: plan.literals,
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -31,9 +31,9 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
31
31
|
// v23: fold cache.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
|
-
// resolved live every run, so it stays correct under
|
|
34
|
+
// resolved live every run, so it stays correct under the lock. No
|
|
35
35
|
// SCHEMA bump — only Cache.key derivation changed.
|
|
36
|
-
// v24: vx
|
|
36
|
+
// v24: .vx/projects.lock.json added to the always-ignored input set (cache/
|
|
37
37
|
// inputs.ts ALWAYS_IGNORE). A task whose globs matched the root
|
|
38
38
|
// lockfile (broad `**/*` on the root project) drops it from the hashed
|
|
39
39
|
// file set, so those keys change; tasks that never matched it are
|
|
@@ -105,7 +105,12 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
105
105
|
// never listed sits under the key of that directory empty (A-61).
|
|
106
106
|
// v42: the stored output is both streams in order (orchestrator/output-log.ts);
|
|
107
107
|
// a v41 entry holds stdout alone and replays no stderr.
|
|
108
|
-
|
|
108
|
+
// v43: a scoped run stores under the scope's key locally too (F4,
|
|
109
|
+
// 2026-10-14); a v42 entry a scoped run left locally sits under the default key.
|
|
110
|
+
// v43 stays when core stopped folding package files (2026-10-14): the
|
|
111
|
+
// `workspace:` and `pkg:` parts left the chain, so every key moved and no
|
|
112
|
+
// unchanged key holds wrong bytes.
|
|
113
|
+
export const CACHE_VERSION = 'vx-cache-v43'
|
|
109
114
|
|
|
110
115
|
/**
|
|
111
116
|
* The digest a key folds for an input gone between its enumeration and its
|
|
@@ -151,16 +156,12 @@ export async function foldKey(
|
|
|
151
156
|
const cap = input.captureInto
|
|
152
157
|
let h = xxh3(CACHE_VERSION)
|
|
153
158
|
h = xxh3(`task:${input.taskId}`, h)
|
|
154
|
-
h = xxh3(`workspace:${input.workspaceFingerprint}`, h)
|
|
155
|
-
h = xxh3(`pkg:${input.projectPackageJsonHash}`, h)
|
|
156
159
|
h = xxh3(`config:${input.taskConfigHash}`, h)
|
|
157
160
|
if (cap) {
|
|
158
161
|
// Recorded so `vx why` can name a key that moved only because vx
|
|
159
162
|
// keys differently now (an upgrade): unrecorded, every task's miss
|
|
160
163
|
// read as "inputs differ" with no input changed (X-142).
|
|
161
164
|
cap.push({ kind: 'format', name: 'cache-version', hash: CACHE_VERSION })
|
|
162
|
-
cap.push({ kind: 'workspace', name: 'fingerprint', hash: input.workspaceFingerprint })
|
|
163
|
-
cap.push({ kind: 'package', name: 'package.json', hash: input.projectPackageJsonHash })
|
|
164
165
|
cap.push({ kind: 'config', name: 'config', hash: input.taskConfigHash })
|
|
165
166
|
}
|
|
166
167
|
|
package/src/cache/layer.ts
CHANGED
|
@@ -65,27 +65,12 @@ export interface CacheKeyInput {
|
|
|
65
65
|
readonly hash: string
|
|
66
66
|
readonly projectDir: string
|
|
67
67
|
}>
|
|
68
|
-
/**
|
|
69
|
-
* Workspace-level fingerprint — typically a hash of `pnpm-lock.yaml` +
|
|
70
|
-
* `pnpm-workspace.yaml`. Folds resolved dep versions and workspace shape
|
|
71
|
-
* into every task's key, so a lockfile bump invalidates everything.
|
|
72
|
-
*/
|
|
73
|
-
workspaceFingerprint: string
|
|
74
68
|
/**
|
|
75
69
|
* CLI args forwarded to the task (after `--`). Folded into the key so that
|
|
76
70
|
* the same command with different forwarded args is treated as a distinct
|
|
77
71
|
* run, never a spurious cache hit.
|
|
78
72
|
*/
|
|
79
73
|
forwardArgs?: readonly string[]
|
|
80
|
-
/**
|
|
81
|
-
* Hash of the project's `package.json` bytes. Folded into the key
|
|
82
|
-
* implicitly (Turbo / Nx parity) so dep changes invalidate every
|
|
83
|
-
* task in that project, even when `cache.inputs.files` doesn't
|
|
84
|
-
* cover package.json. Empty string when the project has no
|
|
85
|
-
* package.json (impossible in practice — workspace discovery
|
|
86
|
-
* requires one — but we don't fail-loud here).
|
|
87
|
-
*/
|
|
88
|
-
projectPackageJsonHash: string
|
|
89
74
|
/**
|
|
90
75
|
* Precomputed content hashes (git blob OIDs) keyed by absolute
|
|
91
76
|
* path — typically the trusted-index OID map harvested by the
|
|
@@ -190,7 +175,7 @@ export interface RunRecord {
|
|
|
190
175
|
endedAt: number // ms-epoch wall clock
|
|
191
176
|
/**
|
|
192
177
|
* Optional analytics columns. Populated by the orchestrator/runner;
|
|
193
|
-
* stored as NULL on rows from older runs. Surfaced via `vx
|
|
178
|
+
* stored as NULL on rows from older runs. Surfaced via `vx info`
|
|
194
179
|
* and consumable from CI by reading cache.db directly.
|
|
195
180
|
*/
|
|
196
181
|
runId?: string // ULID shared across every task in one `vx run` invocation
|
|
@@ -495,7 +480,7 @@ export class CorruptArtifactError extends Error {
|
|
|
495
480
|
|
|
496
481
|
/**
|
|
497
482
|
* `restoreOutputs` found no artifact where the probe found one: it was
|
|
498
|
-
* removed in between — a `vx cache
|
|
483
|
+
* removed in between — a `vx cache clean` in another shell, or the
|
|
499
484
|
* `cacheRetention` of another workspace sharing the cache directory. The
|
|
500
485
|
* bytes are not wrong, they are gone, so the entry is a MISS and the task
|
|
501
486
|
* runs (execute-task.ts); nothing about the cache needs throwing away.
|
|
@@ -702,6 +687,12 @@ export interface CacheLayer {
|
|
|
702
687
|
* Only computed on the miss/save path — never on a hit.
|
|
703
688
|
*/
|
|
704
689
|
inputComponents?: readonly TaskInputRow[]
|
|
690
|
+
/**
|
|
691
|
+
* For a sandboxed task: whether it could read a canonical path. A
|
|
692
|
+
* symlinked output is packed as its target's bytes, outside the
|
|
693
|
+
* sandbox, so one whose target the task could not read is refused.
|
|
694
|
+
*/
|
|
695
|
+
readable?: ((real: string) => boolean) | undefined
|
|
705
696
|
}): Promise<void>
|
|
706
697
|
/**
|
|
707
698
|
* Adopt an artifact produced elsewhere — the remote-hit path. Streams
|
|
@@ -33,12 +33,11 @@ import type {
|
|
|
33
33
|
SaveArgs,
|
|
34
34
|
} from './cache.js'
|
|
35
35
|
import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
|
|
36
|
-
import { xxh3hex } from '../util/index.js'
|
|
37
36
|
|
|
38
37
|
/**
|
|
39
38
|
* What a remote cache layer must provide — THE plugin seam for remote
|
|
40
|
-
* caching. Core ships no wire client; a plugin's `cache`
|
|
41
|
-
* embedder
|
|
39
|
+
* caching. Core ships no wire client; a plugin's `cache` hook returns one
|
|
40
|
+
* (or an embedder passes one as `RunOptions.remoteCache`), speaking
|
|
42
41
|
* whatever protocol it wants, and `LayeredCache` owns everything else:
|
|
43
42
|
* policy gating, in-flight dedup, remote provenance, and the never-fail
|
|
44
43
|
* contract (implementations THROW on failure; LayeredCache degrades every
|
|
@@ -60,6 +59,11 @@ export interface RemoteCacheLayer {
|
|
|
60
59
|
* dropped before it is printed.
|
|
61
60
|
*/
|
|
62
61
|
readonly endpoint?: string
|
|
62
|
+
/**
|
|
63
|
+
* What the wire's credential allows, when less than both (a read-only
|
|
64
|
+
* token): core clears the run's remote axis it lacks. Omitted → both.
|
|
65
|
+
*/
|
|
66
|
+
readonly access?: { readonly read?: boolean; readonly write?: boolean }
|
|
63
67
|
/** Existence probe (drives the plan path's `--dry` remote prediction). */
|
|
64
68
|
has(hash: string): Promise<boolean>
|
|
65
69
|
/**
|
|
@@ -161,91 +165,6 @@ function batchOf(hashes: readonly string[]): { batch: string } {
|
|
|
161
165
|
return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
|
|
162
166
|
}
|
|
163
167
|
|
|
164
|
-
/**
|
|
165
|
-
* The remote as an untrusted scope sees it: reads try the scope's key,
|
|
166
|
-
* where a PR's own earlier runs left it, then the trusted one; writes go to the scope's key only. A scope's key is
|
|
167
|
-
* derived from the task key, so every wire stores it unchanged and the
|
|
168
|
-
* trusted keyspace never holds a scoped write. What a scoped read
|
|
169
|
-
* returns is ingested under the TASK key, and ingest refuses an artifact
|
|
170
|
-
* recording another key, so a derived-key collision degrades to a miss.
|
|
171
|
-
*/
|
|
172
|
-
class ScopedRemote implements RemoteCacheLayer {
|
|
173
|
-
readonly endpoint?: string
|
|
174
|
-
readonly hasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
|
|
175
|
-
/** Task keys a batch probe found under the trusted key alone: their GET skips the scope's. */
|
|
176
|
-
private readonly trustedOnly = new Set<string>()
|
|
177
|
-
|
|
178
|
-
constructor(
|
|
179
|
-
private readonly remote: RemoteCacheLayer,
|
|
180
|
-
private readonly scope: string,
|
|
181
|
-
) {
|
|
182
|
-
if (remote.endpoint !== undefined) this.endpoint = remote.endpoint
|
|
183
|
-
if (remote.hasMany !== undefined) this.hasMany = (hashes) => this.scopedHasMany(hashes)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
private scoped(hash: string): string {
|
|
187
|
-
return scopedKey(this.scope, hash)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async has(hash: string): Promise<boolean> {
|
|
191
|
-
return this.fallThrough(
|
|
192
|
-
() => this.remote.has(this.scoped(hash)),
|
|
193
|
-
() => this.remote.has(hash),
|
|
194
|
-
)
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* The scope's answer, else the trusted one. A scope lookup that fails
|
|
199
|
-
* still asks the trusted key, so a hit there is not lost; its error is
|
|
200
|
-
* rethrown (and warned) when the trusted key fails or has nothing too.
|
|
201
|
-
*/
|
|
202
|
-
private async fallThrough<T>(own: () => Promise<T>, trusted: () => Promise<T>): Promise<T> {
|
|
203
|
-
let found: T
|
|
204
|
-
try {
|
|
205
|
-
found = await own()
|
|
206
|
-
} catch (err) {
|
|
207
|
-
const fallback = await trusted().catch(() => undefined)
|
|
208
|
-
if (!fallback) throw err
|
|
209
|
-
return fallback
|
|
210
|
-
}
|
|
211
|
-
return found || trusted()
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
215
|
-
const scoped = hashes.map((h) => this.scoped(h))
|
|
216
|
-
const [trusted, own] = await Promise.all([
|
|
217
|
-
this.remote.hasMany!(hashes),
|
|
218
|
-
this.remote.hasMany!(scoped),
|
|
219
|
-
])
|
|
220
|
-
if (trusted == null || own == null) return null
|
|
221
|
-
const found = new Set(trusted)
|
|
222
|
-
for (let i = 0; i < hashes.length; i++) {
|
|
223
|
-
if (own.has(scoped[i]!)) found.add(hashes[i]!)
|
|
224
|
-
else if (trusted.has(hashes[i]!)) this.trustedOnly.add(hashes[i]!)
|
|
225
|
-
}
|
|
226
|
-
return found
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async get(
|
|
230
|
-
hash: string,
|
|
231
|
-
): Promise<{ body: Blob | Response; durationMs: number | undefined } | null> {
|
|
232
|
-
if (this.trustedOnly.has(hash)) return this.remote.get(hash)
|
|
233
|
-
return this.fallThrough(
|
|
234
|
-
() => this.remote.get(this.scoped(hash)),
|
|
235
|
-
() => this.remote.get(hash),
|
|
236
|
-
)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
|
|
240
|
-
return this.remote.put(this.scoped(hash), body, meta)
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** The key an untrusted scope stores `hash` under: task-key shaped, 16 hex. */
|
|
245
|
-
function scopedKey(scope: string, hash: string): string {
|
|
246
|
-
return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
168
|
export interface LayeredCacheOptions {
|
|
250
169
|
/**
|
|
251
170
|
* Called for the remote failures the layer degrades to a miss, once per
|
|
@@ -312,17 +231,12 @@ export class LayeredCache implements CacheLayer {
|
|
|
312
231
|
*/
|
|
313
232
|
private readonly reported = new Map<string, { cause: string; repeats: number }>()
|
|
314
233
|
|
|
315
|
-
/** The remote as this run's scope sees it: `remote` itself when trusted. */
|
|
316
|
-
private readonly wire: RemoteCacheLayer
|
|
317
|
-
|
|
318
234
|
constructor(
|
|
319
235
|
readonly local: Cache,
|
|
320
236
|
private readonly remote: RemoteCacheLayer,
|
|
321
237
|
private readonly options: LayeredCacheOptions = {},
|
|
322
238
|
) {
|
|
323
239
|
this.policy = options.policy ?? FULL_CACHE_POLICY
|
|
324
|
-
const scope = this.policy.remoteScope
|
|
325
|
-
this.wire = scope === undefined ? remote : new ScopedRemote(remote, scope)
|
|
326
240
|
this.endpoint = printableEndpoint(remote.endpoint)
|
|
327
241
|
}
|
|
328
242
|
|
|
@@ -349,11 +263,11 @@ export class LayeredCache implements CacheLayer {
|
|
|
349
263
|
* degrades to "no batch info" and the caller falls back to per-hash.
|
|
350
264
|
*/
|
|
351
265
|
async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
352
|
-
if (!this.policy.remoteRead || this.
|
|
266
|
+
if (!this.policy.remoteRead || this.remote.hasMany === undefined) return null
|
|
353
267
|
try {
|
|
354
268
|
// `return await`, deliberately: the catch below is the never-fail
|
|
355
269
|
// contract, and a returned promise's rejection would sail past it.
|
|
356
|
-
const found: unknown = await this.
|
|
270
|
+
const found: unknown = await this.remote.hasMany(hashes)
|
|
357
271
|
if (found !== null && found !== undefined && !(found instanceof Set)) {
|
|
358
272
|
this.reportRemoteError(
|
|
359
273
|
'probe',
|
|
@@ -394,7 +308,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
394
308
|
return this.asRemote(hash, localHit)
|
|
395
309
|
}
|
|
396
310
|
|
|
397
|
-
// Remote reads disabled (e.g. `--cache=remote:`): a local miss is a
|
|
311
|
+
// Remote reads disabled (e.g. `--cache-policy=remote:`): a local miss is a
|
|
398
312
|
// real miss; never touch the remote layer.
|
|
399
313
|
if (!this.policy.remoteRead) return null
|
|
400
314
|
|
|
@@ -410,7 +324,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
410
324
|
// the artifact + index row this pull just wrote exist regardless of
|
|
411
325
|
// policy, and the gate ("don't serve hits from the pre-existing local
|
|
412
326
|
// cache") must not discard them. Going through the gated `get` here
|
|
413
|
-
// made `--cache=local:,remote:rw` download the artifact, throw the hit
|
|
327
|
+
// made `--cache-policy=local:,remote:rw` download the artifact, throw the hit
|
|
414
328
|
// away, re-execute and re-upload — on every run, forever.
|
|
415
329
|
const materialized = await this.local.getIngested(hash)
|
|
416
330
|
if (!materialized) return null
|
|
@@ -437,7 +351,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
437
351
|
if ((await this.local.has(hash)) === 'local') return 'local'
|
|
438
352
|
if (!this.policy.remoteRead) return null
|
|
439
353
|
try {
|
|
440
|
-
return (await this.
|
|
354
|
+
return (await this.remote.has(hash)) ? 'remote' : null
|
|
441
355
|
} catch (err) {
|
|
442
356
|
this.reportRemoteError('probe', hash, err)
|
|
443
357
|
return null
|
|
@@ -474,7 +388,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
474
388
|
const fetchStart = performance.now()
|
|
475
389
|
let remoteResult: unknown
|
|
476
390
|
try {
|
|
477
|
-
remoteResult = await this.
|
|
391
|
+
remoteResult = await this.remote.get(hash)
|
|
478
392
|
} catch (err) {
|
|
479
393
|
this.reportRemoteError('download', hash, err)
|
|
480
394
|
return false
|
|
@@ -560,11 +474,11 @@ export class LayeredCache implements CacheLayer {
|
|
|
560
474
|
// queued job holds a path, not a buffer, a plugin that streams the Blob
|
|
561
475
|
// never holds it whole, and every read of it sees the same bytes though
|
|
562
476
|
// a re-save of the key renames others over the live name. If a
|
|
563
|
-
// concurrent `vx cache
|
|
477
|
+
// concurrent `vx cache clean` removed the artifact first the pin throws
|
|
564
478
|
// and this upload is skipped — the never-fail contract.
|
|
565
479
|
const hash = args.hash
|
|
566
480
|
const durationMs = args.entry.durationMs
|
|
567
|
-
// Local writes disabled (`--cache=local:,remote:rw`): there is no
|
|
481
|
+
// Local writes disabled (`--cache-policy=local:,remote:rw`): there is no
|
|
568
482
|
// on-disk artifact to read later, so the bytes must be packed NOW,
|
|
569
483
|
// while this task's output files are still on disk. Deferring THIS
|
|
570
484
|
// read would pack whatever the tree happens to hold when the job
|
|
@@ -587,7 +501,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
587
501
|
let body: Blob
|
|
588
502
|
if (packed !== undefined) body = new Blob([packed])
|
|
589
503
|
else ({ body, release } = this.local.pinArtifact(hash))
|
|
590
|
-
await this.
|
|
504
|
+
await this.remote.put(hash, body, { durationMs })
|
|
591
505
|
tally.count++
|
|
592
506
|
tally.bytes += body.size
|
|
593
507
|
tally.ms += Math.round(performance.now() - start)
|