@vzn/vx 0.0.642 → 0.0.643
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/package.json +5 -5
- package/src/cache/cache.ts +1 -7
- package/src/cache/index.ts +7 -2
- package/src/cache/layer.ts +2 -0
- package/src/cache/layered-cache.ts +28 -6
- package/src/cache/plugin-cache.ts +23 -16
- package/src/cache/remote-lane.ts +6 -6
- package/src/cache/run-store.ts +4 -1
- package/src/cache/scoped-cache.ts +74 -239
- package/src/cli/help.ts +2 -4
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +4 -12
- package/src/cli/select.ts +7 -9
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +7 -16
- package/src/index.ts +4 -0
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/index.ts +4 -0
- package/src/orchestrator/options.ts +9 -1
- package/src/orchestrator/plugin-host.ts +7 -4
- package/src/orchestrator/plugin.ts +20 -2
- package/src/orchestrator/prepare.ts +67 -17
- package/src/orchestrator/projects.ts +19 -5
- package/src/orchestrator/run-context.ts +80 -0
- package/src/orchestrator/run.ts +1 -0
- package/src/orchestrator/task-hash.ts +6 -0
- package/src/report/summary.ts +3 -0
- package/src/settings.ts +11 -11
- package/src/workspace/affected.ts +120 -63
- package/src/workspace/config-schema.ts +44 -16
- package/src/workspace/index.ts +3 -0
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
|
|
138
|
-
'
|
|
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,8 +159,6 @@ 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:',
|
|
@@ -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 {
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
const scope = resolveCacheScope(
|
|
146
|
+
ws.workspaceRoot,
|
|
147
|
+
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 {
|
|
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. */
|
|
@@ -204,11 +200,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
204
200
|
}
|
|
205
201
|
}
|
|
206
202
|
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
203
|
} else if (flagOf(a, '--cache-policy')) {
|
|
213
204
|
cacheFlag = true
|
|
214
205
|
const name = flagOf(a, '--cache-policy')!
|
|
@@ -494,7 +485,6 @@ export async function resolveRunOptions(
|
|
|
494
485
|
? { cacheFlags: parsed.cacheFlags }
|
|
495
486
|
: { cache: parsed.cache }),
|
|
496
487
|
...(parsed.remoteRequested ? { remoteRequested: true } : {}),
|
|
497
|
-
...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
|
|
498
488
|
// A laptop reads the shared cache and never writes it (owner,
|
|
499
489
|
// 2026-10-03); naming the remote in `--cache-policy` is asking to.
|
|
500
490
|
...(!parsed.remoteRequested && !onCI(process.env['CI'])
|
|
@@ -524,6 +514,8 @@ export async function resolveRunOptions(
|
|
|
524
514
|
if (parsed.settings[path] !== undefined) Object.assign(opts, { [field]: parsed.settings[path] })
|
|
525
515
|
}
|
|
526
516
|
if (parsed.profile !== undefined) opts.profile = parsed.profile
|
|
517
|
+
// `--affected`'s base is the branch's for the cache scope too.
|
|
518
|
+
if (affectedFilter !== undefined) opts.scopeBase = affectedFilter.slice(4, -1)
|
|
527
519
|
|
|
528
520
|
return opts
|
|
529
521
|
}
|
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 `
|
|
248
|
-
*
|
|
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 =
|
|
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 =
|
|
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)
|
|
@@ -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(
|
|
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
|
|
@@ -95,15 +95,6 @@ export interface CacheSettings {
|
|
|
95
95
|
* first, then each remote in declared order.
|
|
96
96
|
*/
|
|
97
97
|
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
98
|
/** Which remote hits land on disk: `all` (the default), only the requested tasks' (`toplevel`), or `none`. */
|
|
108
99
|
download?: 'all' | 'toplevel' | 'none'
|
|
109
100
|
/** The `cache.inputStability` of every task that does not set its own. */
|
package/src/index.ts
CHANGED
|
@@ -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
|
|
@@ -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
|
|
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
|
|
@@ -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
|
-
|
|
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:
|
|
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,8 @@ 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
|
|
558
561
|
}
|
|
559
562
|
|
|
560
563
|
/**
|
|
@@ -569,7 +572,7 @@ export async function resolveCache(
|
|
|
569
572
|
plugins: readonly VxPlugin[],
|
|
570
573
|
host: CacheHost,
|
|
571
574
|
): Promise<CacheLayer> {
|
|
572
|
-
const { localCache, policy, remotes, ...ctx } = host
|
|
575
|
+
const { localCache, policy, remotes, branch, ...ctx } = host
|
|
573
576
|
const resolved: ResolvedRemote[] = []
|
|
574
577
|
for (const plugin of plugins) {
|
|
575
578
|
if (plugin.cache === undefined) continue
|
|
@@ -586,7 +589,7 @@ export async function resolveCache(
|
|
|
586
589
|
resolved.push(entry)
|
|
587
590
|
remotes?.push(entry)
|
|
588
591
|
}
|
|
589
|
-
return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved)
|
|
592
|
+
return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved, branch)
|
|
590
593
|
}
|
|
591
594
|
|
|
592
595
|
/** 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:
|
|
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,22 @@ 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. A later plugin's field wins.
|
|
351
|
+
*/
|
|
352
|
+
export interface BranchFacts {
|
|
353
|
+
scope?: string
|
|
354
|
+
base?: string
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface ConfigContext extends WorkspaceHookContext {
|
|
358
|
+
setBranch(facts: BranchFacts): void
|
|
359
|
+
}
|
|
360
|
+
|
|
343
361
|
export interface DiscoverContext extends WorkspaceHookContext {
|
|
344
362
|
/** Where vx's cache lives: a plugin may keep what it read to answer here. */
|
|
345
363
|
readonly cacheDir: string
|
|
@@ -485,7 +503,7 @@ export interface ScheduleHookContext extends BaseContext {
|
|
|
485
503
|
/** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
|
|
486
504
|
export interface CacheContext extends BaseContext {
|
|
487
505
|
/**
|
|
488
|
-
* The run's
|
|
506
|
+
* The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none. Core already keys a scoped run's
|
|
489
507
|
* writes apart; a remote reads it to pick a credential or check a token's
|
|
490
508
|
* claims (an OIDC branch) for every call it makes.
|
|
491
509
|
*/
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
writesRunStore,
|
|
27
27
|
type PluginCache,
|
|
28
28
|
type ResolvedRemote,
|
|
29
|
-
|
|
29
|
+
BranchScope,
|
|
30
30
|
applyGitEnumeration,
|
|
31
31
|
gitPathspecs,
|
|
32
32
|
MAX_SCOPED_PATHSPECS,
|
|
@@ -38,8 +38,9 @@ import {
|
|
|
38
38
|
buildPackageGraph,
|
|
39
39
|
computeNestedProjectDirs,
|
|
40
40
|
findWorkspaceRoot,
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
type AffectedChanges,
|
|
42
|
+
branchChanges,
|
|
43
|
+
configuredBaseBranch,
|
|
43
44
|
type LoadReads,
|
|
44
45
|
PROJECT_CONFIG_FILENAMES,
|
|
45
46
|
readClaimedFiles,
|
|
@@ -78,13 +79,13 @@ import {
|
|
|
78
79
|
lockReader,
|
|
79
80
|
type LoadedProjects,
|
|
80
81
|
} from './projects.js'
|
|
81
|
-
import { keptByAffected } from './affected-tasks.js'
|
|
82
|
+
import { keptByAffected, keyedByChange } from './affected-tasks.js'
|
|
82
83
|
import { applyGraphStage } from './graph-stage.js'
|
|
83
84
|
import { keyExcludedDependencies } from './excluded-keys.js'
|
|
84
85
|
import { FingerprintWatch } from './fingerprint-watch.js'
|
|
85
86
|
import type { VxPlugin } from './plugin.js'
|
|
86
87
|
import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
|
|
87
|
-
import { currentBranch } from './run-context.js'
|
|
88
|
+
import { currentBranch, resolveCacheScope } from './run-context.js'
|
|
88
89
|
import { createHashCache, type HashCache } from './task-hash.js'
|
|
89
90
|
import { type Logger, warnOf } from '../report/index.js'
|
|
90
91
|
import type { RunOptions } from './options.js'
|
|
@@ -113,6 +114,8 @@ export interface PreparedRun {
|
|
|
113
114
|
hasRemoteLayer: boolean
|
|
114
115
|
/** `--cache-policy` with the workspace's `cacheScope` applied: what the layers were built under. */
|
|
115
116
|
cachePolicy: CachePolicy
|
|
117
|
+
/** The scope this run's cache reads and writes, when one is set; named on the summary's `info` row. */
|
|
118
|
+
cacheScope: string | undefined
|
|
116
119
|
/** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
|
|
117
120
|
remotes: ResolvedRemote[]
|
|
118
121
|
/**
|
|
@@ -249,8 +252,21 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
249
252
|
(reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
|
|
250
253
|
lazyGitEnumeration(workspaceRoot)
|
|
251
254
|
if (wholeTree) void git.start()
|
|
252
|
-
const {
|
|
255
|
+
const {
|
|
256
|
+
workspaceConfig,
|
|
257
|
+
plugins,
|
|
258
|
+
branch: branchFacts,
|
|
259
|
+
} = await loadWorkspacePlugins(workspaceRoot, warnOf(log))
|
|
253
260
|
mark('workspace config')
|
|
261
|
+
const baseBranch = configuredBaseBranch(workspaceConfig)
|
|
262
|
+
const cacheScope =
|
|
263
|
+
options.cacheScope ??
|
|
264
|
+
resolveCacheScope(
|
|
265
|
+
workspaceRoot,
|
|
266
|
+
branchFacts.scope,
|
|
267
|
+
plugins.some((p) => p.cache !== undefined),
|
|
268
|
+
baseBranch,
|
|
269
|
+
)
|
|
254
270
|
let learnedBranch: string | null | undefined
|
|
255
271
|
const branch = (): string | null =>
|
|
256
272
|
learnedBranch === undefined
|
|
@@ -269,10 +285,38 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
269
285
|
// Asked now, read when the cache opens: a repository with no remote is
|
|
270
286
|
// named by its root commit, a git spawn the discovery below overlaps.
|
|
271
287
|
const storeRoot = options.cacheDir ? null : resolveStoreRoot(workspaceRoot, workspaceConfig)
|
|
272
|
-
const
|
|
288
|
+
const discovering =
|
|
273
289
|
reused !== undefined
|
|
274
|
-
? reused.projects
|
|
275
|
-
:
|
|
290
|
+
? Promise.resolve(reused.projects)
|
|
291
|
+
: discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
|
|
292
|
+
// What the branch changed decides which tasks read the scope (owner,
|
|
293
|
+
// 2026-10-17). Only a scoped run's first remote call asks git: a run the
|
|
294
|
+
// local cache answers makes no remote call, and diffing beside discovery
|
|
295
|
+
// cost it 20-60 ms at 1k-5k projects.
|
|
296
|
+
const scopeChanges: (() => Promise<AffectedChanges>) | undefined =
|
|
297
|
+
cacheScope === undefined
|
|
298
|
+
? undefined
|
|
299
|
+
: options.scopeChanges !== undefined
|
|
300
|
+
? async () => options.scopeChanges!
|
|
301
|
+
: () =>
|
|
302
|
+
branchChanges(
|
|
303
|
+
workspaceRoot,
|
|
304
|
+
options.scopeBase ?? branchFacts.base,
|
|
305
|
+
baseBranch,
|
|
306
|
+
discovering,
|
|
307
|
+
{
|
|
308
|
+
untracked: async () => (await git.start()).untracked,
|
|
309
|
+
// A scoped enumeration lists only its projects: null asks git.
|
|
310
|
+
links: async () => {
|
|
311
|
+
const g = await git.start()
|
|
312
|
+
if (!g.scopes.includes('.')) return null
|
|
313
|
+
const out: string[] = []
|
|
314
|
+
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
315
|
+
return out
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
const projectMetas = await discovering
|
|
276
320
|
mark('discover projects')
|
|
277
321
|
|
|
278
322
|
// SCOPED config loading: configs are programs, and evaluating 1090
|
|
@@ -314,12 +358,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
314
358
|
// so a reader profiling that stage was sent to the wrong code.
|
|
315
359
|
mark('package graph')
|
|
316
360
|
|
|
317
|
-
// `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
|
|
318
|
-
// a CI job that knows its trust level says so without editing config.
|
|
319
|
-
const envScope = process.env['VX_CACHE_SCOPE'] || undefined
|
|
320
|
-
if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
|
|
321
361
|
const workspacePolicy = workspaceCachePolicy(workspaceConfig)
|
|
322
|
-
const cacheScope = options.cacheScope ?? envScope ?? workspaceConfig?.cache?.scope
|
|
323
362
|
const asked =
|
|
324
363
|
options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
|
|
325
364
|
// A laptop reads the shared cache and does not write it unless a policy
|
|
@@ -470,6 +509,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
470
509
|
// cache plugin.
|
|
471
510
|
let cache: CacheLayer
|
|
472
511
|
const remotes: ResolvedRemote[] = []
|
|
512
|
+
// A scope only picks remote keys (owner, 2026-10-17): the local store
|
|
513
|
+
// keeps every branch's entries under the task keys.
|
|
514
|
+
const branchScope = cacheScope === undefined ? undefined : new BranchScope(cacheScope)
|
|
473
515
|
if (options.remoteCache) {
|
|
474
516
|
remotes.push({
|
|
475
517
|
remote: options.remoteCache,
|
|
@@ -480,7 +522,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
480
522
|
}
|
|
481
523
|
try {
|
|
482
524
|
cache = options.remoteCache
|
|
483
|
-
? new LayeredCache(localCache, remotes)
|
|
525
|
+
? new LayeredCache(localCache, remotes, branchScope)
|
|
484
526
|
: await resolveCache(plugins, {
|
|
485
527
|
workspaceRoot,
|
|
486
528
|
cacheDir,
|
|
@@ -489,6 +531,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
489
531
|
localCache,
|
|
490
532
|
policy,
|
|
491
533
|
remotes,
|
|
534
|
+
...(branchScope !== undefined ? { branch: branchScope } : {}),
|
|
492
535
|
})
|
|
493
536
|
} catch (err) {
|
|
494
537
|
// A cache plugin that throws or returns something off-contract is
|
|
@@ -499,8 +542,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
499
542
|
localCache.close()
|
|
500
543
|
throw err
|
|
501
544
|
}
|
|
502
|
-
// One scoping rule above every layer, local and remote alike.
|
|
503
|
-
if (cacheScope !== undefined) cache = new ScopedCache(cache, cacheScope)
|
|
504
545
|
// Ask the LAYER, don't infer. Identity against `localCache` answers a
|
|
505
546
|
// DIFFERENT question — "did the plugin hand back something other than the
|
|
506
547
|
// handle I passed in?" — which an ordinary pass-through decorator (a
|
|
@@ -534,6 +575,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
534
575
|
localCache,
|
|
535
576
|
hasRemoteLayer,
|
|
536
577
|
cachePolicy: policy,
|
|
578
|
+
cacheScope,
|
|
537
579
|
remotes,
|
|
538
580
|
keepRunEntry: undefined,
|
|
539
581
|
priorities: () => Promise.resolve(new Map()),
|
|
@@ -721,6 +763,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
721
763
|
if (hasHook(plugins, 'key')) {
|
|
722
764
|
await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
|
|
723
765
|
}
|
|
766
|
+
// The final inputs decide which tasks the branch changed: they read
|
|
767
|
+
// the scope, every other task the default key.
|
|
768
|
+
if (branchScope !== undefined && scopeChanges !== undefined) {
|
|
769
|
+
branchScope.routeTasks(async () =>
|
|
770
|
+
keyedByChange(nodes, await scopeChanges(), projects, packageGraph),
|
|
771
|
+
)
|
|
772
|
+
}
|
|
724
773
|
refuseFiltersThroughGroups(nodes)
|
|
725
774
|
const exclude = options.excludeDependencies
|
|
726
775
|
let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
|
|
@@ -783,6 +832,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
783
832
|
localCache,
|
|
784
833
|
hasRemoteLayer,
|
|
785
834
|
cachePolicy: policy,
|
|
835
|
+
cacheScope,
|
|
786
836
|
remotes,
|
|
787
837
|
priorities,
|
|
788
838
|
branch,
|