@vzn/vx 0.0.640 → 0.0.641
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/caching.md +47 -23
- package/docs/cli.md +10 -5
- package/docs/execution.md +7 -6
- package/docs/features.md +1 -1
- package/docs/schema.md +19 -12
- package/docs/security.md +4 -3
- package/launcher.sh +9 -2
- package/package.json +5 -5
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +248 -41
- package/src/cache/cache.ts +42 -18
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +81 -35
- package/src/cache/layer.ts +7 -1
- package/src/cache/layered-cache.ts +8 -7
- package/src/cache/scoped-cache.ts +8 -2
- package/src/cli/help.ts +7 -1
- package/src/cli/upgrade.ts +37 -2
- package/src/cli/watch-filter.ts +1 -6
- package/src/cli/watch-fs.ts +2 -1
- package/src/handoff.ts +5 -0
- package/src/index.ts +1 -1
- package/src/orchestrator/hit-restore.ts +101 -27
- package/src/orchestrator/local-shortcircuit.ts +8 -0
- package/src/orchestrator/plugin-host.ts +4 -5
- package/src/orchestrator/run-report.ts +30 -9
- package/src/orchestrator/run.ts +25 -23
- package/src/report/logger.ts +5 -2
- package/src/report/summary.ts +6 -4
- package/src/util/index.ts +3 -0
- package/src/util/paths.ts +38 -4
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/project-loader.ts +5 -4
package/src/cache/inputs.ts
CHANGED
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
isInstalledPath,
|
|
28
28
|
isLiteralPattern,
|
|
29
29
|
normalizeGlob,
|
|
30
|
+
namesNodeModules,
|
|
31
|
+
OUTPUT_NEVER,
|
|
30
32
|
outputMatcher,
|
|
31
33
|
realpathOf,
|
|
32
34
|
relPosix,
|
|
@@ -89,21 +91,8 @@ const ALWAYS_IGNORE = [
|
|
|
89
91
|
// must not select every `**` reader under --affected.
|
|
90
92
|
'**/.vx/.gitattributes',
|
|
91
93
|
'**/.vx/.gitignore',
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
// broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
|
|
95
|
-
// file that a CONCURRENT compile is mid-write, racing to EACCES/ENOENT. Always
|
|
96
|
-
// exclude it (vx is Bun-native; compiling standalone binaries is a common task).
|
|
97
|
-
'**/*.bun-build',
|
|
98
|
-
// …and, cross-compiling for a target its cache lacks, the directory it
|
|
99
|
-
// extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
|
|
100
|
-
// 60 MB mid-write under the same race (2026-09-29).
|
|
101
|
-
'**/.????????????????-????????.tmp/**',
|
|
102
|
-
// vx's own restore temp (archive.ts), live only while an artifact lands
|
|
103
|
-
// beside its target. A key taken meanwhile listed it, the rename removed
|
|
104
|
-
// it, and every task reading that directory was withheld its save
|
|
105
|
-
// (withastro/astro: 25 of 33 builds re-ran on a restore, X-209).
|
|
106
|
-
'**/.vx-tmp-*',
|
|
94
|
+
// Where restores stage (archive.ts): vx's own, never an input.
|
|
95
|
+
'**/.vx/tmp/**',
|
|
107
96
|
]
|
|
108
97
|
|
|
109
98
|
const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
|
|
@@ -677,17 +666,6 @@ async function resolveRuntimeValues(
|
|
|
677
666
|
)
|
|
678
667
|
}
|
|
679
668
|
|
|
680
|
-
/**
|
|
681
|
-
* What no output glob reaches. An output is otherwise taken as written —
|
|
682
|
-
* `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
|
|
683
|
-
* apply here — but no task produces these, and a root project declaring
|
|
684
|
-
* `**` would otherwise wipe its repository and the cache it is restoring
|
|
685
|
-
* from (2026-09-16). `.vx` whole and each `vx.lock.json` are vx's own: as
|
|
686
|
-
* outputs, the clean deleted an uncommitted workspace config and a hit
|
|
687
|
-
* restored a lock or ignore file the key does not see over the current one.
|
|
688
|
-
*/
|
|
689
|
-
const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
|
|
690
|
-
|
|
691
669
|
/**
|
|
692
670
|
* What a set of output globs may not reach: `OUTPUT_NEVER`, and every
|
|
693
671
|
* `node_modules` unless a glob names one. `**\/*.js` meant the build's
|
|
@@ -697,8 +675,9 @@ const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
|
|
|
697
675
|
* even `.git`.
|
|
698
676
|
*/
|
|
699
677
|
function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
|
|
700
|
-
|
|
701
|
-
|
|
678
|
+
return (namesNodeModules(outputs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(
|
|
679
|
+
globFor,
|
|
680
|
+
)
|
|
702
681
|
}
|
|
703
682
|
|
|
704
683
|
/**
|
|
@@ -877,6 +856,7 @@ export async function cleanOutputs(args: {
|
|
|
877
856
|
resolved?: readonly string[] | undefined
|
|
878
857
|
}): Promise<string[]> {
|
|
879
858
|
const committed = args.committed ?? committedFiles(undefined, args.projectDir)
|
|
859
|
+
sweepRestoreTemps(args.projectDir, args.outputs)
|
|
880
860
|
const matched = args.resolved ?? (await resolveOutputs(args))
|
|
881
861
|
// `force: true` makes rm tolerate ENOENT (e.g. when two output
|
|
882
862
|
// globs overlap and a sibling already deleted a path mid-iteration).
|
|
@@ -892,6 +872,70 @@ export async function cleanOutputs(args: {
|
|
|
892
872
|
return matched.map((f) => relPosix(args.projectDir, f))
|
|
893
873
|
}
|
|
894
874
|
|
|
875
|
+
/**
|
|
876
|
+
* Remove the restore temps (`.vx-tmp-<pid>-<n>`, archive.ts) a killed run
|
|
877
|
+
* left beside an exact-file output. A restore stages in `.vx/tmp/` and
|
|
878
|
+
* writes one beside its target only where the target is on another
|
|
879
|
+
* filesystem (EXDEV); a glob over the directory takes it with the outputs,
|
|
880
|
+
* but `out/a.txt` reaches no sibling, and a partial temp stayed in `out/`
|
|
881
|
+
* through every later run (Growth's SIGKILL probe, 2026-10-16). A temp
|
|
882
|
+
* whose writer is alive, this process included, is left: it may be
|
|
883
|
+
* mid-restore.
|
|
884
|
+
*/
|
|
885
|
+
export function sweepRestoreTemps(base: string, outputs: readonly string[]): void {
|
|
886
|
+
const dirs = new Set<string>()
|
|
887
|
+
for (const g of outputs) {
|
|
888
|
+
if (g.startsWith('!') || !isLiteralPattern(g)) continue
|
|
889
|
+
dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
|
|
890
|
+
}
|
|
891
|
+
for (const dir of dirs) {
|
|
892
|
+
let names: string[]
|
|
893
|
+
try {
|
|
894
|
+
names = readdirSync(dir)
|
|
895
|
+
} catch {
|
|
896
|
+
continue
|
|
897
|
+
}
|
|
898
|
+
for (const name of names) {
|
|
899
|
+
const m = /^\.vx-tmp-([0-9a-z]+)-[0-9a-z]+$/.exec(name)
|
|
900
|
+
if (m === null) continue
|
|
901
|
+
const pid = Number.parseInt(m[1]!, 36)
|
|
902
|
+
if (pid === process.pid || writerAlive(pid)) continue
|
|
903
|
+
rmSync(path.join(dir, name), { force: true })
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Empty `.vx/tmp/` (archive.ts `STAGING`) of the staging directories
|
|
910
|
+
* (`<pid>-<n>`) a killed run left, once per run. One whose writer is alive,
|
|
911
|
+
* another run's or this one's, is left: it may be mid-restore.
|
|
912
|
+
*/
|
|
913
|
+
export function sweepStaging(workspaceRoot: string): void {
|
|
914
|
+
const top = path.join(workspaceRoot, '.vx', 'tmp')
|
|
915
|
+
let names: string[]
|
|
916
|
+
try {
|
|
917
|
+
names = readdirSync(top)
|
|
918
|
+
} catch {
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
for (const name of names) {
|
|
922
|
+
const m = /^([0-9a-z]+)-[0-9a-z]+$/.exec(name)
|
|
923
|
+
if (m === null) continue
|
|
924
|
+
const pid = Number.parseInt(m[1]!, 36)
|
|
925
|
+
if (pid === process.pid || writerAlive(pid)) continue
|
|
926
|
+
rmSync(path.join(top, name), { recursive: true, force: true })
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function writerAlive(pid: number): boolean {
|
|
931
|
+
try {
|
|
932
|
+
process.kill(pid, 0)
|
|
933
|
+
return true
|
|
934
|
+
} catch (err) {
|
|
935
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
895
939
|
/**
|
|
896
940
|
* Remove exactly these project-relative paths — the recorded rows of an
|
|
897
941
|
* ADDITIVE task's own artifact (item 588), never a glob: the glob would
|
|
@@ -1221,6 +1265,7 @@ export async function cleanWorkspaceOutputs(args: {
|
|
|
1221
1265
|
resolved?: readonly string[] | undefined
|
|
1222
1266
|
}): Promise<string[]> {
|
|
1223
1267
|
const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
|
|
1268
|
+
sweepRestoreTemps(args.workspaceRoot, args.outputs)
|
|
1224
1269
|
const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
|
|
1225
1270
|
await pruneEmptiedDirs(
|
|
1226
1271
|
args.workspaceRoot,
|
|
@@ -1324,10 +1369,12 @@ function outright(reach: { prefixes: readonly string[]; literals: readonly strin
|
|
|
1324
1369
|
/**
|
|
1325
1370
|
* The files on disk under the {@link ignoredReach} entries that `accept`
|
|
1326
1371
|
* keeps, sorted and absolute. A file is hashed from disk like any untracked
|
|
1327
|
-
* one; nothing here is stored or restored.
|
|
1328
|
-
*
|
|
1329
|
-
*
|
|
1330
|
-
*
|
|
1372
|
+
* one; nothing here is stored or restored. `node_modules` is no special
|
|
1373
|
+
* case (owner, 2026-10-16): an ignored path named outright is an input,
|
|
1374
|
+
* a generated client (`node_modules/.prisma/client/**`) too; skipping
|
|
1375
|
+
* installs here folded such an entry to nothing, silently. lstat, not
|
|
1376
|
+
* `exists()`: that answers false for a directory. Never memoized: a
|
|
1377
|
+
* dependency writes these mid-run.
|
|
1331
1378
|
*/
|
|
1332
1379
|
function ignoredInputs(
|
|
1333
1380
|
root: string,
|
|
@@ -1340,8 +1387,7 @@ function ignoredInputs(
|
|
|
1340
1387
|
const st = lstatSync(abs, { throwIfNoEntry: false })
|
|
1341
1388
|
if (st === undefined) continue
|
|
1342
1389
|
if (!st.isDirectory()) {
|
|
1343
|
-
if ((st.isFile() || st.isSymbolicLink()) &&
|
|
1344
|
-
found.add(abs)
|
|
1390
|
+
if ((st.isFile() || st.isSymbolicLink()) && accept(entry)) found.add(abs)
|
|
1345
1391
|
continue
|
|
1346
1392
|
}
|
|
1347
1393
|
for (const rel of WALK.scanSync({
|
|
@@ -1351,7 +1397,7 @@ function ignoredInputs(
|
|
|
1351
1397
|
dot: true,
|
|
1352
1398
|
})) {
|
|
1353
1399
|
const full = `${entry}/${rel.split(path.sep).join('/')}`
|
|
1354
|
-
if (
|
|
1400
|
+
if (!accept(full)) continue
|
|
1355
1401
|
const file = path.join(abs, rel)
|
|
1356
1402
|
if (isInputOnDisk(file)) found.add(file)
|
|
1357
1403
|
}
|
package/src/cache/layer.ts
CHANGED
|
@@ -677,7 +677,13 @@ export interface CacheLayer {
|
|
|
677
677
|
* into the workspace root. Callers restoring entries that may carry
|
|
678
678
|
* workspace outputs must pass `workspaceRoot`.
|
|
679
679
|
*/
|
|
680
|
-
restoreOutputs(
|
|
680
|
+
restoreOutputs(
|
|
681
|
+
hash: string,
|
|
682
|
+
projectDir: string,
|
|
683
|
+
workspaceRoot?: string,
|
|
684
|
+
/** The task's declared outputs: an entry outside them fails the restore, nothing written. */
|
|
685
|
+
outputs?: DeclaredOutputs,
|
|
686
|
+
): Promise<void>
|
|
681
687
|
save(args: {
|
|
682
688
|
hash: string
|
|
683
689
|
/**
|
|
@@ -21,6 +21,7 @@ import type { Cache } from './cache.js'
|
|
|
21
21
|
import type {
|
|
22
22
|
CacheEntry,
|
|
23
23
|
CacheGetContext,
|
|
24
|
+
DeclaredOutputs,
|
|
24
25
|
CacheKeyInput,
|
|
25
26
|
CacheLayer,
|
|
26
27
|
CacheStats,
|
|
@@ -61,11 +62,6 @@ export interface RemoteCacheLayer {
|
|
|
61
62
|
* dropped before it is printed.
|
|
62
63
|
*/
|
|
63
64
|
readonly endpoint?: string
|
|
64
|
-
/**
|
|
65
|
-
* What the wire's credential allows, when less than both (a read-only
|
|
66
|
-
* token): core clears the run's remote axis it lacks. Omitted → both.
|
|
67
|
-
*/
|
|
68
|
-
readonly access?: { readonly read?: boolean; readonly write?: boolean }
|
|
69
65
|
/** Existence probe: `--dry` and `--force` ask it per key when the remote cannot batch. */
|
|
70
66
|
has(hash: string): Promise<boolean>
|
|
71
67
|
/**
|
|
@@ -330,8 +326,13 @@ export class LayeredCache implements CacheLayer {
|
|
|
330
326
|
return this.local.hashFile(filePath)
|
|
331
327
|
}
|
|
332
328
|
|
|
333
|
-
async restoreOutputs(
|
|
334
|
-
|
|
329
|
+
async restoreOutputs(
|
|
330
|
+
hash: string,
|
|
331
|
+
projectDir: string,
|
|
332
|
+
workspaceRoot?: string,
|
|
333
|
+
outputs?: DeclaredOutputs,
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
await this.local.restoreOutputs(hash, projectDir, workspaceRoot, outputs)
|
|
335
336
|
}
|
|
336
337
|
|
|
337
338
|
async save(args: SaveArgs): Promise<void> {
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
CacheEntry,
|
|
9
9
|
CacheGetContext,
|
|
10
10
|
CacheLayer,
|
|
11
|
+
DeclaredOutputs,
|
|
11
12
|
IngestMeta,
|
|
12
13
|
InvocationRecord,
|
|
13
14
|
OutputDirRow,
|
|
@@ -194,8 +195,13 @@ export class ScopedCache implements CacheLayer {
|
|
|
194
195
|
return (await this.inner.outputDirsCurrent?.(projectDir, rows)) ?? false
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
restoreOutputs(
|
|
198
|
-
|
|
198
|
+
restoreOutputs(
|
|
199
|
+
hash: string,
|
|
200
|
+
projectDir: string,
|
|
201
|
+
workspaceRoot?: string,
|
|
202
|
+
outputs?: DeclaredOutputs,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot, outputs)
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
save(args: SaveArgs): Promise<void> {
|
package/src/cli/help.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
117
117
|
' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
|
|
118
118
|
' vx lock [--check] [--format pretty|json]',
|
|
119
119
|
' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
|
|
120
|
-
' vx upgrade [TAG]',
|
|
120
|
+
' vx upgrade [TAG] [-g|--global]',
|
|
121
121
|
' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
|
|
122
122
|
' vx info [--format pretty|json] [--cache-dir <path>]',
|
|
123
123
|
' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
|
|
@@ -274,6 +274,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
274
274
|
' test (executor, cache, telemetry, schedule, admit, commands,',
|
|
275
275
|
' project, graph, key).',
|
|
276
276
|
'',
|
|
277
|
+
'Upgrade:',
|
|
278
|
+
' vx upgrade [TAG] Bring vx to TAG (default: latest) the way it was installed.',
|
|
279
|
+
" In a project: the project's vx, its @vzn/vx-* plugins and",
|
|
280
|
+
' the global vx that ran it, to one release.',
|
|
281
|
+
' -g, --global Only the global vx: no project, no plugin.',
|
|
282
|
+
'',
|
|
277
283
|
'Shell completions:',
|
|
278
284
|
' vx completions <shell> Print a completion script for bash, zsh or fish: the',
|
|
279
285
|
' verbs (plugin verbs of this workspace included) and',
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -712,6 +712,31 @@ export async function upgradeGlobal(
|
|
|
712
712
|
})
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
+
/**
|
|
716
|
+
* `vx upgrade -g`: bring this vx, the global one, to `version` and nothing
|
|
717
|
+
* else (owner, 2026-10-16) — no project's package.json, no plugin. The
|
|
718
|
+
* project's own vx refuses: no global ran.
|
|
719
|
+
*/
|
|
720
|
+
export async function upgradeOnlyGlobal(
|
|
721
|
+
self: string,
|
|
722
|
+
version: string,
|
|
723
|
+
current = VERSION,
|
|
724
|
+
): Promise<void> {
|
|
725
|
+
if (current === version) {
|
|
726
|
+
ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
// Read from the binary's own directory, not cwd: a project there is not this install's.
|
|
730
|
+
const install = managedInstall(self, path.dirname(self))
|
|
731
|
+
if (install?.scope === 'project') {
|
|
732
|
+
throw new UserError(
|
|
733
|
+
`vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (npm install -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
|
|
734
|
+
)
|
|
735
|
+
}
|
|
736
|
+
if (install === null) await upgradeBinary(self, version, current, 'global ')
|
|
737
|
+
else await upgradeManaged(install, version, current, 'global ')
|
|
738
|
+
}
|
|
739
|
+
|
|
715
740
|
/** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
|
|
716
741
|
async function named(what: string, run: () => Promise<void>): Promise<void> {
|
|
717
742
|
try {
|
|
@@ -722,7 +747,9 @@ async function named(what: string, run: () => Promise<void>): Promise<void> {
|
|
|
722
747
|
}
|
|
723
748
|
}
|
|
724
749
|
|
|
725
|
-
export async function upgradeCmd(
|
|
750
|
+
export async function upgradeCmd(argv: readonly string[]): Promise<number> {
|
|
751
|
+
const global = argv.some((a) => a === '-g' || a === '--global')
|
|
752
|
+
const args = argv.filter((a) => a !== '-g' && a !== '--global')
|
|
726
753
|
const unknown = args.find((a) => a.startsWith('-'))
|
|
727
754
|
if (unknown !== undefined) {
|
|
728
755
|
sayNotice(
|
|
@@ -746,10 +773,18 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
746
773
|
)
|
|
747
774
|
}
|
|
748
775
|
const dest = process.execPath
|
|
776
|
+
const handedOffFrom = process.env['VX_HANDED_OFF']
|
|
777
|
+
if (global) {
|
|
778
|
+
// A launcher from before -g handed the call over: the global is the one that did.
|
|
779
|
+
const version = await targetVersion(tag)
|
|
780
|
+
if (handedOffFrom !== undefined) await upgradeGlobal(handedOffFrom, version)
|
|
781
|
+
else await upgradeOnlyGlobal(dest, version)
|
|
782
|
+
return 0
|
|
783
|
+
}
|
|
749
784
|
const install = managedInstall(dest, process.cwd())
|
|
750
785
|
// A global vx handed this run to the project's own: both move, to one
|
|
751
786
|
// version, the project first (owner, 2026-10-15).
|
|
752
|
-
const from =
|
|
787
|
+
const from = handedOffFrom
|
|
753
788
|
if (from !== undefined) {
|
|
754
789
|
const version = await targetVersion(tag)
|
|
755
790
|
await named('project vx', () =>
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -33,13 +33,8 @@ export function isIgnoredWatchPath(rel: string): boolean {
|
|
|
33
33
|
const segments = rel.split(path.sep)
|
|
34
34
|
for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
|
|
35
35
|
if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
|
|
36
|
-
const name = segments[segments.length - 1]!
|
|
37
|
-
// A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
|
|
38
|
-
// renamed away before the judgement, it read as a deletion and ran one
|
|
39
|
-
// more cycle where an uncached task makes ignored paths count.
|
|
40
|
-
if (name.startsWith('.vx-tmp-')) return true
|
|
41
36
|
// `vx lock`'s review copy: no input, so a re-lock is no cycle.
|
|
42
|
-
return
|
|
37
|
+
return segments[segments.length - 1] === 'vx.lock.json'
|
|
43
38
|
}
|
|
44
39
|
|
|
45
40
|
/**
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -16,7 +16,8 @@ export const IGNORED_SEGMENTS = ['node_modules', '.git']
|
|
|
16
16
|
export function isIgnoredDir(segments: readonly string[]): boolean {
|
|
17
17
|
const last = segments.length - 1
|
|
18
18
|
if (IGNORED_SEGMENTS.includes(segments[last]!)) return true
|
|
19
|
-
|
|
19
|
+
// vx's cache, and where its restores stage (archive.ts).
|
|
20
|
+
return (segments[last] === 'cache' || segments[last] === 'tmp') && segments[last - 1] === '.vx'
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/**
|
package/src/handoff.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
// walk runs before anything else loads, through `Bun.file(…).exists()`:
|
|
6
6
|
// importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
|
|
7
7
|
|
|
8
|
+
/** `vx upgrade -g`: moves only the global vx, so it never hands off (owner, 2026-10-16). */
|
|
9
|
+
export function upgradesOnlyItself(argv: readonly string[]): boolean {
|
|
10
|
+
return argv[0] === 'upgrade' && argv.some((a) => a === '-g' || a === '--global')
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
/**
|
|
9
14
|
* The project-local vx to run instead of this one, or null: the workspace
|
|
10
15
|
* root's ({@link workspaceFile}) `node_modules/@vzn/vx`, run through its
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ export { machineMemoryBytes, machineParallelism } from './util/index.js'
|
|
|
22
22
|
// How core reads a task glob — its spelling, and which characters are
|
|
23
23
|
// wildcards (`[` `]` are not; item 667) — for a plugin that turns declared
|
|
24
24
|
// outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
|
|
25
|
-
export { isLiteralPattern, normalizeGlob } from './util/index.js'
|
|
25
|
+
export { isLiteralPattern, normalizeGlob, restorableOutput } from './util/index.js'
|
|
26
26
|
|
|
27
27
|
// Schema types and helpers (used by user vx.config files and presets).
|
|
28
28
|
export type {
|
|
@@ -12,11 +12,10 @@ import type { CacheConfig } from '../config.js'
|
|
|
12
12
|
import {
|
|
13
13
|
type CacheEntry,
|
|
14
14
|
cleanOutputPaths,
|
|
15
|
-
cleanOutputs,
|
|
16
|
-
cleanWorkspaceOutputs,
|
|
17
15
|
committedFiles,
|
|
18
16
|
resolveOutputs,
|
|
19
17
|
resolveWorkspaceOutputs,
|
|
18
|
+
sweepRestoreTemps,
|
|
20
19
|
WORKSPACE_OUTPUT_PREFIX,
|
|
21
20
|
} from '../cache/index.js'
|
|
22
21
|
import type { TaskOutcome } from '../graph/index.js'
|
|
@@ -232,34 +231,74 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
232
231
|
}
|
|
233
232
|
}
|
|
234
233
|
if (!skipRestore) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
234
|
+
const heldRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
235
|
+
const heldWsRels = hit.outputFiles
|
|
236
|
+
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
237
|
+
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
238
|
+
// In place, never clean-then-restore: each file is renamed over the one
|
|
239
|
+
// it replaces, so a reader of the tree never finds a declared output
|
|
240
|
+
// missing, and a restore that fails before its renames (a full disk,
|
|
241
|
+
// a damaged entry) leaves the outputs it found as they were. Only what blocks a rename
|
|
242
|
+
// goes first; what the entry does not hold goes after. The clean used
|
|
243
|
+
// to run first: a reader polling a 64 MB output while ten hits restored
|
|
244
|
+
// met ENOENT on 153,336 of 204,411 reads (2026-10-16). An ADDITIVE task
|
|
245
|
+
// owns only its rows (item 588), and the restore rewrites exactly those.
|
|
246
|
+
let foundRels: string[] = []
|
|
247
|
+
let foundWsRels: string[] = []
|
|
248
|
+
let staleRels: string[] = []
|
|
249
|
+
let staleWsRels: string[] = []
|
|
250
|
+
if (!additive) {
|
|
251
|
+
sweepRestoreTemps(node.projectDir, outputs)
|
|
252
|
+
sweepRestoreTemps(args.workspaceRoot, wsOutputs)
|
|
253
|
+
if (outputs.length > 0) {
|
|
254
|
+
const abs = walked?.proj ?? (await resolveOutputs(cleanArgs))
|
|
255
|
+
foundRels = abs.map((f) => relPosix(node.projectDir, f))
|
|
256
|
+
const blocking = inTheWay(foundRels, heldRels)
|
|
257
|
+
staleRels = notHeld(foundRels, heldRels).filter((f) => !blocking.has(f))
|
|
258
|
+
if (blocking.size > 0) {
|
|
259
|
+
await cleanOutputPaths({
|
|
260
|
+
projectDir: node.projectDir,
|
|
261
|
+
rels: [...blocking],
|
|
262
|
+
outputs,
|
|
263
|
+
committed: cleanArgs.committed,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (wsOutputs.length > 0) {
|
|
268
|
+
const abs = walked?.ws ?? (await resolveWorkspaceOutputs(wsCleanArgs))
|
|
269
|
+
foundWsRels = abs.map((f) => relPosix(args.workspaceRoot, f))
|
|
270
|
+
const blocking = inTheWay(foundWsRels, heldWsRels)
|
|
271
|
+
staleWsRels = notHeld(foundWsRels, heldWsRels).filter((f) => !blocking.has(f))
|
|
272
|
+
if (blocking.size > 0) {
|
|
273
|
+
await cleanOutputPaths({
|
|
274
|
+
projectDir: args.workspaceRoot,
|
|
275
|
+
rels: [...blocking],
|
|
276
|
+
outputs: wsOutputs,
|
|
277
|
+
committed: wsCleanArgs.committed,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot, {
|
|
283
|
+
files: outputs,
|
|
284
|
+
workspaceFiles: wsOutputs,
|
|
285
|
+
})
|
|
286
|
+
if (staleRels.length > 0) {
|
|
239
287
|
await cleanOutputPaths({
|
|
240
288
|
projectDir: node.projectDir,
|
|
241
|
-
rels:
|
|
289
|
+
rels: staleRels,
|
|
242
290
|
outputs,
|
|
243
291
|
committed: cleanArgs.committed,
|
|
244
292
|
})
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
293
|
+
}
|
|
294
|
+
if (staleWsRels.length > 0) {
|
|
248
295
|
await cleanOutputPaths({
|
|
249
296
|
projectDir: args.workspaceRoot,
|
|
250
|
-
rels:
|
|
297
|
+
rels: staleWsRels,
|
|
251
298
|
outputs: wsOutputs,
|
|
252
299
|
committed: wsCleanArgs.committed,
|
|
253
300
|
})
|
|
254
|
-
} else {
|
|
255
|
-
if (outputs.length > 0) {
|
|
256
|
-
cleanedRels = await cleanOutputs({ ...cleanArgs, resolved: walked?.proj })
|
|
257
|
-
}
|
|
258
|
-
if (wsOutputs.length > 0) {
|
|
259
|
-
cleanedWsRels = await cleanWorkspaceOutputs({ ...wsCleanArgs, resolved: walked?.ws })
|
|
260
|
-
}
|
|
261
301
|
}
|
|
262
|
-
await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
|
|
263
302
|
// The tree now holds this entry's bytes: stamp them for the next hit
|
|
264
303
|
// (item 886).
|
|
265
304
|
args.cache.recordOutputStamps?.(hash, node.projectDir, args.workspaceRoot)
|
|
@@ -288,17 +327,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
288
327
|
// unconditional drop (an executed task may write undeclared
|
|
289
328
|
// files only git can see).
|
|
290
329
|
if (outputs.length > 0) {
|
|
291
|
-
args.gitFilesCache?.markOutputsChanged(node.projectDir, [
|
|
292
|
-
...cleanedRels,
|
|
293
|
-
...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
|
|
294
|
-
])
|
|
330
|
+
args.gitFilesCache?.markOutputsChanged(node.projectDir, [...foundRels, ...heldRels])
|
|
295
331
|
}
|
|
296
332
|
if (wsOutputs.length > 0) {
|
|
297
333
|
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
|
|
298
|
-
...
|
|
299
|
-
...
|
|
300
|
-
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
301
|
-
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
|
|
334
|
+
...foundWsRels,
|
|
335
|
+
...heldWsRels,
|
|
302
336
|
])
|
|
303
337
|
}
|
|
304
338
|
}
|
|
@@ -361,3 +395,43 @@ function dirPrefixesOf(outputs: readonly string[]): readonly string[] | null {
|
|
|
361
395
|
}
|
|
362
396
|
|
|
363
397
|
const prefixMemo = new Map<string, readonly string[] | null>()
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* On a case-insensitive volume (macOS's default) a rename onto `out.txt`
|
|
401
|
+
* replaces `Out.txt`: the two are one file, and removing the found name
|
|
402
|
+
* after the restore would remove the restored one.
|
|
403
|
+
*/
|
|
404
|
+
const fold: (rel: string) => string =
|
|
405
|
+
process.platform === 'darwin' ? (rel) => rel.normalize('NFC').toLowerCase() : (rel) => rel
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The found outputs a rename cannot replace: a file where the entry needs a
|
|
409
|
+
* directory (`dist/out` for `dist/out/a.js`) and a file under a path the
|
|
410
|
+
* entry holds as a file (`dist/out/a.js` for `dist/out`), whose directory
|
|
411
|
+
* the prune then takes.
|
|
412
|
+
*/
|
|
413
|
+
function inTheWay(found: readonly string[], held: readonly string[]): Set<string> {
|
|
414
|
+
if (found.length === 0 || held.length === 0) return new Set()
|
|
415
|
+
const files = new Set<string>()
|
|
416
|
+
const dirs = new Set<string>()
|
|
417
|
+
for (const h of held) {
|
|
418
|
+
const key = fold(h)
|
|
419
|
+
files.add(key)
|
|
420
|
+
for (let i = key.indexOf('/'); i !== -1; i = key.indexOf('/', i + 1)) dirs.add(key.slice(0, i))
|
|
421
|
+
}
|
|
422
|
+
const out = new Set<string>()
|
|
423
|
+
for (const f of found) {
|
|
424
|
+
const key = fold(f)
|
|
425
|
+
if (dirs.has(key)) out.add(f)
|
|
426
|
+
for (let i = key.indexOf('/'); i !== -1; i = key.indexOf('/', i + 1)) {
|
|
427
|
+
if (files.has(key.slice(0, i))) out.add(f)
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return out
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The found outputs the entry does not hold: the stale ones, once it has restored. */
|
|
434
|
+
function notHeld(found: readonly string[], held: readonly string[]): string[] {
|
|
435
|
+
const keep = new Set(held.map(fold))
|
|
436
|
+
return found.filter((f) => !keep.has(fold(f)))
|
|
437
|
+
}
|
|
@@ -60,6 +60,12 @@ export interface ShortCircuitArgs {
|
|
|
60
60
|
hashCache: HashCache
|
|
61
61
|
/** Cap on concurrent probes — the run's concurrency. */
|
|
62
62
|
concurrency: number
|
|
63
|
+
/**
|
|
64
|
+
* `cache` is the local store in front of a remote: a hit is certain, a
|
|
65
|
+
* miss is not (the remote may hold it), so only hits are recorded and
|
|
66
|
+
* execute probes every other task through the layers.
|
|
67
|
+
*/
|
|
68
|
+
localOnly?: boolean | undefined
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
/** Per-task result of the up-front probe. `hit` null = a confirmed
|
|
@@ -159,6 +165,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
159
165
|
endProbe()
|
|
160
166
|
for (const { hash, node } of candidates) {
|
|
161
167
|
const hit = hits.get(hash) ?? null
|
|
168
|
+
if (hit === null && args.localOnly === true) continue
|
|
162
169
|
preProbed.set(node.id, { hash, hit })
|
|
163
170
|
if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
|
|
164
171
|
}
|
|
@@ -175,6 +182,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
175
182
|
const { hash, node } = candidates[next++]!
|
|
176
183
|
try {
|
|
177
184
|
const hit = await args.cache.get(hash, getContext(node, node.config.exec?.command ?? ''))
|
|
185
|
+
if (hit === null && args.localOnly === true) continue
|
|
178
186
|
preProbed.set(node.id, { hash, hit })
|
|
179
187
|
if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
|
|
180
188
|
} catch {
|
|
@@ -531,8 +531,8 @@ export interface CacheHost extends CacheContext {
|
|
|
531
531
|
* Collect every plugin's remote (`cache` hook) in declaration order and
|
|
532
532
|
* layer each over the local store: a lookup asks the local store first,
|
|
533
533
|
* then each remote in order (owner, 2026-10-13), and a save reaches every
|
|
534
|
-
* remote. The run's policy
|
|
535
|
-
*
|
|
534
|
+
* remote. The run's policy alone gates each remote (M, 2026-10-16: a
|
|
535
|
+
* plugin moves bytes, it never decides reads or writes); a remote's throw is a miss and a warning naming its plugin. No
|
|
536
536
|
* remote → the local store alone.
|
|
537
537
|
*/
|
|
538
538
|
export async function resolveCache(
|
|
@@ -546,11 +546,10 @@ export async function resolveCache(
|
|
|
546
546
|
const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
|
|
547
547
|
if (remote === undefined) continue
|
|
548
548
|
assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
|
|
549
|
-
const access = remote.access
|
|
550
549
|
const entry: ResolvedRemote = {
|
|
551
550
|
remote,
|
|
552
|
-
read: policy.remoteRead
|
|
553
|
-
write: policy.remoteWrite
|
|
551
|
+
read: policy.remoteRead,
|
|
552
|
+
write: policy.remoteWrite,
|
|
554
553
|
onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
|
|
555
554
|
}
|
|
556
555
|
resolved.push(entry)
|