@vzn/vx 0.0.643 → 0.0.644

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 (43) hide show
  1. package/package.json +5 -5
  2. package/schemas/info.json +0 -11
  3. package/skills/vx/SKILL.md +1 -1
  4. package/src/cache/archive.ts +153 -2
  5. package/src/cache/cache.ts +416 -176
  6. package/src/cache/file-hashes.ts +2 -14
  7. package/src/cache/fold-memo.ts +7 -3
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/key-fold.ts +4 -1
  10. package/src/cache/layer.ts +9 -4
  11. package/src/cache/layered-cache.ts +55 -3
  12. package/src/cache/plugin-cache.ts +33 -32
  13. package/src/cache/remote-lane.ts +12 -7
  14. package/src/cache/tar-stream.ts +12 -7
  15. package/src/cli/format.ts +1 -2
  16. package/src/cli/help.ts +6 -0
  17. package/src/cli/info.ts +2 -19
  18. package/src/cli/plugin-commands.ts +1 -1
  19. package/src/cli/run.ts +3 -0
  20. package/src/cli/upgrade.ts +38 -8
  21. package/src/config.ts +15 -10
  22. package/src/exec/sandbox-paths.ts +2 -2
  23. package/src/graph/scheduler.ts +3 -1
  24. package/src/graph/task-graph.ts +15 -11
  25. package/src/index.ts +1 -1
  26. package/src/orchestrator/doctor.ts +0 -17
  27. package/src/orchestrator/execute-task.ts +1 -1
  28. package/src/orchestrator/options.ts +6 -0
  29. package/src/orchestrator/plugin-host.ts +6 -2
  30. package/src/orchestrator/plugin.ts +4 -1
  31. package/src/orchestrator/prepare.ts +16 -3
  32. package/src/orchestrator/projects.ts +8 -1
  33. package/src/orchestrator/remote-prefetch.ts +11 -4
  34. package/src/orchestrator/run-context.ts +10 -5
  35. package/src/orchestrator/run-settings.ts +3 -0
  36. package/src/orchestrator/run.ts +8 -26
  37. package/src/report/logger.ts +0 -4
  38. package/src/report/summary.ts +0 -11
  39. package/src/settings.ts +43 -14
  40. package/src/util/index.ts +1 -0
  41. package/src/util/paths.ts +42 -33
  42. package/src/util/size.ts +4 -5
  43. package/src/workspace/config-schema.ts +0 -36
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.643",
3
+ "version": "0.0.644",
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.643",
25
- "@vzn/vx-linux-arm64": "0.0.643",
26
- "@vzn/vx-darwin-x64": "0.0.643",
27
- "@vzn/vx-darwin-arm64": "0.0.643"
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"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/schemas/info.json CHANGED
@@ -26,7 +26,6 @@
26
26
  "cacheEntries",
27
27
  "cacheBytes",
28
28
  "settings",
29
- "cacheRetention",
30
29
  "orphans",
31
30
  "runs24h",
32
31
  "hits24h",
@@ -105,16 +104,6 @@
105
104
  }
106
105
  }
107
106
  },
108
- "cacheRetention": {
109
- "type": ["object", "null"],
110
- "description": "cache.retention from its variables, else the workspace, maxSize also in bytes; null when none is declared",
111
- "additionalProperties": false,
112
- "properties": {
113
- "olderThan": { "type": "string" },
114
- "maxSize": { "type": "string" },
115
- "maxBytes": { "type": "integer" }
116
- }
117
- },
118
107
  "orphans": {
119
108
  "type": "object",
120
109
  "additionalProperties": false,
@@ -110,4 +110,4 @@ vx info --format json # vx and Bun versions, projects, config errors, plugins,
110
110
  has installed, copy it from there instead:
111
111
  `cp node_modules/@vzn/vx/skills/vx/SKILL.md .claude/skills/vx/`.
112
112
  `vx upgrade` inside a workspace moves the project's `@vzn/vx` and the
113
- global vx that ran it to the same version.
113
+ global vx that ran it to the same version, then runs `vx lock`.
@@ -70,6 +70,7 @@ import {
70
70
  stat,
71
71
  symlink,
72
72
  unlink,
73
+ utimes,
73
74
  writeFile,
74
75
  } from 'node:fs/promises'
75
76
  import path from 'node:path'
@@ -162,6 +163,8 @@ class SumCheck {
162
163
  add(bytes: Uint8Array): void {
163
164
  this.sum.add(bytes)
164
165
  }
166
+ /** The checked sum's text, once `verify` passed. */
167
+ checked = ''
165
168
  verify(got: string): void {
166
169
  if (got !== this.sum.hex()) {
167
170
  throw new TarFormatError(
@@ -169,6 +172,7 @@ class SumCheck {
169
172
  )
170
173
  }
171
174
  this.seen = true
175
+ this.checked = got
172
176
  }
173
177
  done(): void {
174
178
  if (!this.seen) throw new TarFormatError('artifact carries no checksum')
@@ -471,6 +475,132 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
471
475
  })
472
476
  }
473
477
 
478
+ /**
479
+ * The tree form (`cache.store.decompress`, v47): an artifact's members
480
+ * 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
+ /**
489
+ * Write `plan`'s outputs into the empty `dir` as the tree form, each copied
490
+ * by the kernel (`copy_file_range`, or a clone where the filesystem has one)
491
+ * and given its sidecar mtime, and return what a scan of the plan's tar
492
+ * would: the save indexes from it without packing a tar.
493
+ */
494
+ export async function writeTree(plan: ArtifactPlan, dir: string): Promise<ArtifactScan> {
495
+ const fold = new ScanFold()
496
+ const meta = plan.inputs.find((i) => i.name === META_ENTRY)!.body as Uint8Array
497
+ const metaText = utf8.decode(meta)
498
+ fold.sidecar(metaText)
499
+ const times = (JSON.parse(metaText) as MetaFile).files
500
+ for (const i of plan.inputs) {
501
+ if (i.name === META_ENTRY) continue
502
+ assertSafeName(i.name)
503
+ if (i.name === STDOUT_ENTRY) fold.stdout = utf8.decode(i.body as Uint8Array)
504
+ fold.entry(i.name, i.size, times[i.name]?.[1] ?? 0)
505
+ }
506
+ // A stdout-only entry writes no file to make the directory.
507
+ await mkdir(dir, { recursive: true })
508
+ await Promise.all(
509
+ plan.inputs.map(async (i) => {
510
+ if (!(i.body instanceof Blob)) return
511
+ const to = path.join(dir, i.name)
512
+ await Bun.write(to, i.body)
513
+ const at = new Date(times[i.name]![1])
514
+ await utimes(to, at, at)
515
+ }),
516
+ )
517
+ await writeFile(path.join(dir, META_ENTRY), meta)
518
+ await writeFile(path.join(dir, TREE_SUM), await treeSum(dir, stdoutOf(plan)))
519
+ return fold.result()
520
+ }
521
+
522
+ function stdoutOf(plan: ArtifactPlan): string {
523
+ return utf8.decode(plan.inputs.find((i) => i.name === STDOUT_ENTRY)!.body as Uint8Array)
524
+ }
525
+
526
+ /**
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.
529
+ */
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> {
536
+ // An artifact with no outputs extracts nothing, so nothing made `dir`.
537
+ await mkdir(dir, { recursive: true })
538
+ const meta: MetaFile = { version: 1, files: {} }
539
+ if (scan.key !== undefined) meta.key = scan.key
540
+ for (const e of scan.entries) {
541
+ if (e.name !== STDOUT_ENTRY) meta.files[e.name] = [e.mode, e.mtimeMs]
542
+ }
543
+ if (scan.dirs.length > 0) meta.dirs = scan.dirs
544
+ if (scan.links.length > 0) meta.links = Object.fromEntries(scan.links)
545
+ 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 ?? ''))
560
+ }
561
+
562
+ /** The plan that packs the tree in `dir` back into the artifact it holds. */
563
+ export function planOfTree(dir: string, stdout: string): ArtifactPlan {
564
+ const metaBytes = readFileSync(path.join(dir, META_ENTRY))
565
+ const meta = JSON.parse(utf8.decode(metaBytes)) as MetaFile
566
+ const out = new TextEncoder().encode(stdout)
567
+ const inputs: TarInput[] = [{ name: STDOUT_ENTRY, size: out.byteLength, body: out }]
568
+ for (const [name, [mode, mtimeMs]] of Object.entries(meta.files)) {
569
+ const file = Bun.file(path.join(dir, name))
570
+ inputs.push({
571
+ name,
572
+ size: statSync(path.join(dir, name)).size,
573
+ mode,
574
+ mtime: Math.min(USTAR_MTIME_MAX, Math.max(0, Math.floor(mtimeMs / 1000))),
575
+ body: file,
576
+ })
577
+ }
578
+ inputs.push({ name: META_ENTRY, size: metaBytes.byteLength, body: new Uint8Array(metaBytes) })
579
+ return { inputs, size: tarSize([...inputs, sumInput('0'.repeat(SUM_SIZE))]) }
580
+ }
581
+
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
+
474
604
  /** What a scan of an artifact reads out of it. */
475
605
  interface ArtifactScan {
476
606
  entries: ArchiveEntry[]
@@ -664,6 +794,12 @@ export async function extractArtifactStream(
664
794
  accept?: (name: string) => boolean,
665
795
  /** The workspace root whose `.vx/tmp/` stages the entries; absent, each stages beside its target. */
666
796
  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,
667
803
  ): Promise<Set<string>> {
668
804
  // No `mkdir -p` of the destination here: `stage` creates each entry's
669
805
  // directory chain, the destination included, and the cache restores
@@ -676,14 +812,22 @@ export async function extractArtifactStream(
676
812
  let meta: MetaFile['files'] = {}
677
813
  let dirs: string[] = []
678
814
  let links: Array<[string, string]> = []
815
+ const fold = onScan === undefined ? undefined : new ScanFold()
816
+ let metaText: string | undefined
817
+ let metaLast = false
679
818
  try {
680
819
  const check = new SumCheck()
681
820
  for await (const e of tarEntries(tar)) {
682
821
  if (e.type !== '0') continue
683
822
  const body = await check.body(e.name, e.body)
684
823
  if (body === null) continue
824
+ metaLast = false
685
825
  if (e.name === META_ENTRY) {
686
- const parsed = JSON.parse(await textOf(body)) as MetaFile
826
+ const text = await textOf(body)
827
+ fold?.sidecar(text)
828
+ metaText = text
829
+ metaLast = true
830
+ const parsed = JSON.parse(text) as MetaFile
687
831
  meta = parsed.files ?? {}
688
832
  dirs = dirsOf(parsed)
689
833
  links = linksOf(parsed)
@@ -697,9 +841,11 @@ export async function extractArtifactStream(
697
841
  `archive entry is not one of the task's declared outputs: ${e.name}`,
698
842
  )
699
843
  }
844
+ fold?.entry(e.name, e.size, e.mtimeMs)
700
845
  const dest = x.destFor(e.name)
701
846
  if (dest === null || dest.rel.length === 0) {
702
- await drain(body)
847
+ if (fold !== undefined && e.name === STDOUT_ENTRY) fold.stdout = await textOf(body)
848
+ else await drain(body)
703
849
  continue
704
850
  }
705
851
  const target = path.join(dest.base, dest.rel)
@@ -709,6 +855,11 @@ export async function extractArtifactStream(
709
855
  await x.stage(e.name, dest.base, target, e.size <= SMALL_ENTRY ? await bytesOf(body) : body)
710
856
  }
711
857
  check.done()
858
+ if (fold !== undefined)
859
+ onScan!(
860
+ fold.result(),
861
+ metaLast && metaText !== undefined ? { meta: metaText, sum: check.checked } : undefined,
862
+ )
712
863
  for (const name of dirs) {
713
864
  if (accept !== undefined && !accept(name)) {
714
865
  throw new ArchiveSecurityError(