@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/archive.ts
CHANGED
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
// holding more than a chunk. `Bun.Archive` used to do all three and
|
|
21
21
|
// needed the whole tar in memory on every side — restoring a 150 MiB
|
|
22
22
|
// artifact peaked at 3.2× its size, saving one at 4.7× (measured
|
|
23
|
-
// 2026-09-03). Every regular entry is written
|
|
24
|
-
// `.vx
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// needed, which
|
|
23
|
+
// 2026-09-03). Every regular entry is written to the workspace's staging
|
|
24
|
+
// directory (`.vx/tmp/<pid>-0/`, vx's own, never an output folder) and
|
|
25
|
+
// renamed over its target only once the WHOLE archive has been read and
|
|
26
|
+
// every name proven safe, so a poisoned entry anywhere — even the last
|
|
27
|
+
// one — leaves nothing behind but the empty directories it needed, which
|
|
28
|
+
// are pruned too, and a killed restore leaves its temps where the next
|
|
29
|
+
// run sweeps them (`sweepStaging`).
|
|
28
30
|
//
|
|
29
31
|
// What stays here is the part no tar reader can decide for us: WHERE an
|
|
30
32
|
// entry may land on disk. Every name is validated and every destination
|
|
@@ -39,14 +41,33 @@
|
|
|
39
41
|
|
|
40
42
|
import {
|
|
41
43
|
chmodSync,
|
|
44
|
+
chownSync,
|
|
45
|
+
closeSync,
|
|
46
|
+
constants,
|
|
47
|
+
existsSync,
|
|
48
|
+
fchmodSync,
|
|
49
|
+
fstatSync,
|
|
50
|
+
futimesSync,
|
|
42
51
|
lstatSync,
|
|
52
|
+
openSync,
|
|
43
53
|
readFileSync,
|
|
44
54
|
realpathSync,
|
|
45
|
-
renameSync,
|
|
46
55
|
statSync,
|
|
47
56
|
utimesSync,
|
|
57
|
+
writeFileSync,
|
|
48
58
|
} from 'node:fs'
|
|
49
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
copyFile,
|
|
61
|
+
lstat,
|
|
62
|
+
mkdir,
|
|
63
|
+
readlink,
|
|
64
|
+
realpath,
|
|
65
|
+
rename,
|
|
66
|
+
rmdir,
|
|
67
|
+
stat,
|
|
68
|
+
unlink,
|
|
69
|
+
writeFile,
|
|
70
|
+
} from 'node:fs/promises'
|
|
50
71
|
import path from 'node:path'
|
|
51
72
|
import { UserError } from '../util/index.js'
|
|
52
73
|
import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
|
|
@@ -595,12 +616,15 @@ export async function extractArtifactStream(
|
|
|
595
616
|
destDir: string,
|
|
596
617
|
workspaceDest: string | undefined,
|
|
597
618
|
verify?: (provided: ReadonlySet<string>) => void,
|
|
619
|
+
accept?: (name: string) => boolean,
|
|
620
|
+
/** The workspace root whose `.vx/tmp/` stages the entries; absent, each stages beside its target. */
|
|
621
|
+
stageRoot?: string,
|
|
598
622
|
): Promise<Set<string>> {
|
|
599
623
|
// No `mkdir -p` of the destination here: `stage` creates each entry's
|
|
600
624
|
// directory chain, the destination included, and the cache restores
|
|
601
625
|
// into a project directory that exists. The eager one cost every
|
|
602
626
|
// restore a thread-pool round trip for an EEXIST and a stat (item 627).
|
|
603
|
-
const x = new Extractor(destDir, workspaceDest)
|
|
627
|
+
const x = new Extractor(destDir, workspaceDest, stageRoot)
|
|
604
628
|
const provided = new Set<string>()
|
|
605
629
|
const headerMtime = new Map<string, number>()
|
|
606
630
|
let meta: MetaFile['files'] = {}
|
|
@@ -615,6 +639,13 @@ export async function extractArtifactStream(
|
|
|
615
639
|
continue
|
|
616
640
|
}
|
|
617
641
|
assertSafeName(e.name)
|
|
642
|
+
// Before anything is staged: an entry the task never declared is not
|
|
643
|
+
// this task's save, and none of the archive lands.
|
|
644
|
+
if (accept !== undefined && !accept(e.name)) {
|
|
645
|
+
throw new ArchiveSecurityError(
|
|
646
|
+
`archive entry is not one of the task's declared outputs: ${e.name}`,
|
|
647
|
+
)
|
|
648
|
+
}
|
|
618
649
|
const dest = x.destFor(e.name)
|
|
619
650
|
if (dest === null || dest.rel.length === 0) {
|
|
620
651
|
await drain(body)
|
|
@@ -644,6 +675,9 @@ export async function extractArtifactStream(
|
|
|
644
675
|
return provided
|
|
645
676
|
}
|
|
646
677
|
|
|
678
|
+
/** BSD gives every new file its directory's group, setgid or not. */
|
|
679
|
+
const INHERITS_GROUP = process.platform === 'darwin'
|
|
680
|
+
|
|
647
681
|
/**
|
|
648
682
|
* Entries up to this size are buffered and written without waiting, so a
|
|
649
683
|
* thousand tiny outputs restore in parallel; larger ones stream chunk by
|
|
@@ -674,8 +708,32 @@ async function bytesOf(body: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
|
|
|
674
708
|
const textOf = async (body: AsyncIterable<Uint8Array>): Promise<string> =>
|
|
675
709
|
utf8.decode(await bytesOf(body))
|
|
676
710
|
|
|
711
|
+
/**
|
|
712
|
+
* Bring a target that already held the entry's bytes to its mode and
|
|
713
|
+
* mtime, through a descriptor opened without following a link: one
|
|
714
|
+
* planted since the compare is refused, never written through.
|
|
715
|
+
*/
|
|
716
|
+
function keep(s: Staged, ino: number, mode: number, mtimeMs: number | undefined): void {
|
|
717
|
+
const fd = openSync(s.target, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
718
|
+
try {
|
|
719
|
+
const st = fstatSync(fd)
|
|
720
|
+
if (st.ino !== ino || st.nlink !== 1) {
|
|
721
|
+
throw new Error(`${s.target} changed while it was restored`)
|
|
722
|
+
}
|
|
723
|
+
if ((st.mode & 0o7777) !== (mode & 0o777)) fchmodSync(fd, mode & 0o777)
|
|
724
|
+
if (mtimeMs !== undefined && Math.floor(st.mtimeMs) !== mtimeMs) {
|
|
725
|
+
const t = new Date(mtimeMs)
|
|
726
|
+
futimesSync(fd, t, t)
|
|
727
|
+
}
|
|
728
|
+
} finally {
|
|
729
|
+
closeSync(fd)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
677
733
|
/** One staged entry: written at `tmp`, renamed to `target` on commit. */
|
|
678
734
|
interface Staged {
|
|
735
|
+
/** `tmp` is in the staging directory, not beside `target`. */
|
|
736
|
+
staged: boolean
|
|
679
737
|
name: string
|
|
680
738
|
tmp: string
|
|
681
739
|
target: string
|
|
@@ -683,6 +741,10 @@ interface Staged {
|
|
|
683
741
|
created: string | undefined
|
|
684
742
|
/** Written by Bun's file writer, which creates with 0664 where `writeFile` uses 0666 (before the umask). */
|
|
685
743
|
streamed: boolean
|
|
744
|
+
/** The destination it is contained in. */
|
|
745
|
+
base: string
|
|
746
|
+
/** The target already holds the entry's bytes: nothing was written, and only its mode and mtime may change. */
|
|
747
|
+
same?: number
|
|
686
748
|
}
|
|
687
749
|
|
|
688
750
|
/**
|
|
@@ -709,6 +771,7 @@ class Extractor {
|
|
|
709
771
|
constructor(
|
|
710
772
|
private readonly destDir: string,
|
|
711
773
|
private readonly workspaceDest: string | undefined,
|
|
774
|
+
private readonly stageRoot: string | undefined,
|
|
712
775
|
) {}
|
|
713
776
|
|
|
714
777
|
/** Each namespace anchors at its own destination dir. */
|
|
@@ -865,7 +928,33 @@ class Extractor {
|
|
|
865
928
|
}
|
|
866
929
|
this.ensuredDirs.add(dir)
|
|
867
930
|
}
|
|
868
|
-
//
|
|
931
|
+
// A target that already holds these bytes stays: renaming over a live
|
|
932
|
+
// file costs ~0.4 ms on ext4 (auto_da_alloc), and a hit that changed
|
|
933
|
+
// one of 2,000 outputs paid it 2,000 times. Bytes, not a stat stamp: a
|
|
934
|
+
// reproducible build gives every output one mtime (item 886).
|
|
935
|
+
if (body instanceof Uint8Array && created === undefined) {
|
|
936
|
+
const st = lstatSync(target, { throwIfNoEntry: false })
|
|
937
|
+
if (
|
|
938
|
+
st !== undefined &&
|
|
939
|
+
st.isFile() &&
|
|
940
|
+
st.nlink === 1 &&
|
|
941
|
+
st.size === body.byteLength &&
|
|
942
|
+
readFileSync(target).equals(body)
|
|
943
|
+
) {
|
|
944
|
+
this.staged.push({
|
|
945
|
+
staged: false,
|
|
946
|
+
name,
|
|
947
|
+
tmp: '',
|
|
948
|
+
target,
|
|
949
|
+
created,
|
|
950
|
+
streamed: false,
|
|
951
|
+
base,
|
|
952
|
+
same: st.ino,
|
|
953
|
+
})
|
|
954
|
+
return
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
// Write apart from the target and RENAME into place. rename(2) replaces
|
|
869
958
|
// the destination's directory ENTRY without following it, which is
|
|
870
959
|
// what makes this both link-safe and concurrency-safe:
|
|
871
960
|
//
|
|
@@ -882,15 +971,15 @@ class Extractor {
|
|
|
882
971
|
//
|
|
883
972
|
// A directory at the target makes the rename fail — the same
|
|
884
973
|
// fail-closed outcome the plain write had.
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
const
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
974
|
+
// Staged under `.vx/tmp/`, so no output folder ever holds a temp: a
|
|
975
|
+
// reader or a key taken meanwhile saw `.vx-tmp-*` files beside the
|
|
976
|
+
// outputs, and a killed restore left them there (M, 2026-10-16).
|
|
977
|
+
const staged = this.stageRoot !== undefined
|
|
978
|
+
const tmp = staged
|
|
979
|
+
? path.join(await stagingDir(this.stageRoot!), (tmpSeq++).toString(36))
|
|
980
|
+
: siblingTemp(target)
|
|
892
981
|
const streamed = !(body instanceof Uint8Array)
|
|
893
|
-
this.staged.push({ name, tmp, target, created, streamed })
|
|
982
|
+
this.staged.push({ staged, name, tmp, target, created, streamed, base })
|
|
894
983
|
if (body instanceof Uint8Array) {
|
|
895
984
|
this.inflightBytes += body.byteLength
|
|
896
985
|
// `writeFile`, not `Bun.write`: for a buffer this size `Bun.write`
|
|
@@ -926,12 +1015,11 @@ class Extractor {
|
|
|
926
1015
|
|
|
927
1016
|
/**
|
|
928
1017
|
* Apply each entry's mode and mtime, then rename everything into place.
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
* restores' round trips flowing. The chmod is skipped when the mode is
|
|
1018
|
+
* The metadata calls are synchronous: a few microseconds each on the
|
|
1019
|
+
* calling thread and ~40 µs each as thread-pool round trips, and a large
|
|
1020
|
+
* artifact has thousands of them (measured 2026-09-11 on payload's ui,
|
|
1021
|
+
* 4,069 files: 892 ms → see the archive bench). The renames go to the
|
|
1022
|
+
* pool, `RENAME_BATCH` at a time: each replaces a live file. The chmod is skipped when the mode is
|
|
935
1023
|
* the one the temp file was created with — the common case. The two
|
|
936
1024
|
* writers create with different modes (under umask 000: 0666 and 0664),
|
|
937
1025
|
* so each is stat'ed once.
|
|
@@ -940,38 +1028,107 @@ class Extractor {
|
|
|
940
1028
|
metaFor: (name: string) => [mode: number, mtimeMs: number | undefined],
|
|
941
1029
|
): Promise<void> {
|
|
942
1030
|
await this.drain()
|
|
943
|
-
const
|
|
1031
|
+
const created = new Map<boolean, { mode: number; uid: number; gid: number }>()
|
|
1032
|
+
// The group a file written in place would take, by directory: a setgid
|
|
1033
|
+
// directory's on Linux, every directory's on BSD/macOS. A file staged
|
|
1034
|
+
// elsewhere took the staging directory's.
|
|
1035
|
+
const setgid = new Map<string, number | null>()
|
|
944
1036
|
let n = 0
|
|
1037
|
+
let renames: Promise<void>[] = []
|
|
1038
|
+
const checked = new Set<string>()
|
|
945
1039
|
for (const s of this.staged) {
|
|
1040
|
+
await this.assertStillContained(s, checked)
|
|
946
1041
|
const [mode, mtimeMs] = metaFor(s.name)
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1042
|
+
if (s.same !== undefined) {
|
|
1043
|
+
keep(s, s.same, mode, mtimeMs)
|
|
1044
|
+
continue
|
|
1045
|
+
}
|
|
1046
|
+
let made = created.get(s.streamed)
|
|
1047
|
+
if (made === undefined) {
|
|
1048
|
+
const st = statSync(s.tmp)
|
|
1049
|
+
made = { mode: st.mode & 0o777, uid: st.uid, gid: st.gid }
|
|
1050
|
+
created.set(s.streamed, made)
|
|
1051
|
+
}
|
|
1052
|
+
if ((mode & 0o777) !== made.mode) chmodSync(s.tmp, mode & 0o777)
|
|
1053
|
+
// A Date, not seconds: Bun reads a negative number of seconds as
|
|
1054
|
+
// "now", and a Date before 1970 as itself (Bun 1.4.2).
|
|
1055
|
+
const t = mtimeMs === undefined ? undefined : new Date(mtimeMs)
|
|
1056
|
+
if (t !== undefined) utimesSync(s.tmp, t, t)
|
|
1057
|
+
if (s.staged) {
|
|
1058
|
+
const dir = path.dirname(s.target)
|
|
1059
|
+
let gid = setgid.get(dir)
|
|
1060
|
+
if (gid === undefined) {
|
|
1061
|
+
const st = statSync(dir)
|
|
1062
|
+
gid = INHERITS_GROUP || (st.mode & 0o2000) !== 0 ? st.gid : null
|
|
1063
|
+
setgid.set(dir, gid)
|
|
1064
|
+
}
|
|
1065
|
+
if (gid !== null && gid !== made.gid) chownSync(s.tmp, made.uid, gid)
|
|
951
1066
|
}
|
|
952
|
-
|
|
953
|
-
if (
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
const t = new Date(mtimeMs)
|
|
957
|
-
utimesSync(s.tmp, t, t)
|
|
1067
|
+
renames.push(this.place(s, mode, t))
|
|
1068
|
+
if (++n % RENAME_BATCH === 0) {
|
|
1069
|
+
await settle(renames)
|
|
1070
|
+
renames = []
|
|
958
1071
|
}
|
|
959
|
-
renameSync(s.tmp, s.target)
|
|
960
|
-
if (++n % COMMIT_BATCH === 0) await new Promise<void>((r) => setImmediate(r))
|
|
961
1072
|
}
|
|
1073
|
+
await settle(renames)
|
|
962
1074
|
this.staged.length = 0
|
|
963
1075
|
}
|
|
964
1076
|
|
|
965
1077
|
/**
|
|
966
|
-
*
|
|
967
|
-
*
|
|
1078
|
+
* Rename `s` over its target. A target on another filesystem than
|
|
1079
|
+
* `.vx/tmp/` (EXDEV) is reached through a temp beside it, for that file
|
|
1080
|
+
* only: the rename that replaces the live file stays atomic.
|
|
1081
|
+
*/
|
|
1082
|
+
private async place(s: Staged, mode: number, t: Date | undefined): Promise<void> {
|
|
1083
|
+
try {
|
|
1084
|
+
await rename(s.tmp, s.target)
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
if (!s.staged || (err as NodeJS.ErrnoException).code !== 'EXDEV') throw err
|
|
1087
|
+
const side = siblingTemp(s.target)
|
|
1088
|
+
try {
|
|
1089
|
+
await copyFile(s.tmp, side)
|
|
1090
|
+
chmodSync(side, mode & 0o777)
|
|
1091
|
+
if (t !== undefined) utimesSync(side, t, t)
|
|
1092
|
+
await rename(side, s.target)
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
await unlink(side).catch(() => undefined)
|
|
1095
|
+
throw e
|
|
1096
|
+
}
|
|
1097
|
+
await unlink(s.tmp).catch(() => undefined)
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* The staging check, again at the rename: a directory on the way swapped
|
|
1103
|
+
* for a link out of the base since then would carry the rename with it.
|
|
1104
|
+
* Bun has no `renameat` against a held directory fd, so this is by path,
|
|
1105
|
+
* once per directory per commit.
|
|
1106
|
+
*/
|
|
1107
|
+
private async assertStillContained(s: Staged, checked: Set<string>): Promise<void> {
|
|
1108
|
+
const dir = path.dirname(s.target)
|
|
1109
|
+
if (checked.has(dir)) return
|
|
1110
|
+
checked.add(dir)
|
|
1111
|
+
const realBase = await this.realBaseOf(s.base)
|
|
1112
|
+
const real = await realOf(dir, true)
|
|
1113
|
+
if (real !== realBase && !real.startsWith(realBase + path.sep)) {
|
|
1114
|
+
throw await linkOutError(path.resolve(s.base), dir, realBase)
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Never leave a temp behind: beside a target, the declared output globs
|
|
1120
|
+
* would sweep it into the next artifact. Directories this extraction created
|
|
968
1121
|
* are removed bottom-up while empty; one a concurrent writer has since
|
|
969
1122
|
* filled is left alone.
|
|
970
1123
|
*/
|
|
971
1124
|
async abort(): Promise<void> {
|
|
972
1125
|
await Promise.allSettled(this.inflight)
|
|
973
1126
|
this.inflight = []
|
|
974
|
-
await Promise.all(
|
|
1127
|
+
await Promise.all(
|
|
1128
|
+
this.staged.map((s) =>
|
|
1129
|
+
s.same === undefined ? unlink(s.tmp).catch(() => undefined) : undefined,
|
|
1130
|
+
),
|
|
1131
|
+
)
|
|
975
1132
|
// Newest first: a later entry's chain can sit inside an earlier one's
|
|
976
1133
|
// (`dist/` then `dist/sub/`), never the reverse.
|
|
977
1134
|
for (const s of this.staged.toReversed()) {
|
|
@@ -997,12 +1154,62 @@ class Extractor {
|
|
|
997
1154
|
}
|
|
998
1155
|
}
|
|
999
1156
|
|
|
1000
|
-
/**
|
|
1001
|
-
|
|
1157
|
+
/**
|
|
1158
|
+
* Renames in flight at once. A rename over a live file is not the few
|
|
1159
|
+
* microseconds of one into an empty directory: on ext4 replacing a file
|
|
1160
|
+
* starts writeback of the new one's data (`auto_da_alloc`), ~250 µs each,
|
|
1161
|
+
* and synchronous ones stalled the main thread. 300 one-file restores
|
|
1162
|
+
* took 25% longer than delete-then-restore (2026-10-16); on the thread
|
|
1163
|
+
* pool they tie it, and 2,000 files in one restore are faster.
|
|
1164
|
+
*/
|
|
1165
|
+
const RENAME_BATCH = 64
|
|
1166
|
+
|
|
1167
|
+
/** Wait for every rename, then throw the first failure: none is left in flight for `abort`. */
|
|
1168
|
+
async function settle(renames: readonly Promise<void>[]): Promise<void> {
|
|
1169
|
+
for (const r of await Promise.allSettled(renames)) if (r.status === 'rejected') throw r.reason
|
|
1170
|
+
}
|
|
1002
1171
|
|
|
1003
1172
|
/** Per-process counter for extract temp names; uniqueness only. */
|
|
1004
1173
|
let tmpSeq = 0
|
|
1005
1174
|
|
|
1175
|
+
/** `.vx/<STAGING>/`: where restores stage, each process in its own `<pid>-0` directory. */
|
|
1176
|
+
export const STAGING = 'tmp'
|
|
1177
|
+
|
|
1178
|
+
const stagingMade = new Set<string>()
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* This process's staging directory under `root`'s `.vx/tmp/`, made on its
|
|
1182
|
+
* first restore and again only if something removed it: one per restore
|
|
1183
|
+
* cost 300 restores 12% (2026-10-16). Every restore's renames empty it;
|
|
1184
|
+
* the next run's `sweepStaging` removes it once this process is gone.
|
|
1185
|
+
*/
|
|
1186
|
+
async function stagingDir(root: string): Promise<string> {
|
|
1187
|
+
const top = path.join(root, '.vx', STAGING)
|
|
1188
|
+
const own = path.join(top, `${process.pid.toString(36)}-0`)
|
|
1189
|
+
if (stagingMade.has(own) && existsSync(own)) return own
|
|
1190
|
+
await mkdir(own, { recursive: true })
|
|
1191
|
+
// Ignored by git whatever `.vx/.gitignore` an older init committed.
|
|
1192
|
+
try {
|
|
1193
|
+
writeFileSync(path.join(top, '.gitignore'), '*\n', { flag: 'wx' })
|
|
1194
|
+
} catch {
|
|
1195
|
+
// there already, or the restore fails on its first write anyway
|
|
1196
|
+
}
|
|
1197
|
+
stagingMade.add(own)
|
|
1198
|
+
return own
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* A temp beside `target`, with its own short name, never `<target>.suffix`:
|
|
1203
|
+
* a suffix pushed a legal name of 242–255 bytes past NAME_MAX, and the
|
|
1204
|
+
* restore of a valid artifact failed with ENAMETOOLONG (item 666).
|
|
1205
|
+
*/
|
|
1206
|
+
function siblingTemp(target: string): string {
|
|
1207
|
+
return path.join(
|
|
1208
|
+
path.dirname(target),
|
|
1209
|
+
`.vx-tmp-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}`,
|
|
1210
|
+
)
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1006
1213
|
const WORKSPACE_PREFIX = 'workspace-outputs/'
|
|
1007
1214
|
|
|
1008
1215
|
/**
|
package/src/cache/cache.ts
CHANGED
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
isFsRefusal,
|
|
61
61
|
isOutOfFds,
|
|
62
62
|
OUT_OF_FDS_HINT,
|
|
63
|
-
|
|
63
|
+
restorableOutput,
|
|
64
64
|
relPosix,
|
|
65
65
|
span,
|
|
66
66
|
splitTaskId,
|
|
@@ -1842,9 +1842,14 @@ export class Cache implements CacheLayer {
|
|
|
1842
1842
|
outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
|
|
1843
1843
|
return this.outputs.outputDirsCurrent(projectDir, rows)
|
|
1844
1844
|
}
|
|
1845
|
-
async restoreOutputs(
|
|
1845
|
+
async restoreOutputs(
|
|
1846
|
+
hash: string,
|
|
1847
|
+
projectDir: string,
|
|
1848
|
+
workspaceRoot?: string,
|
|
1849
|
+
outputs?: DeclaredOutputs,
|
|
1850
|
+
): Promise<void> {
|
|
1846
1851
|
try {
|
|
1847
|
-
await this.restoreOutputsOnce(hash, projectDir, workspaceRoot)
|
|
1852
|
+
await this.restoreOutputsOnce(hash, projectDir, workspaceRoot, outputs)
|
|
1848
1853
|
} catch (err) {
|
|
1849
1854
|
if (!(err instanceof CorruptArtifactError)) throw err
|
|
1850
1855
|
// Bad bytes under a good key failed the task on every run until
|
|
@@ -1879,7 +1884,8 @@ export class Cache implements CacheLayer {
|
|
|
1879
1884
|
private async restoreOutputsOnce(
|
|
1880
1885
|
hash: string,
|
|
1881
1886
|
projectDir: string,
|
|
1882
|
-
workspaceRoot
|
|
1887
|
+
workspaceRoot: string | undefined,
|
|
1888
|
+
outputs: DeclaredOutputs | undefined,
|
|
1883
1889
|
): Promise<void> {
|
|
1884
1890
|
// In-process extraction — no fork+exec on the hot path
|
|
1885
1891
|
// (~5-10ms reclaimed per hit vs the prior subprocess `tar -xf`).
|
|
@@ -1967,7 +1973,14 @@ export class Cache implements CacheLayer {
|
|
|
1967
1973
|
// four workers overlap their round trips and a blocking one stalls
|
|
1968
1974
|
// the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
|
|
1969
1975
|
const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
|
|
1970
|
-
await extractArtifactStream(
|
|
1976
|
+
await extractArtifactStream(
|
|
1977
|
+
tar,
|
|
1978
|
+
projectDir,
|
|
1979
|
+
workspaceRoot,
|
|
1980
|
+
verify,
|
|
1981
|
+
outputs === undefined ? undefined : declaredName(outputs),
|
|
1982
|
+
workspaceRoot,
|
|
1983
|
+
)
|
|
1971
1984
|
} catch (err) {
|
|
1972
1985
|
// A UserError here is the extractor naming the tree's fault (an
|
|
1973
1986
|
// output directory that links out of the project).
|
|
@@ -1981,8 +1994,8 @@ export class Cache implements CacheLayer {
|
|
|
1981
1994
|
throw new CorruptArtifactError(hash, err.message, err)
|
|
1982
1995
|
// What is on disk, not what is in the archive: a directory standing
|
|
1983
1996
|
// where the entry holds a file, or a file where it needs a directory.
|
|
1984
|
-
// The
|
|
1985
|
-
// directories that removal emptied, so this is a path it left: one
|
|
1997
|
+
// The restore removes the FILES the output globs select that block it
|
|
1998
|
+
// and the directories that removal emptied, so this is a path it left: one
|
|
1986
1999
|
// the globs do not cover, or an empty directory where a file goes,
|
|
1987
2000
|
// which `dist/**` covers and the clean does not take (item 1094).
|
|
1988
2001
|
// Named as such, not as a corrupt artifact. A
|
|
@@ -2001,7 +2014,7 @@ export class Cache implements CacheLayer {
|
|
|
2001
2014
|
: 'a file stands where the entry needs a directory'
|
|
2002
2015
|
throw new UserError(
|
|
2003
2016
|
`restore of ${hash} into ${projectDir} was blocked by what is on disk (${msg.startsWith(code) ? msg : `${code}: ${msg}`}): ${what}. ` +
|
|
2004
|
-
`
|
|
2017
|
+
`A restore replaces what the output globs select, not this — remove it and re-run.`,
|
|
2005
2018
|
)
|
|
2006
2019
|
}
|
|
2007
2020
|
// A legal name under a destination deep enough that the two together
|
|
@@ -2049,7 +2062,7 @@ export class Cache implements CacheLayer {
|
|
|
2049
2062
|
// on one workspace, a `vx watch` beside a `vx run` — a clean landing
|
|
2050
2063
|
// 4–32 ms into a 2,000-file restore reproduces it, 2026-09-16). The
|
|
2051
2064
|
// artifact is intact; the tree was not ours alone.
|
|
2052
|
-
if (code === 'ENOENT' && (err as Error).message
|
|
2065
|
+
if (code === 'ENOENT' && /\.vx-tmp-|[\\/]\.vx[\\/]tmp[\\/]/.test((err as Error).message)) {
|
|
2053
2066
|
throw new UserError(
|
|
2054
2067
|
`restore of ${hash} into ${projectDir} was interrupted: a file it had just written vanished (${(err as Error).message}). ` +
|
|
2055
2068
|
`Another vx run is using this workspace — re-run once it is done.`,
|
|
@@ -3083,16 +3096,9 @@ function assertArtifactNames(
|
|
|
3083
3096
|
}
|
|
3084
3097
|
}
|
|
3085
3098
|
if (declared === undefined) return
|
|
3086
|
-
|
|
3087
|
-
const isFile = outputMatcher(declared.files)
|
|
3088
|
-
const isWsFile = outputMatcher(declared.workspaceFiles)
|
|
3099
|
+
const ok = declaredName(declared)
|
|
3089
3100
|
for (const name of names) {
|
|
3090
|
-
|
|
3091
|
-
? isFile(name.slice('outputs/'.length))
|
|
3092
|
-
: name.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
3093
|
-
? isWsFile(name.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
3094
|
-
: true
|
|
3095
|
-
if (!ok) {
|
|
3101
|
+
if (!ok(name)) {
|
|
3096
3102
|
throw new CorruptArtifactError(
|
|
3097
3103
|
hash,
|
|
3098
3104
|
`artifact carries ${name}, which is not one of the task's declared outputs`,
|
|
@@ -3101,6 +3107,24 @@ function assertArtifactNames(
|
|
|
3101
3107
|
}
|
|
3102
3108
|
}
|
|
3103
3109
|
|
|
3110
|
+
/**
|
|
3111
|
+
* Whether an artifact entry names one of the task's declared outputs, as
|
|
3112
|
+
* the save could have packed it (`restorableOutput`: a `!` take-back, vx's
|
|
3113
|
+
* own files and an unnamed `node_modules` are none). Asked at ingest and
|
|
3114
|
+
* again at every restore, so an entry that reached the local store by any
|
|
3115
|
+
* route still writes nothing it should not (2026-10-16).
|
|
3116
|
+
*/
|
|
3117
|
+
function declaredName(declared: DeclaredOutputs): (name: string) => boolean {
|
|
3118
|
+
const isFile = restorableOutput(declared.files)
|
|
3119
|
+
const isWsFile = restorableOutput(declared.workspaceFiles)
|
|
3120
|
+
return (name) =>
|
|
3121
|
+
name.startsWith('outputs/')
|
|
3122
|
+
? isFile(name.slice('outputs/'.length))
|
|
3123
|
+
: name.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
3124
|
+
? isWsFile(name.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
3125
|
+
: true
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3104
3128
|
/** What `ingest` reads a remote body through. */
|
|
3105
3129
|
interface ChunkReader {
|
|
3106
3130
|
read(): Promise<{ done: true; value?: undefined } | { done: false; value: Uint8Array }>
|
package/src/cache/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export {
|
|
|
12
12
|
type CacheEntry,
|
|
13
13
|
type CacheKeyInput,
|
|
14
14
|
type CacheLayer,
|
|
15
|
+
type RemoteTraffic,
|
|
15
16
|
type CachePolicy,
|
|
16
17
|
cachesNothing,
|
|
17
18
|
EXECUTED_RUNS_SQL,
|
|
@@ -48,6 +49,8 @@ export {
|
|
|
48
49
|
resolveInputs,
|
|
49
50
|
resolveOutputs,
|
|
50
51
|
resolveWorkspaceOutputs,
|
|
52
|
+
sweepRestoreTemps,
|
|
53
|
+
sweepStaging,
|
|
51
54
|
asTrees,
|
|
52
55
|
type ProjectFilesCache,
|
|
53
56
|
type WorkspaceFilesCache,
|