@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.
@@ -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 beside its target under a
24
- // `.vx-tmp-*` name and renamed into place only once the WHOLE archive has
25
- // been read and every name proven safe, so a poisoned entry anywhere —
26
- // even the last one — leaves nothing behind but the empty directories it
27
- // needed, which are pruned too.
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 { lstat, mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
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
- // Write beside the target and RENAME into place. rename(2) replaces
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
- // The temp is a sibling with its own short name, never `<target>.suffix`:
886
- // a suffix pushed a legal name of 242–255 bytes past NAME_MAX, and the
887
- // restore of a valid artifact failed with ENAMETOOLONG (item 666).
888
- const tmp = path.join(
889
- path.dirname(target),
890
- `.vx-tmp-${process.pid.toString(36)}-${(tmpSeq++).toString(36)}`,
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
- * Synchronous calls in batches: the three metadata calls per file are a
930
- * few microseconds each on the calling thread and ~40 µs each as
931
- * thread-pool round trips, and a large artifact has thousands of them
932
- * (measured 2026-09-11 on payload's ui, 4,069 files: 892 ms → see the
933
- * archive bench). A yield every `COMMIT_BATCH` files keeps the other
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 createdModes = new Map<boolean, number>()
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
- let createdMode = createdModes.get(s.streamed)
948
- if (createdMode === undefined) {
949
- createdMode = statSync(s.tmp).mode & 0o777
950
- createdModes.set(s.streamed, createdMode)
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
- if ((mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
953
- if (mtimeMs !== undefined) {
954
- // A Date, not seconds: Bun reads a negative number of seconds as
955
- // "now", and a Date before 1970 as itself (Bun 1.4.2).
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
- * Never leave a stray `.vx-tmp-*` behind: the declared output globs would
967
- * sweep it into the next artifact. Directories this extraction created
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(this.staged.map((s) => unlink(s.tmp).catch(() => undefined)))
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
- /** Files renamed into place between two yields of a commit. */
1001
- const COMMIT_BATCH = 256
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
  /**
@@ -60,7 +60,7 @@ import {
60
60
  isFsRefusal,
61
61
  isOutOfFds,
62
62
  OUT_OF_FDS_HINT,
63
- outputMatcher,
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(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
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?: string,
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(tar, projectDir, workspaceRoot, verify)
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 clean removes the FILES the output globs select and 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
- `The clean before a restore removes the files the output globs select and the directories that empties, not this — remove it and re-run.`,
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.includes('.vx-tmp-')) {
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
- // A path a `!` entry takes back is no declared output either (A-44).
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
- const ok = name.startsWith('outputs/')
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 }>
@@ -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,