@vzn/vx 0.0.644 → 0.0.645

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 vx contributors
3
+ Copyright (c) 2026 VZN
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  <p align="center">
14
14
  <a href="https://www.npmjs.com/package/@vzn/vx"><img src="https://img.shields.io/npm/v/@vzn/vx?color=7cb518" alt="npm"></a>
15
- <a href="https://github.com/vznjs/vx/actions/workflows/ci.yml"><img src="https://github.com/vznjs/vx/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
15
+ <a href="https://github.com/vznjs/vx/actions/workflows/ci.yml?query=branch%3Amain+event%3Apush"><img src="https://github.com/vznjs/vx/actions/workflows/ci.yml/badge.svg?branch=main&event=push" alt="CI"></a>
16
16
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
17
17
  <a href="https://vznjs.github.io/vx/"><img src="https://img.shields.io/badge/docs-vznjs.github.io%2Fvx-c6f84e" alt="Documentation"></a>
18
18
  </p>
@@ -35,13 +35,12 @@ Windows: run vx inside WSL2.
35
35
 
36
36
  ## Quick start
37
37
 
38
- In a git repo with a `package.json` workspace:
38
+ In a git repo:
39
39
 
40
40
  ```sh
41
- npm install -D @vzn/vx # the scope matters: npm's `vx` is an unrelated package
42
- npx @vzn/vx init # beside turbo.json or nx.json: migrates them; else a vx.config.ts per package
43
- npx @vzn/vx run build --all # builds in dependency order and stores the results
44
- npx @vzn/vx run build --all # nothing changed: every task up-to-date
41
+ npx @vzn/vx init # installs vx globally and in the repo; beside turbo.json or nx.json: migrates them; else a root vx.config.ts
42
+ vx run build --all # builds in dependency order and stores the results
43
+ vx run build --all # nothing changed: every task up-to-date
45
44
  ```
46
45
 
47
46
  Bun workspaces: `bun add -d @vzn/vx` and `bunx @vzn/vx` (npm refuses to run in
@@ -166,17 +165,17 @@ Every gap, and when another tool is the better pick:
166
165
 
167
166
  <!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
168
167
 
169
- | Time added over the ideal run, 1,601 projects, 9,603 tasks | vx | Turborepo | Nx | Vite Task |
170
- | ---------------------------------------------------------- | ----------- | --------- | ---------- | --------- |
171
- | Nothing changed | **856 ms** | 855 ms | 23.22 s | 11.24 s |
172
- | Nothing changed, outputs restored | **1.45 s** | 1.50 s | 21.64 s | 11.21 s |
173
- | Cold build | **7.73 s** | 10.32 s | 3 min 23 s | 9.44 s |
174
- | Cold build: CPU the runner burns | **36.42 s** | 1 min 3 s | 5 min 35 s | 25.64 s |
168
+ | Time added over the ideal run, 1,601 projects, 9,603 tasks | vx | Turborepo | Nx | Vite Task |
169
+ | ---------------------------------------------------------- | ---------- | --------- | ---------- | --------- |
170
+ | Nothing changed | **757 ms** | 761 ms | 22.63 s | 12.26 s |
171
+ | Nothing changed, outputs restored | **1.27 s** | 1.76 s | 22.16 s | 11.66 s |
172
+ | Cold build | **6.13 s** | 8.87 s | 3 min 20 s | 7.94 s |
173
+ | Cold build: CPU the runner burns | 30.06 s | 1 min 8 s | 5 min 45 s | 23.91 s |
175
174
 
176
175
  Time each tool adds over the ideal run; lower is better.
177
176
 
178
177
  Benchmark workload: a synthetic monorepo of 1,601 projects and 9,603 tasks in 30 dependency levels, five core libraries a quarter of the projects use; build 300 ms, test and typecheck 150 ms, lint 75 ms, publish 30 ms; real repos with uneven task times will differ.
179
- Run 2026-10-12 on linux x64, 4 cores: vx at commit 126d60a93, Turborepo 2.11.7, Nx 23.3.0, Vite Task (vite-plus) 1.1.0.
178
+ Run 2026-10-20 on linux x64, 4 cores: vx at commit a88652b0b, Turborepo 2.11.9, Nx 23.3.0, Vite Task (vite-plus) 1.3.0.
180
179
 
181
180
  Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
182
181
 
@@ -188,17 +187,10 @@ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.
188
187
  vx. The schema may still change; 1.0's promise:
189
188
  [versioning-1.0.md](packages/vx/docs/design/versioning-1.0.md).
190
189
 
191
- ## Contributing
190
+ ## Issues
192
191
 
193
- ```sh
194
- git clone https://github.com/vznjs/vx && cd vx
195
- bun install
196
- bun packages/vx/src/bin.ts run ci --all # lint, test, docs build: every package
197
- ```
198
-
199
- Needs Bun ≥ 1.4. Every dev task is a vx task; no `package.json`
200
- scripts. Start with [CONTRIBUTING.md](CONTRIBUTING.md) and
201
- [STATUS.md](packages/vx/docs/STATUS.md).
192
+ vx is maintained by VZN and takes issues, not pull requests: report a
193
+ bug or ask for a feature in [issues](https://github.com/vznjs/vx/issues).
202
194
 
203
195
  ## License
204
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.644",
3
+ "version": "0.0.645",
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.644",
25
- "@vzn/vx-linux-arm64": "0.0.644",
26
- "@vzn/vx-darwin-x64": "0.0.644",
27
- "@vzn/vx-darwin-arm64": "0.0.644"
24
+ "@vzn/vx-linux-x64": "0.0.645",
25
+ "@vzn/vx-linux-arm64": "0.0.645",
26
+ "@vzn/vx-darwin-x64": "0.0.645",
27
+ "@vzn/vx-darwin-arm64": "0.0.645"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/schemas/init.json CHANGED
@@ -52,6 +52,11 @@
52
52
  "next": {
53
53
  "type": ["string", "null"],
54
54
  "description": "the command to type after the real run; null where the plan names none"
55
+ },
56
+ "installs": {
57
+ "type": "array",
58
+ "items": { "type": "string" },
59
+ "description": "core's plan only: the install commands the real run runs (global, then the root devDependency)"
55
60
  }
56
61
  }
57
62
  }
@@ -163,8 +163,6 @@ class SumCheck {
163
163
  add(bytes: Uint8Array): void {
164
164
  this.sum.add(bytes)
165
165
  }
166
- /** The checked sum's text, once `verify` passed. */
167
- checked = ''
168
166
  verify(got: string): void {
169
167
  if (got !== this.sum.hex()) {
170
168
  throw new TarFormatError(
@@ -172,7 +170,6 @@ class SumCheck {
172
170
  )
173
171
  }
174
172
  this.seen = true
175
- this.checked = got
176
173
  }
177
174
  done(): void {
178
175
  if (!this.seen) throw new TarFormatError('artifact carries no checksum')
@@ -478,14 +475,11 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
478
475
  /**
479
476
  * The tree form (`cache.store.decompress`, v47): an artifact's members
480
477
  * as plain files under one directory, `outputs/<rel>` and
481
- * `workspace-outputs/<rel>` as named in the tar, beside its sidecar and a
482
- * `.vx-sum` over them. stdout lives in the index. A restore copies (or
483
- * clones) each file out, so a hit decodes nothing; an upload packs the
484
- * tree back into the artifact a remote expects.
485
- */
486
- const TREE_SUM = SUM_ENTRY
487
-
488
- /**
478
+ * `workspace-outputs/<rel>` as named in the tar, beside its sidecar.
479
+ * stdout lives in the index. A restore copies (or clones) each file out,
480
+ * so a hit decodes nothing; an upload packs the tree back into the
481
+ * artifact a remote expects.
482
+ *
489
483
  * Write `plan`'s outputs into the empty `dir` as the tree form, each copied
490
484
  * by the kernel (`copy_file_range`, or a clone where the filesystem has one)
491
485
  * and given its sidecar mtime, and return what a scan of the plan's tar
@@ -515,24 +509,14 @@ export async function writeTree(plan: ArtifactPlan, dir: string): Promise<Artifa
515
509
  }),
516
510
  )
517
511
  await writeFile(path.join(dir, META_ENTRY), meta)
518
- await writeFile(path.join(dir, TREE_SUM), await treeSum(dir, stdoutOf(plan)))
519
512
  return fold.result()
520
513
  }
521
514
 
522
- function stdoutOf(plan: ArtifactPlan): string {
523
- return utf8.decode(plan.inputs.find((i) => i.name === STDOUT_ENTRY)!.body as Uint8Array)
524
- }
525
-
526
515
  /**
527
- * Write a scanned artifact's sidecar and sum into `dir`, whose outputs an
528
- * extract just wrote: an ingested remote hit kept as a tree.
516
+ * Write a scanned artifact's sidecar into `dir`, whose outputs an extract
517
+ * just wrote: an ingested remote hit kept as a tree.
529
518
  */
530
- export async function sealTree(
531
- dir: string,
532
- scan: ArtifactScan,
533
- /** The archive's own sidecar text and checked sum (`extractArtifactStream`'s `onScan`). */
534
- raw?: { meta: string; sum: string },
535
- ): Promise<void> {
519
+ export async function sealTree(dir: string, scan: ArtifactScan): Promise<void> {
536
520
  // An artifact with no outputs extracts nothing, so nothing made `dir`.
537
521
  await mkdir(dir, { recursive: true })
538
522
  const meta: MetaFile = { version: 1, files: {} }
@@ -543,20 +527,7 @@ export async function sealTree(
543
527
  if (scan.dirs.length > 0) meta.dirs = scan.dirs
544
528
  if (scan.links.length > 0) meta.links = Object.fromEntries(scan.links)
545
529
  if (scan.exec !== undefined) meta.exec = scan.exec
546
- const text = JSON.stringify(meta)
547
- await writeFile(path.join(dir, META_ENTRY), text)
548
- // The tree packs back to the archive it came from when the sidecar is
549
- // the same bytes and the entries came in its order (stdout first, the
550
- // sidecar last): the sum the extract checked is then the tree's, and the
551
- // tree is not read back to sum it.
552
- const names = Object.keys(meta.files)
553
- const same =
554
- raw !== undefined &&
555
- raw.meta === text &&
556
- scan.entries[0]?.name === STDOUT_ENTRY &&
557
- scan.entries.length === names.length + 1 &&
558
- names.every((n, i) => scan.entries[i + 1]!.name === n)
559
- await writeFile(path.join(dir, TREE_SUM), same ? raw.sum : await treeSum(dir, scan.stdout ?? ''))
530
+ await writeFile(path.join(dir, META_ENTRY), JSON.stringify(meta))
560
531
  }
561
532
 
562
533
  /** The plan that packs the tree in `dir` back into the artifact it holds. */
@@ -579,28 +550,6 @@ export function planOfTree(dir: string, stdout: string): ArtifactPlan {
579
550
  return { inputs, size: tarSize([...inputs, sumInput('0'.repeat(SUM_SIZE))]) }
580
551
  }
581
552
 
582
- /** The sum over the tree's members, as `SUM_ENTRY` defines it for the tar it packs to. */
583
- async function treeSum(dir: string, stdout: string): Promise<string> {
584
- const sum = new EntrySum()
585
- for (const i of planOfTree(dir, stdout).inputs) {
586
- sum.entry(i.name)
587
- if (i.body instanceof Blob && i.size > 1 << 20)
588
- for await (const c of i.body.stream()) sum.add(c)
589
- else if (i.body instanceof Blob) sum.add(await i.body.bytes())
590
- else sum.add(i.body as Uint8Array)
591
- }
592
- return sum.hex()
593
- }
594
-
595
- /** Whether the tree's bytes still sum to what was stored (`cache.store.verify: 'hash'`). */
596
- export async function treeIntact(dir: string, stdout: string): Promise<boolean> {
597
- try {
598
- return (await treeSum(dir, stdout)) === readFileSync(path.join(dir, TREE_SUM), 'utf8')
599
- } catch {
600
- return false
601
- }
602
- }
603
-
604
553
  /** What a scan of an artifact reads out of it. */
605
554
  interface ArtifactScan {
606
555
  entries: ArchiveEntry[]
@@ -794,12 +743,8 @@ export async function extractArtifactStream(
794
743
  accept?: (name: string) => boolean,
795
744
  /** The workspace root whose `.vx/tmp/` stages the entries; absent, each stages beside its target. */
796
745
  stageRoot?: string,
797
- /**
798
- * Handed what `scanArtifact` would read of the same bytes, once their sum
799
- * has checked, with the sidecar's text and the sum when the sidecar came
800
- * last: what `sealTree` may reuse rather than read the tree back.
801
- */
802
- onScan?: (scan: ArtifactScan, raw: { meta: string; sum: string } | undefined) => void,
746
+ /** Handed what `scanArtifact` would read of the same bytes, once their sum has checked. */
747
+ onScan?: (scan: ArtifactScan) => void,
803
748
  ): Promise<Set<string>> {
804
749
  // No `mkdir -p` of the destination here: `stage` creates each entry's
805
750
  // directory chain, the destination included, and the cache restores
@@ -813,20 +758,15 @@ export async function extractArtifactStream(
813
758
  let dirs: string[] = []
814
759
  let links: Array<[string, string]> = []
815
760
  const fold = onScan === undefined ? undefined : new ScanFold()
816
- let metaText: string | undefined
817
- let metaLast = false
818
761
  try {
819
762
  const check = new SumCheck()
820
763
  for await (const e of tarEntries(tar)) {
821
764
  if (e.type !== '0') continue
822
765
  const body = await check.body(e.name, e.body)
823
766
  if (body === null) continue
824
- metaLast = false
825
767
  if (e.name === META_ENTRY) {
826
768
  const text = await textOf(body)
827
769
  fold?.sidecar(text)
828
- metaText = text
829
- metaLast = true
830
770
  const parsed = JSON.parse(text) as MetaFile
831
771
  meta = parsed.files ?? {}
832
772
  dirs = dirsOf(parsed)
@@ -855,11 +795,7 @@ export async function extractArtifactStream(
855
795
  await x.stage(e.name, dest.base, target, e.size <= SMALL_ENTRY ? await bytesOf(body) : body)
856
796
  }
857
797
  check.done()
858
- if (fold !== undefined)
859
- onScan!(
860
- fold.result(),
861
- metaLast && metaText !== undefined ? { meta: metaText, sum: check.checked } : undefined,
862
- )
798
+ if (fold !== undefined) onScan!(fold.result())
863
799
  for (const name of dirs) {
864
800
  if (accept !== undefined && !accept(name)) {
865
801
  throw new ArchiveSecurityError(
@@ -78,7 +78,6 @@ import {
78
78
  planArtifact,
79
79
  planOfTree,
80
80
  sealTree,
81
- treeIntact,
82
81
  writeTree,
83
82
  } from './archive.js'
84
83
  import { readUmask, restoredMode } from './umask.js'
@@ -814,14 +813,11 @@ export interface StoreOptions {
814
813
  embeddings: number
815
814
  /** A file-backed entry is a `<hash>.tree` of plain files; off, one `<hash>.tar.zst`. */
816
815
  decompress: boolean
817
- /** What a tree restore checks first: each file's size and mtime, every byte, or nothing. */
818
- verify: 'stat' | 'hash' | 'none'
819
816
  }
820
817
 
821
818
  export const DEFAULT_STORE: StoreOptions = {
822
819
  embeddings: 32 * 1024,
823
820
  decompress: true,
824
- verify: 'stat',
825
821
  }
826
822
 
827
823
  /** What a run's background uploads moved: totals over the ones that landed. */
@@ -859,6 +855,8 @@ export class Cache implements CacheLayer {
859
855
  * so the run can report them without a method on the layer seam.
860
856
  */
861
857
  readonly uploads: UploadTally = { count: 0, bytes: 0, ms: 0, failed: 0 }
858
+ /** The run's hits pulled from those remotes, and their bytes on the wire. */
859
+ readonly downloads: { count: number; bytes: number } = { count: 0, bytes: 0 }
862
860
  /** The same remotes' traffic per key, filled by the same `LayeredCache`. */
863
861
  readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map() }
864
862
 
@@ -1924,9 +1922,8 @@ export class Cache implements CacheLayer {
1924
1922
  }
1925
1923
 
1926
1924
  /**
1927
- * Restore a tree-form entry: its files checked as `cache.store.verify`
1928
- * says, then copied (or cloned) out one by one where the outputs are
1929
- * absent, as on every restore after a clean. Anything else (a tree to
1925
+ * Restore a tree-form entry: its files copied (or cloned) out one by
1926
+ * one where the outputs are absent, as on every restore after a clean. Anything else (a tree to
1930
1927
  * restore into, a workspace output, a link) goes through the extractor
1931
1928
  * the tar form uses, fed the tree packed back into its tar.
1932
1929
  */
@@ -1948,21 +1945,6 @@ export class Cache implements CacheLayer {
1948
1945
  `its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
1949
1946
  )
1950
1947
  }
1951
- if (this.store.verify === 'stat') {
1952
- const changed = await Promise.all(
1953
- files.map((r) =>
1954
- stat(path.join(tree, treeName(r.path))).then(
1955
- (st) => st.size !== r.size || Math.floor(st.mtimeMs) !== r.mtimeMs,
1956
- () => true,
1957
- ),
1958
- ),
1959
- )
1960
- if (changed.includes(true)) {
1961
- throw new CorruptArtifactError(hash, 'a stored file changed since it was saved')
1962
- }
1963
- } else if (this.store.verify === 'hash' && !(await treeIntact(tree, this.stdoutText(hash)))) {
1964
- throw new CorruptArtifactError(hash, 'the stored files no longer sum to what was saved')
1965
- }
1966
1948
  if (await this.copyTree(tree, rows, files, projectDir, accept)) return
1967
1949
  await extractArtifactStream(
1968
1950
  packArtifactStream(planOfTree(tree, this.stdoutText(hash))),
@@ -2699,7 +2681,6 @@ export class Cache implements CacheLayer {
2699
2681
  const dir = this.tempTreePath(hash)
2700
2682
  tree = dir
2701
2683
  let scan: Awaited<ReturnType<typeof scanArtifact>> | undefined
2702
- let raw: { meta: string; sum: string } | undefined
2703
2684
  await extractArtifactStream(
2704
2685
  await decodedTar(
2705
2686
  compressed instanceof Uint8Array ? compressed : Bun.file(tmpPath!),
@@ -2711,13 +2692,12 @@ export class Cache implements CacheLayer {
2711
2692
  undefined,
2712
2693
  undefined,
2713
2694
  undefined,
2714
- (s, r) => {
2695
+ (s) => {
2715
2696
  scan = s
2716
- raw = r
2717
2697
  },
2718
2698
  )
2719
2699
  scanned = scan!
2720
- await sealTree(dir, scanned, raw)
2700
+ await sealTree(dir, scanned)
2721
2701
  } else if (tar !== undefined) {
2722
2702
  // Our own tar, in memory: read in place, on this thread.
2723
2703
  scanned = scanTarBytes(tar)
@@ -82,14 +82,13 @@ export {
82
82
  type BlobSizeMemo,
83
83
  } from './git-inputs.js'
84
84
  export {
85
+ type ArtifactInfo,
85
86
  LayeredCache,
86
87
  type RemoteCacheLayer,
87
- type RemoteKey,
88
88
  type RemoteOpenContext,
89
89
  } from './layered-cache.js'
90
90
  export { networkConcurrency } from './remote-lane.js'
91
91
  export type { ResolvedRemote } from './remote-lane.js'
92
- export { BranchScope } from './scoped-cache.js'
93
92
  export {
94
93
  absentPluginCache,
95
94
  pluginCache,
@@ -5,6 +5,7 @@
5
5
  // reference to `Cache` below is a type (the local handle a layer may wrap).
6
6
 
7
7
  import type { Cache } from './cache.js'
8
+ import type { ArtifactInfo } from './layered-cache.js'
8
9
  import { fsClockLag, stampGrainMs } from './fs-clock.js'
9
10
 
10
11
  export interface CacheKeyInput {
@@ -550,12 +551,12 @@ export interface CacheLayer {
550
551
  */
551
552
  readonly hasRemote?: boolean
552
553
  /**
553
- * Batch existence probe over the remote — the subset of `hashes` stored
554
- * remotely, in ONE round-trip, or `null` for "no batch info; use
555
- * per-hash". Only meaningful with `hasRemote`; a remote layer that can't
554
+ * Batch existence probe over the remote — the `hashes` stored remotely
555
+ * and what the first remote holding each knows of it (`ArtifactInfo`), in
556
+ * ONE round-trip, or `null` for "no batch info; use per-hash". Only meaningful with `hasRemote`; a remote layer that can't
556
557
  * batch omits it and the prefetch pass falls back to per-hash GETs.
557
558
  */
558
- remoteHasMany?(hashes: readonly string[]): Promise<Set<string> | null>
559
+ remoteHasMany?(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null>
559
560
  /**
560
561
  * The subset of `hashes` the local store already holds. The prefetch
561
562
  * pass probes the remote only for the rest, so a run the local cache
@@ -581,8 +582,6 @@ export interface CacheLayer {
581
582
  * about to call absent. Only meaningful with `hasRemote`.
582
583
  */
583
584
  probeFirst?(started: Promise<void>): void
584
- /** `taskId`'s key is `hash`: a scoped run picks each remote key by its task (`BranchScope`). */
585
- noteKey?(taskId: string, hash: string): void
586
585
  /**
587
586
  * Resolve once every background write-through upload settles. The
588
587
  * orchestrator awaits it before `close()` — an upload reading layer state