@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
@@ -1,20 +1,12 @@
1
- // The run's `cache.scope` over every layer at once (owner, 2026-10-14): a
2
- // scoped run reads the default key, then the scope's, and writes only the
3
- // scope's, in the local store and every remote alike. Below this wrapper no
4
- // layer knows a scope exists: the scope's key is a key like any other.
1
+ // A branch's cache scope (owner, 2026-10-17): the local store never knows
2
+ // one, so every branch shares one local pool under the task keys. Scopes
3
+ // decide only which remote entry a task asks for: a task the branch changed
4
+ // reads the scope's key, every other task the default key, and a scoped run
5
+ // writes every remote under the scope's key. An artifact is packed under its
6
+ // task key wherever it is stored, so a remote entry ingests under that key.
5
7
 
6
8
  import { xxh3hex } from '../util/index.js'
7
- import type {
8
- CacheEntry,
9
- CacheGetContext,
10
- CacheLayer,
11
- DeclaredOutputs,
12
- IngestMeta,
13
- InvocationRecord,
14
- OutputDirRow,
15
- RunRecord,
16
- SaveArgs,
17
- } from './layer.js'
9
+ import type { RemoteCacheLayer, RemoteKey } from './layered-cache.js'
18
10
 
19
11
  /** The key a scope stores `hash` under: task-key shaped, 16 hex, so every wire stores it unchanged. */
20
12
  export function scopedKey(scope: string, hash: string): string {
@@ -22,231 +14,74 @@ export function scopedKey(scope: string, hash: string): string {
22
14
  }
23
15
 
24
16
  /**
25
- * A layer as a scoped run sees it. Callers speak task keys; each answer is
26
- * remembered under the key that served it (the default key on a default hit,
27
- * the scope's on a scope hit or a save), so the restore, the output index and
28
- * the run's history all reach the entry that answered. An artifact records
29
- * the key it was packed under and ingest refuses another, so a derived-key
30
- * collision degrades to a miss. A default artifact a remote ingest refuses is
31
- * a miss there, and the scope's key is asked next (Security F6).
17
+ * Which remote key each task asks for, in a run with a scope. The routes
18
+ * come from the branch's diff, which only a remote call awaits: a run the
19
+ * local cache answers never waits on git.
32
20
  */
33
- export class ScopedCache implements CacheLayer {
34
- readonly local?: CacheLayer['local']
35
- readonly hasRemote?: boolean
36
- readonly drainUploads?: () => Promise<void>
37
- readonly stopUploads?: () => void
38
- readonly remoteHasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
39
- /** A scoped run saves under the scope's key, so that is the key asked. */
40
- readonly probeBeforeUpload?: (hashes: Promise<readonly string[]>) => void
41
- readonly getMany?: (
42
- hashes: readonly string[],
43
- ctx?: (hash: string) => CacheGetContext,
44
- ) => Promise<Map<string, CacheEntry>>
45
- /** Task key → the key that answers for it this run. */
46
- private readonly served = new Map<string, string>()
47
-
48
- constructor(
49
- private readonly inner: CacheLayer,
50
- private readonly scope: string,
51
- ) {
52
- if (inner.local !== undefined) this.local = inner.local
53
- if (inner.hasRemote !== undefined) this.hasRemote = inner.hasRemote
54
- if (inner.drainUploads !== undefined) this.drainUploads = () => inner.drainUploads!()
55
- if (inner.stopUploads !== undefined) this.stopUploads = () => inner.stopUploads!()
56
- if (inner.remoteHasMany !== undefined) this.remoteHasMany = (h) => this.scopedHasMany(h)
57
- if (inner.probeBeforeUpload !== undefined)
58
- this.probeBeforeUpload = (h) =>
59
- inner.probeBeforeUpload!(h.then((keys) => keys.map((k) => this.own(k))))
60
- if (inner.getMany !== undefined) this.getMany = (h, ctx) => this.scopedGetMany(h, ctx)
61
- }
62
-
63
- private own(hash: string): string {
64
- const key = scopedKey(this.scope, hash)
65
- this.local?.traffic.aliases.set(key, hash)
66
- return key
67
- }
68
-
69
- private keyOf(hash: string): string {
70
- return this.served.get(hash) ?? hash
71
- }
72
-
73
- /** A batch keyed by served keys, keyed back by task key. */
74
- private batch<T>(
75
- hashes: readonly string[],
76
- read: (keys: string[]) => Map<string, T>,
77
- ): Map<string, T> {
78
- const keys = hashes.map((h) => this.keyOf(h))
79
- const found = read(keys)
80
- const out = new Map<string, T>()
81
- for (let i = 0; i < hashes.length; i++) {
82
- const v = found.get(keys[i]!)
83
- if (v !== undefined) out.set(hashes[i]!, v)
21
+ export class BranchScope {
22
+ /** Finds the tasks that read the scope, at the first remote call; null: every task does. */
23
+ private route: (() => Promise<ReadonlySet<string>>) | null = null
24
+ private routes: Promise<ReadonlySet<string>> | null = null
25
+ /** Task key → its task. */
26
+ private readonly taskOf = new Map<string, string>()
27
+
28
+ constructor(readonly scope: string) {}
29
+
30
+ /** Only the tasks `ids` finds read the scope; set before any remote call. */
31
+ routeTasks(ids: () => Promise<ReadonlySet<string>>): void {
32
+ this.route = ids
33
+ }
34
+
35
+ noteKey(taskId: string, hash: string): void {
36
+ this.taskOf.set(hash, taskId)
37
+ }
38
+
39
+ /** The remote key `hash` is read under, and the scope it is read in (none: the default key). */
40
+ private async readKey(hash: string): Promise<{ key: string; at: RemoteKey }> {
41
+ const id = this.taskOf.get(hash)
42
+ if (this.route !== null) this.routes ??= this.route()
43
+ const tasks = this.routes === null ? null : await this.routes
44
+ return tasks !== null && id !== undefined && !tasks.has(id)
45
+ ? { key: hash, at: {} }
46
+ : { key: scopedKey(this.scope, hash), at: { scope: this.scope } }
47
+ }
48
+
49
+ /**
50
+ * `inner` as this run asks it: each task key under its route's key; every
51
+ * write under the scope's. Each call also names its scope, for a backend
52
+ * that keeps one store per scope; no wire vx ships sends it.
53
+ */
54
+ remote(inner: RemoteCacheLayer): RemoteCacheLayer {
55
+ const read = (h: string) => this.readKey(h)
56
+ const at: RemoteKey = { scope: this.scope }
57
+ const hasMany = inner.hasMany
58
+ return {
59
+ ...(inner.endpoint !== undefined ? { endpoint: inner.endpoint } : {}),
60
+ ...(inner.open !== undefined ? { open: (ctx) => inner.open!(ctx) } : {}),
61
+ has: async (h) => {
62
+ const r = await read(h)
63
+ return inner.has(r.key, r.at)
64
+ },
65
+ ...(hasMany !== undefined
66
+ ? {
67
+ hasMany: async (hashes) => {
68
+ const reads = await Promise.all(hashes.map(read))
69
+ const keys = reads.map((r) => r.key)
70
+ const found = await hasMany.call(
71
+ inner,
72
+ keys,
73
+ reads.map((r) => r.at),
74
+ )
75
+ if (!(found instanceof Set)) return found
76
+ return new Set(hashes.filter((_, i) => found.has(keys[i]!)))
77
+ },
78
+ }
79
+ : {}),
80
+ get: async (h) => {
81
+ const r = await read(h)
82
+ return inner.get(r.key, r.at)
83
+ },
84
+ put: (h, body, meta) => inner.put(scopedKey(this.scope, h), body, { ...meta, ...at }),
84
85
  }
85
- return out
86
- }
87
-
88
- /** A task key the store holds under the default key or the scope's. */
89
- async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
90
- const held = await this.inner.localHasMany?.([...hashes, ...hashes.map((h) => this.own(h))])
91
- if (held === undefined) return new Set()
92
- return new Set(hashes.filter((h) => held.has(h) || held.has(this.own(h))))
93
- }
94
-
95
- private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
96
- const scoped = hashes.map((h) => this.own(h))
97
- const [plain, own] = await Promise.all([
98
- this.inner.remoteHasMany!(hashes),
99
- this.inner.remoteHasMany!(scoped),
100
- ])
101
- if (plain == null || own == null) return null
102
- const found = new Set(plain)
103
- const scopeOnly: string[] = []
104
- for (let i = 0; i < hashes.length; i++) {
105
- if (plain.has(hashes[i]!) || !own.has(scoped[i]!)) continue
106
- found.add(hashes[i]!)
107
- scopeOnly.push(hashes[i]!)
108
- }
109
- // Their default key is known absent: its GET is skipped.
110
- this.inner.markRemoteAbsent?.(scopeOnly)
111
- return found
112
- }
113
-
114
- markRemoteAbsent(hashes: Iterable<string>): void {
115
- const all: string[] = []
116
- for (const h of hashes) all.push(h, this.own(h))
117
- this.inner.markRemoteAbsent?.(all)
118
- }
119
-
120
- key(input: Parameters<CacheLayer['key']>[0]): Promise<string> {
121
- return this.inner.key(input)
122
- }
123
-
124
- async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
125
- // The scope's key first when only it is local: the default key's
126
- // remote GET would cost a round-trip for the same inputs.
127
- const own = this.own(hash)
128
- const keys = (await this.inner.localHasMany?.([hash, own]))?.has(own)
129
- ? [own, hash]
130
- : [hash, own]
131
- for (const key of keys) {
132
- const entry = await this.inner.get(key, ctx)
133
- if (entry === null) continue
134
- this.served.set(hash, key)
135
- return key === hash ? entry : { ...entry, hash }
136
- }
137
- return null
138
- }
139
-
140
- private async scopedGetMany(
141
- hashes: readonly string[],
142
- ctx?: (hash: string) => CacheGetContext,
143
- ): Promise<Map<string, CacheEntry>> {
144
- const out = await this.inner.getMany!(hashes, ctx)
145
- for (const h of out.keys()) this.served.set(h, h)
146
- const rest = hashes.filter((h) => !out.has(h))
147
- if (rest.length === 0) return out
148
- const byOwn = new Map(rest.map((h) => [this.own(h), h]))
149
- const scoped = await this.inner.getMany!(
150
- [...byOwn.keys()],
151
- ctx === undefined ? undefined : (key) => ctx(byOwn.get(key)!),
152
- )
153
- for (const [key, entry] of scoped) {
154
- const hash = byOwn.get(key)!
155
- this.served.set(hash, key)
156
- out.set(hash, { ...entry, hash })
157
- }
158
- return out
159
- }
160
-
161
- async has(hash: string): Promise<'local' | 'remote' | null> {
162
- return (await this.inner.has(hash)) ?? (await this.inner.has(this.own(hash)))
163
- }
164
-
165
- async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
166
- return (await this.inner.prefetch(hash, ctx)) || this.inner.prefetch(this.own(hash), ctx)
167
- }
168
-
169
- loadOutputFilesBatch(hashes: readonly string[]): ReturnType<CacheLayer['loadOutputFilesBatch']> {
170
- return this.batch(hashes, (keys) => this.inner.loadOutputFilesBatch(keys))
171
- }
172
-
173
- isOutputsCurrent(
174
- ...args: Parameters<CacheLayer['isOutputsCurrent']>
175
- ): ReturnType<CacheLayer['isOutputsCurrent']> {
176
- return this.inner.isOutputsCurrent(...args)
177
- }
178
-
179
- async recordOutputDirs(
180
- hash: string,
181
- projectDir: string,
182
- prefixes: readonly string[],
183
- holds?: (files: readonly string[]) => boolean,
184
- ): Promise<void> {
185
- await this.inner.recordOutputDirs?.(this.keyOf(hash), projectDir, prefixes, holds)
186
- }
187
-
188
- recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
189
- this.inner.recordOutputStamps?.(this.keyOf(hash), projectDir, workspaceRoot)
190
- }
191
-
192
- loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
193
- return this.batch(hashes, (keys) => this.inner.loadOutputDirsBatch?.(keys) ?? new Map())
194
- }
195
-
196
- async outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
197
- return (await this.inner.outputDirsCurrent?.(projectDir, rows)) ?? false
198
- }
199
-
200
- restoreOutputs(
201
- hash: string,
202
- projectDir: string,
203
- workspaceRoot?: string,
204
- outputs?: DeclaredOutputs,
205
- ): Promise<void> {
206
- return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot, outputs)
207
- }
208
-
209
- save(args: SaveArgs): Promise<void> {
210
- const key = this.own(args.hash)
211
- this.served.set(args.hash, key)
212
- return this.inner.save({ ...args, hash: key })
213
- }
214
-
215
- ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
216
- const key = this.own(hash)
217
- this.served.set(hash, key)
218
- return this.inner.ingest(key, body, meta)
219
- }
220
-
221
- /** History names the key that answered, so `vx why` reads that entry's components. */
222
- recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
223
- this.inner.recordRunBundle({
224
- ...bundle,
225
- runs: bundle.runs.map((r) => (r.hash === undefined ? r : { ...r, hash: this.keyOf(r.hash) })),
226
- })
227
- }
228
-
229
- stats(...args: Parameters<CacheLayer['stats']>): ReturnType<CacheLayer['stats']> {
230
- return this.inner.stats(...args)
231
- }
232
-
233
- hashFile(filePath: string): Promise<string> {
234
- return this.inner.hashFile(filePath)
235
- }
236
-
237
- outputsPath(hash: string): string {
238
- return this.inner.outputsPath(this.keyOf(hash))
239
- }
240
-
241
- artifactSize(hash: string): number | undefined {
242
- return this.inner.artifactSize?.(this.keyOf(hash))
243
- }
244
-
245
- prune(...args: Parameters<CacheLayer['prune']>): ReturnType<CacheLayer['prune']> {
246
- return this.inner.prune(...args)
247
- }
248
-
249
- close(): void {
250
- this.inner.close()
251
86
  }
252
87
  }
@@ -122,14 +122,19 @@ class Source {
122
122
  constructor(private readonly reader: AsyncIterator<Uint8Array>) {}
123
123
 
124
124
  private async fill(): Promise<boolean> {
125
- if (this.done) return false
126
- const { done, value } = await this.reader.next()
127
- if (done === true || value === undefined) {
128
- this.done = true
129
- return false
125
+ // An empty chunk is skipped, never reported as data: the callers read
126
+ // `chunks[0]` after a true (the tar a tree packs back yields them).
127
+ for (;;) {
128
+ if (this.done) return false
129
+ const { done, value } = await this.reader.next()
130
+ if (done === true || value === undefined) {
131
+ this.done = true
132
+ return false
133
+ }
134
+ if (value.byteLength === 0) continue
135
+ this.chunks.push(value)
136
+ return true
130
137
  }
131
- if (value.byteLength > 0) this.chunks.push(value)
132
- return true
133
138
  }
134
139
 
135
140
  /** Exactly `n` bytes, or null at a clean end (nothing buffered), or a TarFormatError mid-entry. */
package/src/cli/format.ts CHANGED
@@ -1,4 +1,3 @@
1
- // `formatBytes` moved to util/size.ts beside the parsers (item 658): a run
2
- // names what the workspace's `cacheRetention` evicted, and the orchestrator
1
+ // `formatBytes` lives in util/size.ts beside the parsers: the orchestrator
3
2
  // may not import the CLI.
4
3
  export { formatBytes } from '../util/index.js'
package/src/cli/help.ts CHANGED
@@ -134,8 +134,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
134
134
  ' foo, @scope/*, ./packages/foo, foo..., ...foo,',
135
135
  ' foo^..., !foo, [<since>]',
136
136
  ' --affected[=<base>] Projects changed since <base>, and dependents',
137
- ' (--filter "...[<base>]"); default run.affectedBase,',
138
- ' else origin/HEAD, main, master, HEAD~1.',
137
+ ' (--filter "...[<base>]"); default the merge base',
138
+ ' with origin/<baseBranch>; none on that branch.',
139
139
  " pkg#task Run a specific project's task directly.",
140
140
  '',
141
141
  'Execution (for run):',
@@ -159,12 +159,16 @@ export function helpText(pluginCommands: readonly string[] = []): string {
159
159
  ' flags; unnamed layers keep theirs. Examples:',
160
160
  ' local:rw,remote:r (remote read-only), remote:',
161
161
  ' (remote off), local:r (local read-only).',
162
- " --cache-scope <name> This run's cache.scope (pr-123): write there,",
163
- ' read the default keys first.',
164
162
  ' --cache-dir <path> Cache directory (cwd-relative); default',
165
163
  ' .vx/cache, entries in ~/.vx/<id>/cache.',
166
164
  ' --cache-download <mode> all (default) | toplevel | none:',
167
165
  ' where remote outputs land.',
166
+ ' --cache-concurrency-download <n>',
167
+ ' Remote downloads at once.',
168
+ ' --cache-concurrency-upload <n>',
169
+ ' Uploads at once, per remote.',
170
+ ' --cache-concurrency-restore <n>',
171
+ ' Local hits restoring at once.',
168
172
  ' --retries <n> Up to <n> more runs of a failed task, for',
169
173
  ' tasks without their own exec.retries. Not',
170
174
  ' folded into cache keys.',
package/src/cli/info.ts CHANGED
@@ -100,9 +100,6 @@ export function renderInfo(f: InfoFacts, colors: ColorSupport = { enabled: false
100
100
  ...(Object.values(f.settings).some((s) => s.source !== 'default')
101
101
  ? ([['settings', describeSettings(f.settings)]] as [string, string][])
102
102
  : []),
103
- ...(f.cacheRetention !== null
104
- ? ([['cache retention', describeRetention(f.cacheRetention)]] as [string, string][])
105
- : []),
106
103
  // Only when there is something to say.
107
104
  ...(f.orphans.artifacts > 0
108
105
  ? ([
@@ -128,14 +125,9 @@ export function renderInfo(f: InfoFacts, colors: ColorSupport = { enabled: false
128
125
  return ui.report('vx info', rows).lines(colors).join('\n')
129
126
  }
130
127
 
131
- /** `812 (1.2 GB)`, or against `cacheRetention.maxSize`: `812 (1.2 GB of 10 GB, 12%)`. */
128
+ /** `812 (1.2 GB)`. */
132
129
  function describeEntries(f: InfoFacts): string {
133
- const max = f.cacheRetention?.maxBytes
134
- const size =
135
- max === undefined
136
- ? formatBytes(f.cacheBytes)
137
- : `${formatBytes(f.cacheBytes)} of ${formatBytes(max)}, ${Math.round((f.cacheBytes / max) * 100)}%`
138
- return `${f.cacheEntries} (${size})`
130
+ return `${f.cacheEntries} (${formatBytes(f.cacheBytes)})`
139
131
  }
140
132
 
141
133
  /**
@@ -152,15 +144,6 @@ function describeSettings(settings: InfoFacts['settings']): string {
152
144
  .join(', ')
153
145
  }
154
146
 
155
- /** What a run's end evicts: `entries unused for 30d, and the oldest entries past 10G`. */
156
- function describeRetention(r: NonNullable<InfoFacts['cacheRetention']>): string {
157
- const parts = [
158
- ...(r.olderThan !== undefined ? [`entries unused for ${r.olderThan}`] : []),
159
- ...(r.maxSize !== undefined ? [`the oldest entries past ${r.maxSize}`] : []),
160
- ]
161
- return `each run evicts ${parts.join(', and ')}`
162
- }
163
-
164
147
  /** A path under the home directory as `~/…`: shorter, and no user name in a pasted report. */
165
148
  function tildePath(p: string): string {
166
149
  const home = os.homedir()
@@ -9,8 +9,8 @@
9
9
 
10
10
  import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
11
11
  import type { WorkspaceConfig } from '../config.js'
12
- import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
13
- import { cacheScopeEnvError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
12
+ import type { BranchFacts, VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
13
+ import { configuredBaseBranch, findWorkspaceRoot } from '../workspace/index.js'
14
14
  import { machineParallelism, maskedLine } from '../util/index.js'
15
15
  import { resolveSetting } from '../settings.js'
16
16
 
@@ -49,6 +49,7 @@ async function workspacePlugins(cwd: string): Promise<
49
49
  workspaceConfig: WorkspaceConfig | null
50
50
  concurrency: number
51
51
  plugins: readonly VxPlugin[]
52
+ branch: BranchFacts
52
53
  }
53
54
  | UnresolvedPluginCommand
54
55
  | null
@@ -76,6 +77,7 @@ async function workspacePlugins(cwd: string): Promise<
76
77
  (resolveSetting('run.concurrency', ws.workspaceConfig).value as number | undefined) ??
77
78
  machineParallelism(),
78
79
  plugins: ws.plugins,
80
+ branch: ws.branch,
79
81
  }
80
82
  }
81
83
 
@@ -136,13 +138,16 @@ async function verbCache(
136
138
  ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
137
139
  plugin: string,
138
140
  ): Promise<PluginCache> {
139
- const [{ Cache, pluginCache }, { resolveCache }] = await Promise.all([
141
+ const [{ Cache, pluginCache }, { resolveCache, resolveCacheScope }] = await Promise.all([
140
142
  import('../cache/index.js'),
141
143
  import('../orchestrator/index.js'),
142
144
  ])
143
- const envScope = process.env['VX_CACHE_SCOPE'] || undefined
144
- if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
145
- const scope = envScope ?? ws.workspaceConfig?.cache?.scope
145
+ const scope = resolveCacheScope(
146
+ ws.workspaceRoot,
147
+ ws.branch.trusted === true ? null : ws.branch.scope,
148
+ ws.plugins.some((p) => p.cache !== undefined),
149
+ configuredBaseBranch(ws.workspaceConfig),
150
+ )
146
151
  const localCache = new Cache(
147
152
  ws.cacheDir,
148
153
  { read: true, write: false },
package/src/cli/run.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isatty } from 'node:tty'
2
- import { cacheScopeError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
2
+ import { findWorkspaceRoot } from '../workspace/index.js'
3
3
  import { translateForeign } from './foreign-flags.js'
4
4
  import { flagHint, refuse, seeHelp } from './help.js'
5
5
  import {
@@ -60,8 +60,6 @@ export interface RunArgs {
60
60
  remoteRequested?: boolean
61
61
  /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
62
62
  cacheDir: string | undefined
63
- /** `--cache-scope <name>`: this run's `cache.scope`, above `VX_CACHE_SCOPE` and the workspace's. */
64
- cacheScope?: string
65
63
  /** `vx watch --poll` (`watch.poll`). */
66
64
  poll?: true
67
65
  forwardArgs: string[]
@@ -90,9 +88,7 @@ function flagOf(a: string | undefined, name: string): string | undefined {
90
88
  // The settings parsed by their kind alone; the cache flags, `--continue`
91
89
  // and `--cache-dir` keep their own branches above.
92
90
  const GENERIC_FLAGS = SETTINGS.filter(
93
- (s) =>
94
- s.flag?.verb === 'run' &&
95
- !['run.continue', 'cache.dir', 'cache.policy', 'cache.scope'].includes(s.path),
91
+ (s) => s.flag?.verb === 'run' && !['run.continue', 'cache.dir', 'cache.policy'].includes(s.path),
96
92
  )
97
93
 
98
94
  /** The setting a flag names. */
@@ -113,6 +109,9 @@ const SETTING_OPTIONS = [
113
109
  ['run.retries', 'retries'],
114
110
  ['run.outputLogs', 'outputLogs'],
115
111
  ['cache.download', 'download'],
112
+ ['cache.concurrency.download', 'downloadConcurrency'],
113
+ ['cache.concurrency.upload', 'uploadConcurrency'],
114
+ ['cache.concurrency.restore', 'restoreConcurrency'],
116
115
  ] as const satisfies readonly (readonly [string, keyof RunOptions])[]
117
116
 
118
117
  /** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
@@ -204,11 +203,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
204
203
  }
205
204
  }
206
205
  out.cacheDir = v
207
- } else if (a === '--cache-scope' || a?.startsWith('--cache-scope=')) {
208
- const v = a === '--cache-scope' ? before[++i] : a.slice('--cache-scope='.length)
209
- if (v === undefined || !isCacheScope(v))
210
- return { ...out, error: cacheScopeError('--cache-scope') }
211
- out.cacheScope = v
212
206
  } else if (flagOf(a, '--cache-policy')) {
213
207
  cacheFlag = true
214
208
  const name = flagOf(a, '--cache-policy')!
@@ -494,7 +488,6 @@ export async function resolveRunOptions(
494
488
  ? { cacheFlags: parsed.cacheFlags }
495
489
  : { cache: parsed.cache }),
496
490
  ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
497
- ...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
498
491
  // A laptop reads the shared cache and never writes it (owner,
499
492
  // 2026-10-03); naming the remote in `--cache-policy` is asking to.
500
493
  ...(!parsed.remoteRequested && !onCI(process.env['CI'])
@@ -524,6 +517,8 @@ export async function resolveRunOptions(
524
517
  if (parsed.settings[path] !== undefined) Object.assign(opts, { [field]: parsed.settings[path] })
525
518
  }
526
519
  if (parsed.profile !== undefined) opts.profile = parsed.profile
520
+ // `--affected`'s base is the branch's for the cache scope too.
521
+ if (affectedFilter !== undefined) opts.scopeBase = affectedFilter.slice(4, -1)
527
522
 
528
523
  return opts
529
524
  }
package/src/cli/select.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  refIsHead,
13
13
  applyFilters,
14
14
  buildPackageGraph,
15
+ configuredBaseBranch,
15
16
  defaultAffectedBase,
16
17
  findWorkspaceRoot,
17
18
  type FingerprintClaims,
@@ -49,7 +50,6 @@ import {
49
50
  loadCliProjects,
50
51
  loadCliWorkspace,
51
52
  } from './workspace-config.js'
52
- import { resolveSetting } from '../settings.js'
53
53
 
54
54
  /**
55
55
  * The projects whose tasks declare an `inputs.workspaceFiles` glob
@@ -244,20 +244,18 @@ function refusalOf(err: unknown): Refusal {
244
244
 
245
245
  /**
246
246
  * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
247
- * projects and their dependents. An empty base is `run.affectedBase`
248
- * (its variable, then the workspace's, which a plugin's `config` stage may set from nx.json's
249
- * `defaultBase` or `TURBO_SCM_BASE`), then the guess.
247
+ * projects and their dependents. An empty base is a plugin's (`setBranch`:
248
+ * a pull request's base commit), else the merge base with `baseBranch`.
250
249
  */
251
250
  export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
252
251
  const root = await findWorkspaceRoot(cwd)
253
252
  let base = affected
254
253
  if (base === '') {
255
- const ws = (await loadCliWorkspace(root)).workspaceConfig
256
- base = (resolveSetting('run.affectedBase', ws).value as string | undefined) ?? ''
257
- }
258
- if (base === '') {
254
+ const ws = await loadCliWorkspace(root)
259
255
  try {
260
- base = await defaultAffectedBase(root)
256
+ base =
257
+ ws.branch.base ??
258
+ (await defaultAffectedBase(root, configuredBaseBranch(ws.workspaceConfig)))
261
259
  } catch (err) {
262
260
  if (!isUserError(err)) throw err
263
261
  return refusalOf(err)