@vzn/vx 0.0.639 → 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.
Files changed (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
package/docs/security.md CHANGED
@@ -45,9 +45,10 @@ vulnerability), never in a public issue.
45
45
  choosing is trusted as a writer, as it is in every action cache. Use a
46
46
  store only your builds write to, or `turboCache()`'s signature key,
47
47
  which refuses an artifact a keyholder did not sign. A job you do not
48
- trust (a fork's pull request) reads at most: `--cache-policy=local:rw,remote:r`,
49
- or the tools' own `TURBO_REMOTE_CACHE_READ_ONLY`, `TURBO_CACHE` and
50
- `NX_SKIP_REMOTE_CACHE`, which the cache plugins honour (L-34).
48
+ trust (a fork's pull request) reads at most: `--cache-policy=local:rw,remote:r`
49
+ or `cache.policy`. Core's policy alone decides; a cache plugin reads no
50
+ tool's own switch (`TURBO_CACHE`, `NX_SKIP_REMOTE_CACHE`) and cannot
51
+ narrow it.
51
52
 
52
53
  ## Cache poisoning
53
54
 
@@ -217,7 +218,7 @@ executor's error, telemetry,
217
218
  `vx show` (L-26). Values in the run history are digests under a per-store
218
219
  salt, and what follows `--` on the command line reaches telemetry as a
219
220
  count, not a quote, a secret before it masked (L-44). A remote executor (`@vzn/vx-reapi`) receives a
220
- task's `exec.env.define` values and the `cache.inputs.env` values its
221
+ task's `exec.env.define` values and the `inputs.env` values its
221
222
  local child gets, never `passThrough`; those values sit unmasked in the
222
223
  action's Command, which the remote stores in its CAS, as Bazel's
223
224
  `--action_env` does, so a secret a remote task needs is trusted to that
package/launcher.sh CHANGED
@@ -35,8 +35,15 @@ scope=${scope##*/}
35
35
  # directory up to the git top-level (handoff.ts workspaceFile, the same
36
36
  # rule). One hop (VX_HANDED_OFF, which also names this launcher), never to
37
37
  # this package itself (`-ef`: same inode, so a pnpm symlink counts as
38
- # itself). Shell tests and builtins only, no fork per level.
39
- if [ -z "${VX_HANDED_OFF-}" ]; then
38
+ # itself). Shell tests and builtins only, no fork per level. `vx upgrade -g`
39
+ # moves only this global vx, so it never hands off (owner, 2026-10-16).
40
+ stay=
41
+ if [ "${1-}" = upgrade ]; then
42
+ for a in "$@"; do
43
+ case $a in -g | --global) stay=1 ;; esac
44
+ done
45
+ fi
46
+ if [ -z "${VX_HANDED_OFF-}" ] && [ -z "$stay" ]; then
40
47
  d=$PWD
41
48
  while :; do
42
49
  root=
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.639",
3
+ "version": "0.0.641",
4
4
  "description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.639",
25
- "@vzn/vx-linux-arm64": "0.0.639",
26
- "@vzn/vx-darwin-x64": "0.0.639",
27
- "@vzn/vx-darwin-arm64": "0.0.639"
24
+ "@vzn/vx-linux-x64": "0.0.641",
25
+ "@vzn/vx-linux-arm64": "0.0.641",
26
+ "@vzn/vx-darwin-x64": "0.0.641",
27
+ "@vzn/vx-darwin-arm64": "0.0.641"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/schemas/show.json CHANGED
@@ -90,24 +90,24 @@
90
90
  }
91
91
  }
92
92
  },
93
+ "inputs": {
94
+ "type": "object",
95
+ "additionalProperties": false,
96
+ "required": ["files"],
97
+ "properties": {
98
+ "files": { "$ref": "#/$defs/stringList" },
99
+ "workspaceFiles": { "$ref": "#/$defs/stringList" },
100
+ "env": { "$ref": "#/$defs/stringList" },
101
+ "tasks": { "$ref": "#/$defs/stringList" },
102
+ "runtime": { "$ref": "#/$defs/stringList" },
103
+ "workspaceRuntime": { "$ref": "#/$defs/stringList" }
104
+ }
105
+ },
93
106
  "cache": {
94
107
  "type": "object",
95
108
  "additionalProperties": false,
96
- "required": ["inputs", "outputs"],
109
+ "required": ["outputs"],
97
110
  "properties": {
98
- "inputs": {
99
- "type": "object",
100
- "additionalProperties": false,
101
- "required": ["files"],
102
- "properties": {
103
- "files": { "$ref": "#/$defs/stringList" },
104
- "workspaceFiles": { "$ref": "#/$defs/stringList" },
105
- "env": { "$ref": "#/$defs/stringList" },
106
- "tasks": { "$ref": "#/$defs/stringList" },
107
- "runtime": { "$ref": "#/$defs/stringList" },
108
- "workspaceRuntime": { "$ref": "#/$defs/stringList" }
109
- }
110
- },
111
111
  "outputs": {
112
112
  "type": "object",
113
113
  "additionalProperties": false,
@@ -90,9 +90,10 @@ vx info --format json # vx and Bun versions, projects, config errors, plugins,
90
90
  - Never delete or edit the cache by hand; vx repairs it itself.
91
91
  - A sandboxed task that fails on a denied path: grant that one path in
92
92
  `exec.sandbox.allow`. Never remove `sandbox` or widen it further.
93
- - A cached task declares its inputs (`cache.inputs.files`); if a change
94
- does not re-run a task, the file is not one of its inputs. Fix the
95
- config, do not force.
93
+ - A task declares its inputs (`inputs.files`), cached or not; if a
94
+ change does not re-run a task, the file is not one of its inputs. Fix
95
+ the config, do not force. A task with no `inputs` has no key, so every
96
+ cached task behind it misses: give it `inputs`.
96
97
  - Every JSON shape is a JSON Schema in `node_modules/@vzn/vx/schemas/`.
97
98
  - Offline reference: `vx docs <words> --format json` searches the CLI, config
98
99
  and cache pages that ship with vx; the sections holding every word print
package/src/bin.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env -S bun --no-env-file --no-install
2
2
  // Wrapped in an explicit async main so `bun build --compile` accepts
3
3
  // the file. The compile target doesn't allow top-level await.
4
- import { handOff, localVx, vxPath } from './handoff.js'
4
+ import { handOff, localVx, upgradesOnlyItself, vxPath } from './handoff.js'
5
5
 
6
6
  let settled = false
7
7
 
@@ -10,11 +10,13 @@ async function main(): Promise<void> {
10
10
  try {
11
11
  // Inside the try: `process.cwd()` throws EMFILE when out of descriptors,
12
12
  // and that is a one-line UserError below, not a stack.
13
- const local = await localVx(
14
- process.cwd(),
15
- process.execPath,
16
- Bun.main.startsWith('/$bunfs') || (process.argv[1] ?? '').startsWith('/$bunfs'),
17
- )
13
+ const local = upgradesOnlyItself(argv)
14
+ ? null
15
+ : await localVx(
16
+ process.cwd(),
17
+ process.execPath,
18
+ Bun.main.startsWith('/$bunfs') || (process.argv[1] ?? '').startsWith('/$bunfs'),
19
+ )
18
20
  if (local !== null) await handOff(local, argv)
19
21
  // `vx --version` needs one leaf module; the dispatcher and the util
20
22
  // barrel it pulled in were 20 ms of its 28 (2026-10-01).
@@ -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
  /**