@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.
- package/package.json +5 -5
- package/schemas/info.json +0 -11
- package/skills/vx/SKILL.md +1 -1
- package/src/cache/archive.ts +153 -2
- package/src/cache/cache.ts +417 -183
- package/src/cache/file-hashes.ts +2 -14
- package/src/cache/fold-memo.ts +7 -3
- package/src/cache/index.ts +10 -2
- package/src/cache/key-fold.ts +4 -1
- package/src/cache/layer.ts +11 -4
- package/src/cache/layered-cache.ts +82 -8
- package/src/cache/plugin-cache.ts +51 -43
- package/src/cache/remote-lane.ts +18 -13
- package/src/cache/run-store.ts +4 -1
- package/src/cache/scoped-cache.ts +74 -239
- package/src/cache/tar-stream.ts +12 -7
- package/src/cli/format.ts +1 -2
- package/src/cli/help.ts +8 -4
- package/src/cli/info.ts +2 -19
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +7 -12
- package/src/cli/select.ts +7 -9
- package/src/cli/upgrade.ts +38 -8
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +22 -26
- package/src/exec/sandbox-paths.ts +2 -2
- package/src/graph/scheduler.ts +3 -1
- package/src/graph/task-graph.ts +15 -11
- package/src/index.ts +5 -1
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/doctor.ts +0 -17
- package/src/orchestrator/execute-task.ts +1 -1
- package/src/orchestrator/index.ts +4 -0
- package/src/orchestrator/options.ts +15 -1
- package/src/orchestrator/plugin-host.ts +11 -4
- package/src/orchestrator/plugin.ts +23 -2
- package/src/orchestrator/prepare.ts +81 -18
- package/src/orchestrator/projects.ts +26 -5
- package/src/orchestrator/remote-prefetch.ts +11 -4
- package/src/orchestrator/run-context.ts +85 -0
- package/src/orchestrator/run-settings.ts +3 -0
- package/src/orchestrator/run.ts +9 -26
- package/src/orchestrator/task-hash.ts +6 -0
- package/src/report/logger.ts +0 -4
- package/src/report/summary.ts +3 -11
- package/src/settings.ts +54 -25
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +42 -33
- package/src/util/size.ts +4 -5
- package/src/workspace/affected.ts +120 -63
- package/src/workspace/config-schema.ts +44 -52
- package/src/workspace/index.ts +3 -0
|
@@ -1,20 +1,12 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
readonly
|
|
39
|
-
|
|
40
|
-
readonly
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
}
|
package/src/cache/tar-stream.ts
CHANGED
|
@@ -122,14 +122,19 @@ class Source {
|
|
|
122
122
|
constructor(private readonly reader: AsyncIterator<Uint8Array>) {}
|
|
123
123
|
|
|
124
124
|
private async fill(): Promise<boolean> {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
this.done
|
|
129
|
-
|
|
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`
|
|
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
|
|
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,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)
|
|
128
|
+
/** `812 (1.2 GB)`. */
|
|
132
129
|
function describeEntries(f: InfoFacts): string {
|
|
133
|
-
|
|
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 {
|
|
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.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 {
|
|
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 `
|
|
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)
|