@vzn/vx 0.0.642 → 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 (52) 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 +417 -183
  6. package/src/cache/file-hashes.ts +2 -14
  7. package/src/cache/fold-memo.ts +7 -3
  8. package/src/cache/index.ts +10 -2
  9. package/src/cache/key-fold.ts +4 -1
  10. package/src/cache/layer.ts +11 -4
  11. package/src/cache/layered-cache.ts +82 -8
  12. package/src/cache/plugin-cache.ts +51 -43
  13. package/src/cache/remote-lane.ts +18 -13
  14. package/src/cache/run-store.ts +4 -1
  15. package/src/cache/scoped-cache.ts +74 -239
  16. package/src/cache/tar-stream.ts +12 -7
  17. package/src/cli/format.ts +1 -2
  18. package/src/cli/help.ts +8 -4
  19. package/src/cli/info.ts +2 -19
  20. package/src/cli/plugin-commands.ts +11 -6
  21. package/src/cli/run.ts +7 -12
  22. package/src/cli/select.ts +7 -9
  23. package/src/cli/upgrade.ts +38 -8
  24. package/src/cli/workspace-config.ts +8 -2
  25. package/src/config.ts +22 -26
  26. package/src/exec/sandbox-paths.ts +2 -2
  27. package/src/graph/scheduler.ts +3 -1
  28. package/src/graph/task-graph.ts +15 -11
  29. package/src/index.ts +5 -1
  30. package/src/orchestrator/affected-tasks.ts +23 -2
  31. package/src/orchestrator/doctor.ts +0 -17
  32. package/src/orchestrator/execute-task.ts +1 -1
  33. package/src/orchestrator/index.ts +4 -0
  34. package/src/orchestrator/options.ts +15 -1
  35. package/src/orchestrator/plugin-host.ts +11 -4
  36. package/src/orchestrator/plugin.ts +23 -2
  37. package/src/orchestrator/prepare.ts +81 -18
  38. package/src/orchestrator/projects.ts +26 -5
  39. package/src/orchestrator/remote-prefetch.ts +11 -4
  40. package/src/orchestrator/run-context.ts +85 -0
  41. package/src/orchestrator/run-settings.ts +3 -0
  42. package/src/orchestrator/run.ts +9 -26
  43. package/src/orchestrator/task-hash.ts +6 -0
  44. package/src/report/logger.ts +0 -4
  45. package/src/report/summary.ts +3 -11
  46. package/src/settings.ts +54 -25
  47. package/src/util/index.ts +1 -0
  48. package/src/util/paths.ts +42 -33
  49. package/src/util/size.ts +4 -5
  50. package/src/workspace/affected.ts +120 -63
  51. package/src/workspace/config-schema.ts +44 -52
  52. package/src/workspace/index.ts +3 -0
@@ -21,6 +21,7 @@ import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/p
21
21
  import path from 'node:path'
22
22
  import { flagHint, seeHelp } from './help.js'
23
23
  import { UserError, sayNotice, ui } from '../util/index.js'
24
+ import { findWorkspaceRoot } from '../workspace/index.js'
24
25
  import { VERSION } from '../version.js'
25
26
 
26
27
  const REGISTRY = 'https://registry.npmjs.org'
@@ -596,11 +597,11 @@ export async function upgradeManaged(
596
597
  version: string,
597
598
  current = VERSION,
598
599
  step = '',
599
- ): Promise<void> {
600
+ ): Promise<boolean> {
600
601
  const plugins = install.plugins ?? []
601
602
  if (version === current && plugins.every((p) => installedVersion(install.cwd, p) === version)) {
602
603
  ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
603
- return
604
+ return false
604
605
  }
605
606
  const argv = upgradeArgv(install, version)
606
607
  // PATH as it is now: `Bun.which` alone reads the one the process started with.
@@ -636,6 +637,7 @@ export async function upgradeManaged(
636
637
  }`,
637
638
  )
638
639
  }
640
+ return true
639
641
  }
640
642
 
641
643
  /**
@@ -648,7 +650,7 @@ async function upgradeBinary(
648
650
  current = VERSION,
649
651
  step = '',
650
652
  key: SigningKey = NPM_SIGNING_KEY,
651
- ): Promise<void> {
653
+ ): Promise<boolean> {
652
654
  const pkg = platformPackage()
653
655
  let installed: string | undefined
654
656
  const replaced = await replaceBinary(
@@ -679,6 +681,7 @@ async function upgradeBinary(
679
681
  ? ui.status('ok', 'vx upgrade', `${step}installed ${installed}`)
680
682
  : ui.status('info', 'vx upgrade', `${step}already at ${current}`),
681
683
  )
684
+ return replaced
682
685
  }
683
686
 
684
687
  /** The version `bin --version` answers, without the hand-off note a handed-off run adds. */
@@ -738,9 +741,9 @@ export async function upgradeOnlyGlobal(
738
741
  }
739
742
 
740
743
  /** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
741
- async function named(what: string, run: () => Promise<void>): Promise<void> {
744
+ async function named<T>(what: string, run: () => Promise<T>): Promise<T> {
742
745
  try {
743
- await run()
746
+ return await run()
744
747
  } catch (err) {
745
748
  if (!(err instanceof UserError)) throw err
746
749
  throw new UserError(err.message.replace(/^vx upgrade: /, `vx upgrade: ${what}: `), err.code)
@@ -787,19 +790,46 @@ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
787
790
  const from = handedOffFrom
788
791
  if (from !== undefined) {
789
792
  const version = await targetVersion(tag)
790
- await named('project vx', () =>
793
+ const moved = await named('project vx', () =>
791
794
  install === null
792
795
  ? upgradeBinary(dest, version, VERSION, 'project ')
793
796
  : upgradeManaged(install, version, VERSION, 'project '),
794
797
  )
795
798
  await upgradeGlobal(from, version)
799
+ if (moved) await relock(newVx(install, dest))
796
800
  return 0
797
801
  }
798
- if (install !== null) await upgradeManaged(install, await targetVersion(tag))
799
- else await upgradeBinary(dest, versionSpec(tag))
802
+ const moved =
803
+ install !== null
804
+ ? await upgradeManaged(install, await targetVersion(tag))
805
+ : await upgradeBinary(dest, versionSpec(tag))
806
+ if (moved) await relock(newVx(install, dest))
800
807
  return 0
801
808
  }
802
809
 
810
+ /**
811
+ * The vx an upgrade just installed: the replaced binary, or the project's
812
+ * `node_modules/.bin/vx` (a manager's store path names the old version).
813
+ */
814
+ const newVx = (install: ManagedInstall | null, dest: string): string =>
815
+ install === null ? dest : path.join(install.cwd, 'node_modules', '.bin', 'vx')
816
+
817
+ /**
818
+ * Lock the workspace with the vx just installed at `bin` (M, 2026-10-18):
819
+ * a release may record the lock differently. `-g` never re-locks. A lock
820
+ * that fails leaves the upgrade done and says what to run.
821
+ */
822
+ export async function relock(bin: string, cwd = process.cwd()): Promise<void> {
823
+ const root = await findWorkspaceRoot(cwd).catch(() => null)
824
+ if (root === null) return
825
+ const proc = Bun.spawnSync({
826
+ cmd: [bin, 'lock'],
827
+ cwd: root,
828
+ stdio: ['inherit', 'inherit', 'inherit'],
829
+ })
830
+ if (proc.exitCode !== 0) sayNotice('warn', 'vx upgrade', 'vx lock failed: run vx lock')
831
+ }
832
+
803
833
  /**
804
834
  * `bin --version` when it answers as vx within `timeoutMs`, else null. The
805
835
  * answer decides whether a replaced binary is rolled back, so it is
@@ -15,7 +15,7 @@ import {
15
15
  lockOfDiscovery,
16
16
  lockReader,
17
17
  } from '../orchestrator/index.js'
18
- import type { VxPlugin } from '../orchestrator/index.js'
18
+ import type { BranchFacts, VxPlugin } from '../orchestrator/index.js'
19
19
  import {
20
20
  buildPackageGraph,
21
21
  type ProjectEntry,
@@ -27,6 +27,8 @@ import {
27
27
  interface CliWorkspace {
28
28
  workspaceConfig: WorkspaceConfig | null
29
29
  plugins: readonly VxPlugin[]
30
+ /** What a plugin's `config` stage said of the branch: its cache scope and base commit. */
31
+ branch: BranchFacts
30
32
  cacheDir: string
31
33
  /** The shared store a run opens `cacheDir` with (`resolveStoreRoot`), asked on load. */
32
34
  storeRoot: Promise<string | null>
@@ -37,10 +39,14 @@ export const warnToStderr = (message: string): void => {
37
39
  }
38
40
 
39
41
  export async function loadCliWorkspace(workspaceRoot: string): Promise<CliWorkspace> {
40
- const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnToStderr)
42
+ const { workspaceConfig, plugins, branch } = await loadWorkspacePlugins(
43
+ workspaceRoot,
44
+ warnToStderr,
45
+ )
41
46
  return {
42
47
  workspaceConfig,
43
48
  plugins,
49
+ branch,
44
50
  cacheDir: resolveCacheDir(workspaceRoot, workspaceConfig),
45
51
  storeRoot: resolveStoreRoot(workspaceRoot, workspaceConfig),
46
52
  }
package/src/config.ts CHANGED
@@ -14,6 +14,13 @@ export type Duration = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`
14
14
  * Nothing here is folded into a cache key.
15
15
  */
16
16
  export interface WorkspaceConfig {
17
+ /**
18
+ * The repository's default branch (`'develop'`). A run on it has no
19
+ * cache scope and a bare `--affected` has no default; elsewhere both
20
+ * compare with the merge base of `origin/<baseBranch>` and HEAD.
21
+ * Omitted → `origin/HEAD`'s branch, then `main`, then `master`.
22
+ */
23
+ baseBranch?: string
17
24
  run?: RunSettings
18
25
  cache?: CacheSettings
19
26
  lock?: LockSettings
@@ -42,13 +49,6 @@ export interface RunSettings {
42
49
  * `schedule` plugin's weights still come first; this breaks their ties.
43
50
  */
44
51
  schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
45
- /**
46
- * The git ref a bare `--affected` compares with (`origin/develop`).
47
- * `--affected=<base>` overrides it. Omitted → `origin/HEAD`, then the
48
- * first of `origin/main`, `origin/master`, `main`, `master` that is not
49
- * HEAD, else `HEAD~1`.
50
- */
51
- affectedBase?: string
52
52
  /**
53
53
  * A task's time limit, for tasks without their own `exec.timeout` (which
54
54
  * beats every run-level source). A runaway-process guard: a timed-out
@@ -79,15 +79,6 @@ export interface CacheSettings {
79
79
  * `.vx/cache` and its entries in the user's shared store.
80
80
  */
81
81
  dir?: string
82
- /**
83
- * Evict from the local cache at the end of every run, whatever its
84
- * policy (a remote hit is ingested under any), by the same policy
85
- * `vx cache clean` takes as flags: entries not used for `olderThan`
86
- * (`'30d'`, `'12h'`), then least-recently-used entries until the cache
87
- * is under `maxSize` (`'10GB'`, `'500MB'`). Either or both. Omitted →
88
- * nothing is evicted unless you run `vx cache clean`.
89
- */
90
- retention?: { olderThan?: Duration; maxSize?: string }
91
82
  /**
92
83
  * Which layers a run reads and writes (`'local:rw,remote:r'`; a layer it
93
84
  * leaves out stays on); `--no-cache` and `--force` still apply on top.
@@ -95,15 +86,6 @@ export interface CacheSettings {
95
86
  * first, then each remote in declared order.
96
87
  */
97
88
  policy?: string
98
- /**
99
- * Where this run's cache writes land. Omitted, a run reads and writes the
100
- * default keys. A name (`'pr-123'`) reads the default key first, then the
101
- * scope's, and writes only the scope's, so a PR never writes what the
102
- * default branch reads. Whether a run writes at all is `policy`'s call.
103
- * A client-side convention, not a security boundary: the boundary is a
104
- * cache token the server limits.
105
- */
106
- scope?: string
107
89
  /** Which remote hits land on disk: `all` (the default), only the requested tasks' (`toplevel`), or `none`. */
108
90
  download?: 'all' | 'toplevel' | 'none'
109
91
  /** The `cache.inputStability` of every task that does not set its own. */
@@ -116,6 +98,21 @@ export interface CacheSettings {
116
98
  * both (`'always'`). Only a run whose policy writes a remote keeps any.
117
99
  */
118
100
  upload?: { failures?: 'ci' | 'local' | 'always' }
101
+ /**
102
+ * How the local cache keeps an entry. `embeddings` (default `'32KiB'`):
103
+ * an entry up to this size inside the index; `false`, none. `decompress` (default true): a larger one as its
104
+ * plain files, which a hit copies or clones out with no decode; off, as
105
+ * one `.tar.zst`. `verify` (default `'stat'`): what a hit on plain files
106
+ * checks first, each file's size and mtime, every byte (`'hash'`), or
107
+ * nothing (`'none'`).
108
+ */
109
+ store?: { embeddings?: false | string; decompress?: boolean; verify?: 'stat' | 'hash' | 'none' }
110
+ /**
111
+ * Cache transfers at once. `download` and `upload` (per remote) default
112
+ * to min(96, max(cores × 3, 64)); `restore`, local hits writing their
113
+ * outputs, to twice `run.concurrency`.
114
+ */
115
+ concurrency?: { download?: number; upload?: number; restore?: number }
119
116
  }
120
117
 
121
118
  /** `WorkspaceConfig.lock`. */
@@ -795,7 +792,6 @@ export function defineWorkspace<T extends WorkspaceConfig>(
795
792
  rules?: Known<At<At<T, 'run'>, 'rules'>, WorkspaceRules>
796
793
  }
797
794
  cache?: Known<At<T, 'cache'>, CacheSettings> & {
798
- retention?: Known<At<At<T, 'cache'>, 'retention'>, NonNullable<CacheSettings['retention']>>
799
795
  inputStability?: Known<At<At<T, 'cache'>, 'inputStability'>, InputStability>
800
796
  }
801
797
  lock?: Known<At<T, 'lock'>, LockSettings>
@@ -16,8 +16,8 @@ import path from 'node:path'
16
16
  * move that spelling into the scan, which finds nothing before the task has
17
17
  * written, and turn a working grant into `Read-only file system`. The two
18
18
  * predicates answer different questions: whether a declaration must be
19
- * MATCHED against other declarations (495; `GLOB_WILDCARDS`, where a bracket
20
- * is literal since item 667), and whether a grant can be mounted (here).
19
+ * MATCHED against other declarations (495; `GLOB_WILDCARDS`), and whether a
20
+ * grant can be mounted (here).
21
21
  * Item 577 gave each question one spelling.
22
22
  */
23
23
  export const MOUNT_WILDCARDS = /[*?[\]]/
@@ -239,6 +239,8 @@ export type ContinueMode = 'never' | 'deps-ok' | 'always'
239
239
  export interface ScheduleOptions {
240
240
  nodes: Map<string, TaskNode>
241
241
  concurrency: number
242
+ /** Restore-tier tasks at once; twice `concurrency` unset. */
243
+ restoreConcurrency?: number
242
244
  /**
243
245
  * An aborted run dispatches nothing further: every task not yet
244
246
  * started completes `aborted` (the ones in flight are being killed by
@@ -604,7 +606,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
604
606
  // the two lanes share its one slot, where a lane each let an 80 MB
605
607
  // restore run inside another task's execution (item 1103).
606
608
  let activeRestore = 0
607
- const restoreConcurrency = 2 * concurrency
609
+ const restoreConcurrency = options.restoreConcurrency ?? 2 * concurrency
608
610
  const serial = concurrency === 1
609
611
  const execRoom = (): boolean => (serial ? active + activeRestore < 1 : active < concurrency)
610
612
  const restoreRoom = (): boolean =>
@@ -4,6 +4,7 @@ import {
4
4
  checkIgnoreSync,
5
5
  ignoreProbes,
6
6
  isLiteralPattern,
7
+ literalPath,
7
8
  listedSync,
8
9
  normalizeGlob,
9
10
  relPosix,
@@ -915,15 +916,17 @@ export function outputsOverlap(rawA: string, rawB: string): boolean {
915
916
  // A built path proves overlap only between globs as written: a literal's
916
917
  // `/**` twin reads it as a directory, and `dist/types.d.ts` met
917
918
  // `dist/**/*.js` at `dist/types.d.ts/x.js`, a file no one can write.
918
- const bothGlobs = !isLiteralPattern(rawA) && !isLiteralPattern(rawB)
919
+ const bothGlobs = literalPath(rawA) === null && literalPath(rawB) === null
919
920
  for (const a of asTrees([rawA])) {
920
921
  for (const b of asTrees([rawB])) {
921
- if (isLiteralPattern(a) && isLiteralPattern(b)) {
922
- if (a === b) return true
923
- } else if (isLiteralPattern(a)) {
924
- if (taskGlob(b).match(a)) return true
925
- } else if (isLiteralPattern(b)) {
926
- if (taskGlob(a).match(b)) return true
922
+ const la = literalPath(a)
923
+ const lb = literalPath(b)
924
+ if (la !== null && lb !== null) {
925
+ if (la === lb) return true
926
+ } else if (la !== null) {
927
+ if (taskGlob(b).match(la)) return true
928
+ } else if (lb !== null) {
929
+ if (taskGlob(a).match(lb)) return true
927
930
  } else if (a === b || covers(a, b) || covers(b, a) || (bothGlobs && witnessed(a, b)))
928
931
  return true
929
932
  }
@@ -1202,13 +1205,13 @@ function collideAcross(
1202
1205
  * found no match outside the head this cuts, and thousands once `\` and
1203
1206
  * `!` were left out (item 746).
1204
1207
  */
1205
- const GLOB_HEAD_END = /[*?{}\\!]/
1208
+ const GLOB_HEAD_END = /[*?{}[\]\\!]/
1206
1209
 
1207
1210
  /** `firstSegment`'s answer for a glob that can start anywhere. */
1208
1211
  const ANYWHERE = ''
1209
1212
 
1210
1213
  /** Where a glob's first segment ends: a separator, or anything `GLOB_HEAD_END` stops at. */
1211
- const FIRST_SEGMENT_END = /[*?{}\\!/]/
1214
+ const FIRST_SEGMENT_END = /[*?{}[\]\\!/]/
1212
1215
 
1213
1216
  const firstSegments = new Map<string, string>()
1214
1217
 
@@ -1315,8 +1318,9 @@ function overlapCandidates<T>(
1315
1318
  const s = sideOf?.(tasks[i]!) === true ? 1 : 0
1316
1319
  side.push(s)
1317
1320
  for (const tree of asTrees(globsOf(tasks[i]!) ?? [])) {
1318
- if (isLiteralPattern(tree)) {
1319
- literals.push([i, tree])
1321
+ const lit = literalPath(tree)
1322
+ if (lit !== null) {
1323
+ literals.push([i, lit])
1320
1324
  continue
1321
1325
  }
1322
1326
  file(globs[s]!, tree, i)
package/src/index.ts CHANGED
@@ -20,7 +20,7 @@ export { PLUGIN_HOOKS, type PluginHook } from './config.js'
20
20
  // the second, so the cgroup walk has one implementation.
21
21
  export { machineMemoryBytes, machineParallelism } from './util/index.js'
22
22
  // How core reads a task glob — its spelling, and which characters are
23
- // wildcards (`[` `]` are not; item 667) — for a plugin that turns declared
23
+ // wildcards (`[` `]` and an escape are) — for a plugin that turns declared
24
24
  // outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
25
25
  export { isLiteralPattern, normalizeGlob, restorableOutput } from './util/index.js'
26
26
 
@@ -46,6 +46,7 @@ export type {
46
46
  } from './config.js'
47
47
  export { defineProject, defineWorkspace } from './config.js'
48
48
  export { definePlugin, refuseUnknownOptions } from './orchestrator/index.js'
49
+ export { branchCacheScope } from './workspace/index.js'
49
50
 
50
51
  // Programmatic engine API: run / plan / prepare (docs/cli.md § Programmatic
51
52
  // API). The graph primitives, the cache-key hashing seam and the git / host
@@ -83,6 +84,7 @@ export type {
83
84
  CacheLayer,
84
85
  PluginCache,
85
86
  RemoteCacheLayer,
87
+ RemoteKey,
86
88
  RemoteOpenContext,
87
89
  RunRecord,
88
90
  InvocationRecord,
@@ -171,6 +173,8 @@ export type {
171
173
  ScheduleHookContext,
172
174
  AdmitContext,
173
175
  WorkspaceHookContext,
176
+ ConfigContext,
177
+ BranchFacts,
174
178
  DiscoverContext,
175
179
  DiscoveredProject,
176
180
  PluginSetupContext,
@@ -41,6 +41,7 @@ const KEYED_BY_EVERY_TASK: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAME
41
41
  function wholeProjects(
42
42
  changes: AffectedChanges,
43
43
  projects: ReadonlyMap<string, ProjectEntry>,
44
+ byKeys: boolean,
44
45
  ): Map<string, string | undefined> {
45
46
  // Each whole project, with the path no task claims when one did it.
46
47
  const whole = new Map<string, string | undefined>([...changes.whole].map((n) => [n, undefined]))
@@ -50,7 +51,7 @@ function wholeProjects(
50
51
  (t) => t.exec !== undefined && t.inputs !== undefined,
51
52
  )
52
53
  const unclaimed = (rel: string): boolean =>
53
- KEYED_BY_EVERY_TASK.has(rel) || !keyed.some((t) => declaresInput(t, rel, null))
54
+ KEYED_BY_EVERY_TASK.has(rel) || (!byKeys && !keyed.some((t) => declaresInput(t, rel, null)))
54
55
  const rel = rels.find(unclaimed)
55
56
  if (rel !== undefined) whole.set(name, rel)
56
57
  }
@@ -99,8 +100,10 @@ export function affectedRoots(
99
100
  projects: ReadonlyMap<string, ProjectEntry>,
100
101
  packageGraph: PackageGraph,
101
102
  explain?: AffectedExplain,
103
+ /** Only what moves a key: a changed path no task declares reaches no task. */
104
+ byKeys = false,
102
105
  ): string[] {
103
- const whole = wholeProjects(changes, projects)
106
+ const whole = wholeProjects(changes, projects, byKeys)
104
107
  const seeds = explain === undefined ? null : new Map<string, AffectedReason>()
105
108
  const through = explain === undefined ? null : new Map<string, string>()
106
109
  const inProject = (name: string, rel: string | undefined): AffectedReason => {
@@ -226,6 +229,24 @@ export function affectedRoots(
226
229
  return kept
227
230
  }
228
231
 
232
+ /**
233
+ * The tasks of `nodes` whose key the change can move (a branch's cache
234
+ * scope, owner 2026-10-17): a changed path one of its inputs declares, a
235
+ * whole project, or a task its closure holds. Unlike `--affected`, a path
236
+ * no task declares (a README) reaches none: its key is the default
237
+ * branch's, so it reads the default key.
238
+ */
239
+ export function keyedByChange(
240
+ nodes: ReadonlyMap<string, TaskNode>,
241
+ changes: AffectedChanges,
242
+ projects: ReadonlyMap<string, ProjectEntry>,
243
+ packageGraph: PackageGraph,
244
+ ): Set<string> {
245
+ return new Set(
246
+ affectedRoots(nodes, [...nodes.keys()], changes, projects, packageGraph, undefined, true),
247
+ )
248
+ }
249
+
229
250
  /**
230
251
  * The requested tasks `--affected` keeps, in their order: one the user
231
252
  * named (`pkg#task`), one in a project another include selected outright
@@ -18,7 +18,6 @@ import {
18
18
  isUserError,
19
19
  machineMemoryBytes,
20
20
  machineParallelism,
21
- parseSize,
22
21
  treeBytes,
23
22
  } from '../util/index.js'
24
23
  import { vxPath } from '../handoff.js'
@@ -99,8 +98,6 @@ export interface InfoFacts {
99
98
  string,
100
99
  { value: string | number | boolean | null; source: 'env' | 'workspace' | 'default' }
101
100
  >
102
- /** `cache.retention`, with `maxSize` in bytes; null when none is set. */
103
- cacheRetention: { olderThan?: string; maxSize?: string; maxBytes?: number } | null
104
101
  orphans: { artifacts: number; bytes: number }
105
102
  runs24h: number
106
103
  hits24h: number
@@ -261,7 +258,6 @@ async function collectWorkspaceInfo(
261
258
  cacheEntries: stats.entryCount,
262
259
  cacheBytes: stats.totalBytes,
263
260
  settings: settingsFact(workspaceConfig),
264
- cacheRetention: retentionFact(workspaceConfig),
265
261
  // A row-less artifact is indexed again only when its task asks for its
266
262
  // key; the rest only `vx cache clean` reclaims (after an upgrade's
267
263
  // schema reset, most often).
@@ -395,16 +391,3 @@ function settingsFact(config: WorkspaceConfig | null): InfoFacts['settings'] {
395
391
  }
396
392
  return out
397
393
  }
398
-
399
- function retentionFact(config: WorkspaceConfig | null): InfoFacts['cacheRetention'] {
400
- const raw = (path: string): string | undefined =>
401
- process.env[envName(path)] || (atPath(config, path) as string | undefined)
402
- const olderThan = raw('cache.retention.olderThan')
403
- const maxSize = raw('cache.retention.maxSize')
404
- if (olderThan === undefined && maxSize === undefined) return null
405
- return {
406
- ...(olderThan !== undefined ? { olderThan } : {}),
407
- // Validated before the run (env) or at load (workspace), so it parses.
408
- ...(maxSize !== undefined ? { maxSize, maxBytes: parseSize(maxSize)! } : {}),
409
- }
410
- }
@@ -335,7 +335,7 @@ async function restoreProbed(
335
335
 
336
336
  /**
337
337
  * A hit restored, or null when its artifact was removed after the probe (a
338
- * prune, another workspace's retention on a shared cache directory): a
338
+ * `vx cache clean`, here or for a workspace sharing the cache directory): a
339
339
  * miss. The restore has wiped the declared outputs by then; the run path
340
340
  * cleans them again and the task writes them.
341
341
  */
@@ -50,6 +50,8 @@ export {
50
50
  type ScheduleHookContext,
51
51
  type AdmitContext,
52
52
  type WorkspaceHookContext,
53
+ type ConfigContext,
54
+ type BranchFacts,
53
55
  type DiscoverContext,
54
56
  type DiscoveredProject,
55
57
  type InstallPluginsArgs,
@@ -117,6 +119,8 @@ export {
117
119
  captureWorkspaceIdentity,
118
120
  currentBranch,
119
121
  detectCi,
122
+ localBranchScope,
123
+ resolveCacheScope,
120
124
  } from './run-context.js'
121
125
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
122
126
  export {
@@ -90,8 +90,16 @@ export interface RunOptions {
90
90
  * saying it is not.
91
91
  */
92
92
  remoteRequested?: boolean
93
- /** This run's `cache.scope` (the CLI's `--cache-scope`), above `VX_CACHE_SCOPE` and `.vx/workspace`. */
93
+ /** This run's cache scope, above `VX_CACHE_SCOPE`, a plugin's and the branch's (an embedder's). */
94
94
  cacheScope?: string
95
+ /**
96
+ * What the branch changed, for a scoped run: the tasks whose key it can
97
+ * move read the scope's key, every other task the default key (one
98
+ * lookup each). Absent, the run diffs the branch from `scopeBase`.
99
+ */
100
+ scopeChanges?: AffectedChanges
101
+ /** The commit a scoped run diffs the branch from (`--affected`'s base); absent, a plugin's, else the merge base with the default branch. */
102
+ scopeBase?: string
95
103
  /**
96
104
  * The default policy's remote write is off (`remote:r`): the CLI passes it
97
105
  * off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
@@ -122,6 +130,12 @@ export interface RunOptions {
122
130
  * Locally-placed tasks always write in place and ignore this.
123
131
  */
124
132
  download?: 'all' | 'toplevel' | 'none'
133
+ /** Remote downloads at once (`cache.concurrency.download`). */
134
+ downloadConcurrency?: number
135
+ /** Uploads at once, per remote (`cache.concurrency.upload`). */
136
+ uploadConcurrency?: number
137
+ /** Local hits restoring at once (`cache.concurrency.restore`); twice `concurrency` unset. */
138
+ restoreConcurrency?: number
125
139
  /**
126
140
  * Run intent, derived by the CLI from selection flags: 'broad' iff
127
141
  * `--all` / `--filter` / `--affected` was passed, else 'focused'.
@@ -9,6 +9,7 @@
9
9
 
10
10
  import path from 'node:path'
11
11
  import {
12
+ type BranchScope,
12
13
  type Cache,
13
14
  type CacheLayer,
14
15
  type CachePolicy,
@@ -34,7 +35,7 @@ import type {
34
35
  RunReport,
35
36
  ScheduleHookContext,
36
37
  VxPlugin,
37
- WorkspaceHookContext,
38
+ ConfigContext,
38
39
  } from './plugin.js'
39
40
  import type { ProjectMeta } from '../workspace/index.js'
40
41
 
@@ -137,7 +138,7 @@ function assertEditedInPlace(
137
138
  export async function applyConfigHooks(
138
139
  plugins: readonly VxPlugin[],
139
140
  workspace: WorkspaceConfig,
140
- ctx: WorkspaceHookContext,
141
+ ctx: ConfigContext,
141
142
  /** Runs after EACH plugin's edit, so a refusal can name the plugin that made it. */
142
143
  afterEach?: (plugin: VxPlugin) => void,
143
144
  ): Promise<void> {
@@ -555,6 +556,10 @@ export interface CacheHost extends CacheContext {
555
556
  readonly policy: CachePolicy
556
557
  /** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
557
558
  readonly remotes?: ResolvedRemote[]
559
+ /** The run's branch scope, which picks each remote key. */
560
+ readonly branch?: BranchScope
561
+ /** Uploads at once, per remote (`cache.concurrency.upload`). */
562
+ readonly uploadConcurrency?: number
558
563
  }
559
564
 
560
565
  /**
@@ -569,7 +574,7 @@ export async function resolveCache(
569
574
  plugins: readonly VxPlugin[],
570
575
  host: CacheHost,
571
576
  ): Promise<CacheLayer> {
572
- const { localCache, policy, remotes, ...ctx } = host
577
+ const { localCache, policy, remotes, branch, uploadConcurrency, ...ctx } = host
573
578
  const resolved: ResolvedRemote[] = []
574
579
  for (const plugin of plugins) {
575
580
  if (plugin.cache === undefined) continue
@@ -586,7 +591,9 @@ export async function resolveCache(
586
591
  resolved.push(entry)
587
592
  remotes?.push(entry)
588
593
  }
589
- return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved)
594
+ return resolved.length === 0
595
+ ? localCache
596
+ : new LayeredCache(localCache, resolved, branch, uploadConcurrency)
590
597
  }
591
598
 
592
599
  /** The plugin each resolved executor came from, so a hook of it that throws is named. */
@@ -61,8 +61,10 @@ export interface VxPlugin {
61
61
  * The workspace config, before anything is derived from it (concurrency,
62
62
  * cacheDir, timeout, …). `plugins` is already fixed by the time this runs.
63
63
  * Core re-validates after each plugin, as it does a user's file.
64
+ * `ctx.setBranch` names what the CI knows of the branch: its cache scope
65
+ * and the commit it is compared with.
64
66
  */
65
- config?(workspace: WorkspaceConfig, ctx: WorkspaceHookContext): void | Promise<void>
67
+ config?(workspace: WorkspaceConfig, ctx: ConfigContext): void | Promise<void>
66
68
 
67
69
  /**
68
70
  * Projects beyond the directories holding a vx.config, their names, and
@@ -340,6 +342,25 @@ export interface WorkspaceHookContext {
340
342
  warn(message: string): void
341
343
  }
342
344
 
345
+ /**
346
+ * What a CI knows of the run's branch (owner, 2026-10-17): `scope`, the
347
+ * cache scope (`'vznjs/vx:feat-x'`; `VX_CACHE_SCOPE` beats it, and it
348
+ * beats the one git names), and `base`, the commit the branch is compared
349
+ * with (a pull request's base), for a bare `--affected` and for which
350
+ * tasks read the scope. `trusted`: the run has no scope, whatever git
351
+ * names, and reads and writes only the default keys (a merge queue's run,
352
+ * owner 2026-10-18). A later plugin's field wins.
353
+ */
354
+ export interface BranchFacts {
355
+ scope?: string
356
+ base?: string
357
+ trusted?: boolean
358
+ }
359
+
360
+ export interface ConfigContext extends WorkspaceHookContext {
361
+ setBranch(facts: BranchFacts): void
362
+ }
363
+
343
364
  export interface DiscoverContext extends WorkspaceHookContext {
344
365
  /** Where vx's cache lives: a plugin may keep what it read to answer here. */
345
366
  readonly cacheDir: string
@@ -485,7 +506,7 @@ export interface ScheduleHookContext extends BaseContext {
485
506
  /** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
486
507
  export interface CacheContext extends BaseContext {
487
508
  /**
488
- * The run's `cache.scope` (`'pr-123'`), `undefined` when none. Core already keys a scoped run's
509
+ * The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none. Core already keys a scoped run's
489
510
  * writes apart; a remote reads it to pick a credential or check a token's
490
511
  * claims (an OIDC branch) for every call it makes.
491
512
  */