@vzn/vx 0.0.643 → 0.0.644
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +416 -176
- package/src/cache/file-hashes.ts +2 -14
- package/src/cache/fold-memo.ts +7 -3
- package/src/cache/index.ts +3 -0
- package/src/cache/key-fold.ts +4 -1
- package/src/cache/layer.ts +9 -4
- package/src/cache/layered-cache.ts +55 -3
- package/src/cache/plugin-cache.ts +33 -32
- package/src/cache/remote-lane.ts +12 -7
- package/src/cache/tar-stream.ts +12 -7
- package/src/cli/format.ts +1 -2
- package/src/cli/help.ts +6 -0
- package/src/cli/info.ts +2 -19
- package/src/cli/plugin-commands.ts +1 -1
- package/src/cli/run.ts +3 -0
- package/src/cli/upgrade.ts +38 -8
- package/src/config.ts +15 -10
- 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 +1 -1
- package/src/orchestrator/doctor.ts +0 -17
- package/src/orchestrator/execute-task.ts +1 -1
- package/src/orchestrator/options.ts +6 -0
- package/src/orchestrator/plugin-host.ts +6 -2
- package/src/orchestrator/plugin.ts +4 -1
- package/src/orchestrator/prepare.ts +16 -3
- package/src/orchestrator/projects.ts +8 -1
- package/src/orchestrator/remote-prefetch.ts +11 -4
- package/src/orchestrator/run-context.ts +10 -5
- package/src/orchestrator/run-settings.ts +3 -0
- package/src/orchestrator/run.ts +8 -26
- package/src/report/logger.ts +0 -4
- package/src/report/summary.ts +0 -11
- package/src/settings.ts +43 -14
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +42 -33
- package/src/util/size.ts +4 -5
- package/src/workspace/config-schema.ts +0 -36
package/src/settings.ts
CHANGED
|
@@ -17,7 +17,8 @@ export type SettingKind =
|
|
|
17
17
|
| { readonly type: 'concurrency' }
|
|
18
18
|
// `zero`: 0 is a value ("wait for nothing not already done"), not a typo.
|
|
19
19
|
| { readonly type: 'duration'; readonly timer?: true; readonly zero?: true }
|
|
20
|
-
|
|
20
|
+
// `off`: `false` is a value, 0 bytes (the thing the size bounds is off).
|
|
21
|
+
| { readonly type: 'size'; readonly off?: true }
|
|
21
22
|
| { readonly type: 'bool' }
|
|
22
23
|
| { readonly type: 'enum'; readonly values: readonly string[] }
|
|
23
24
|
| { readonly type: 'string' }
|
|
@@ -115,18 +116,6 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
115
116
|
doc: 'Where the cache lives, relative to the workspace root; named, it holds the whole cache.',
|
|
116
117
|
flag: { verb: 'run', name: '--cache-dir' },
|
|
117
118
|
},
|
|
118
|
-
{
|
|
119
|
-
path: 'cache.retention.maxSize',
|
|
120
|
-
kind: { type: 'size' },
|
|
121
|
-
default: 'none',
|
|
122
|
-
doc: "Evict least-recently-used entries at each run's end until the cache is under this (`'10GB'`).",
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
path: 'cache.retention.olderThan',
|
|
126
|
-
kind: { type: 'duration' },
|
|
127
|
-
default: 'none',
|
|
128
|
-
doc: "Evict entries unused for this long at each run's end (`'30d'`).",
|
|
129
|
-
},
|
|
130
119
|
{
|
|
131
120
|
path: 'cache.policy',
|
|
132
121
|
kind: { type: 'string' },
|
|
@@ -141,6 +130,27 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
141
130
|
doc: "A branch's remote entries (`'vznjs/vx:feat-x'`): tasks the branch changed read the scope's key, the rest the default key; the local cache never uses it. Empty: no scope.",
|
|
142
131
|
config: false,
|
|
143
132
|
},
|
|
133
|
+
{
|
|
134
|
+
path: 'cache.concurrency.download',
|
|
135
|
+
kind: { type: 'int', min: 1 },
|
|
136
|
+
default: 'min(96, max(cores × 3, 64))',
|
|
137
|
+
doc: 'Remote downloads at once.',
|
|
138
|
+
flag: { verb: 'run', name: '--cache-concurrency-download' },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
path: 'cache.concurrency.upload',
|
|
142
|
+
kind: { type: 'int', min: 1 },
|
|
143
|
+
default: 'min(96, max(cores × 3, 64))',
|
|
144
|
+
doc: 'Uploads at once, per remote.',
|
|
145
|
+
flag: { verb: 'run', name: '--cache-concurrency-upload' },
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
path: 'cache.concurrency.restore',
|
|
149
|
+
kind: { type: 'int', min: 1 },
|
|
150
|
+
default: 'twice `run.concurrency`',
|
|
151
|
+
doc: 'Local hits restoring outputs at once.',
|
|
152
|
+
flag: { verb: 'run', name: '--cache-concurrency-restore' },
|
|
153
|
+
},
|
|
144
154
|
{
|
|
145
155
|
path: 'cache.download',
|
|
146
156
|
kind: { type: 'enum', values: ['all', 'toplevel', 'none'] },
|
|
@@ -170,6 +180,24 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
170
180
|
task: 'cache.outputs.clean',
|
|
171
181
|
env: false,
|
|
172
182
|
},
|
|
183
|
+
{
|
|
184
|
+
path: 'cache.store.embeddings',
|
|
185
|
+
kind: { type: 'size', off: true },
|
|
186
|
+
default: "'32KiB'",
|
|
187
|
+
doc: 'Keep an entry up to this size inside the cache index, one zstd tar, rather than as files; `false` keeps none.',
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
path: 'cache.store.decompress',
|
|
191
|
+
kind: { type: 'bool' },
|
|
192
|
+
default: 'true',
|
|
193
|
+
doc: 'Keep a larger entry as its plain files, which a hit copies or clones out; off, as one `.tar.zst`.',
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
path: 'cache.store.verify',
|
|
197
|
+
kind: { type: 'enum', values: ['stat', 'hash', 'none'] },
|
|
198
|
+
default: 'stat',
|
|
199
|
+
doc: "What a hit on plain files checks first: each file's size and mtime, every byte, or nothing.",
|
|
200
|
+
},
|
|
173
201
|
{
|
|
174
202
|
path: 'cache.upload.failures',
|
|
175
203
|
kind: { type: 'enum', values: ['ci', 'local', 'always'] },
|
|
@@ -250,7 +278,7 @@ export function kindHint(kind: SettingKind): string {
|
|
|
250
278
|
? "a duration like '500ms', '30s' or '5m', at most 24 days"
|
|
251
279
|
: "a duration like '45s', '12h' or '30d'"
|
|
252
280
|
case 'size':
|
|
253
|
-
return "a size like '500MB' or '10GB'"
|
|
281
|
+
return kind.off === true ? "false, or a size like '32KiB'" : "a size like '500MB' or '10GB'"
|
|
254
282
|
case 'bool':
|
|
255
283
|
return 'true or false'
|
|
256
284
|
case 'enum':
|
|
@@ -298,6 +326,7 @@ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
|
|
|
298
326
|
return kind.timer === true && n > MAX_TIMEOUT_MS ? null : n
|
|
299
327
|
}
|
|
300
328
|
case 'size':
|
|
329
|
+
if (kind.off === true && (raw === false || raw === 'false')) return 0
|
|
301
330
|
return typeof raw === 'string' ? parseSize(raw) : null
|
|
302
331
|
case 'bool':
|
|
303
332
|
if (typeof raw === 'boolean') return raw
|
package/src/util/index.ts
CHANGED
package/src/util/paths.ts
CHANGED
|
@@ -48,7 +48,15 @@ export function relPosix(from: string, to: string): string {
|
|
|
48
48
|
* Every glob reaching here is user-written, so every one takes the rule.
|
|
49
49
|
*/
|
|
50
50
|
export function staticPrefix(rawGlob: string): string {
|
|
51
|
-
|
|
51
|
+
// An escaped character is part of the prefix and names itself:
|
|
52
|
+
// `app/\[id\]/**` is rooted at the directory `app/[id]`.
|
|
53
|
+
const g = normalizeGlob(rawGlob)
|
|
54
|
+
if (!g.includes('\\')) return prefixOf(g, GLOB_WILDCARDS)
|
|
55
|
+
const masked = g.replace(/\\./g, '\0\0')
|
|
56
|
+
const prefix = prefixOf(masked, GLOB_WILDCARDS)
|
|
57
|
+
return prefix === '.' || prefix === '/'
|
|
58
|
+
? prefix
|
|
59
|
+
: g.slice(0, prefix.length).replace(/\\(.)/g, '$1')
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
/**
|
|
@@ -98,10 +106,10 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
|
|
|
98
106
|
if (globs.length === 0) return null
|
|
99
107
|
const out: string[] = []
|
|
100
108
|
for (const g of globs.map(normalizeGlob)) {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const dir =
|
|
109
|
+
const named = /^(.+?)\/\*\*$/.exec(g)?.[1] ?? g
|
|
110
|
+
if (g.startsWith('!') || !namesOnePath(named)) return null
|
|
111
|
+
// An escaped bracket names itself: `app/\[id\]/**` is the tree `app/[id]`.
|
|
112
|
+
const dir = named.replace(/\\(.)/g, '$1').replace(/\/+$/, '')
|
|
105
113
|
if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
|
|
106
114
|
return null
|
|
107
115
|
out.push(dir)
|
|
@@ -136,10 +144,6 @@ function normalizeWith(glob: string, wildcards: RegExp): string {
|
|
|
136
144
|
let g = neg ? glob.slice(1) : glob
|
|
137
145
|
g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
|
|
138
146
|
if (g === '.') g = ''
|
|
139
|
-
// Where a bracket is literal, Turbo's escaped spelling `\[id\]` names the
|
|
140
|
-
// same path as `[id]`; one spelling is what lets every literal fast path
|
|
141
|
-
// (string compare, `settleLiterals`, `asTrees`) see it as a literal.
|
|
142
|
-
if (wildcards === GLOB_WILDCARDS && g.includes('\\')) g = g.replace(/\\([[\]])/g, '$1')
|
|
143
147
|
if (wildcards.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
|
|
144
148
|
return neg ? `!${g}` : g
|
|
145
149
|
}
|
|
@@ -172,21 +176,19 @@ export function isLiteralPattern(glob: string): boolean {
|
|
|
172
176
|
* class written out nine times — a spelling that drifts is how two guards
|
|
173
177
|
* come to answer differently for one pattern.
|
|
174
178
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
* unrelated `app/i/page.js`. {@link taskGlob} is how such a glob reaches
|
|
181
|
-
* `Bun.Glob`, whose own alphabet still reads the class.
|
|
179
|
+
* A task glob is a standard glob (M, 2026-10-18): `[...]` is a character
|
|
180
|
+
* class and `\[id\]` names the route directory `[id]`. An escape counts as
|
|
181
|
+
* a wildcard too: such a pattern is matched by `Bun.Glob`, which reads the
|
|
182
|
+
* escape, never compared as a string; `asTrees` still reads an escaped-only
|
|
183
|
+
* pattern as one path.
|
|
182
184
|
*/
|
|
183
|
-
export const GLOB_WILDCARDS = /[*?{}]/
|
|
185
|
+
export const GLOB_WILDCARDS = /[*?{}[\]\\]/
|
|
184
186
|
|
|
185
187
|
/**
|
|
186
|
-
* `Bun.Glob`'s own alphabet,
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
188
|
+
* `Bun.Glob`'s own alphabet, for the globs vx does not own: package-manager
|
|
189
|
+
* workspace members and `--filter` path globs follow npm/pnpm semantics,
|
|
190
|
+
* and a sandbox grant's prefix must stop where the scan that expands the
|
|
191
|
+
* grant (`Bun.Glob`, `MOUNT_WILDCARDS`) sees a wildcard.
|
|
190
192
|
*/
|
|
191
193
|
export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
|
|
192
194
|
|
|
@@ -200,11 +202,7 @@ export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
|
|
|
200
202
|
*/
|
|
201
203
|
export const EXTGLOB = /[!@+*?]\(/
|
|
202
204
|
|
|
203
|
-
/**
|
|
204
|
-
* Compile a task glob for `Bun.Glob`, with every bare bracket escaped so it
|
|
205
|
-
* matches itself (item 667). Every task glob is compiled here and nowhere
|
|
206
|
-
* else: one site that forgot is a route directory that keys nothing.
|
|
207
|
-
*/
|
|
205
|
+
/** Compile a task glob for `Bun.Glob`. Every task glob is compiled here and nowhere else. */
|
|
208
206
|
export function taskGlob(pattern: string): Bun.Glob {
|
|
209
207
|
const memo = memoFor(taskGlobs)
|
|
210
208
|
let glob = memo.get(pattern)
|
|
@@ -230,9 +228,7 @@ function memoFor<T>(memos: WeakMap<object, Map<string, T>>): Map<string, T> {
|
|
|
230
228
|
}
|
|
231
229
|
|
|
232
230
|
function compileTaskGlob(pattern: string): Bun.Glob {
|
|
233
|
-
|
|
234
|
-
const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
|
|
235
|
-
const glob = new Bun.Glob(source)
|
|
231
|
+
const glob = new Bun.Glob(pattern)
|
|
236
232
|
const alternatives = braceAlternatives(pattern)
|
|
237
233
|
if (alternatives.length > 1) {
|
|
238
234
|
const any = anyTaskGlob(alternatives)
|
|
@@ -288,12 +284,12 @@ const REGEXP_SYNTAX = /[\\^$.*+?()[\]{}|]/g
|
|
|
288
284
|
* The RegExp source of a task glob, matching what `Bun.Glob` matches
|
|
289
285
|
* (`tests/util-paths.test.ts` holds the two to the same verdict on every
|
|
290
286
|
* shape): `*` any run within a name, `?` one character, a `**` segment any
|
|
291
|
-
* number of directories
|
|
292
|
-
*
|
|
287
|
+
* number of directories. Null for the syntax left to `Bun.Glob`: a brace,
|
|
288
|
+
* a character class, an escape, a leading `!`, an empty, `.` or `..`
|
|
293
289
|
* segment, a `**` inside a name, or two `**` segments in a row.
|
|
294
290
|
*/
|
|
295
291
|
function regExpSource(pattern: string): string | null {
|
|
296
|
-
if (/[\\{}]/.test(pattern) || pattern.startsWith('!')) return null
|
|
292
|
+
if (/[\\{}[\]]/.test(pattern) || pattern.startsWith('!')) return null
|
|
297
293
|
const segments = pattern.split('/')
|
|
298
294
|
let out = ''
|
|
299
295
|
for (let i = 0; i < segments.length; i++) {
|
|
@@ -342,7 +338,7 @@ export function asTrees(patterns: readonly string[]): string[] {
|
|
|
342
338
|
const out: string[] = []
|
|
343
339
|
for (const raw of patterns) {
|
|
344
340
|
const p = normalizeGlob(raw)
|
|
345
|
-
if (!
|
|
341
|
+
if (!namesOnePath(p)) {
|
|
346
342
|
out.push(p)
|
|
347
343
|
continue
|
|
348
344
|
}
|
|
@@ -353,6 +349,19 @@ export function asTrees(patterns: readonly string[]): string[] {
|
|
|
353
349
|
return out
|
|
354
350
|
}
|
|
355
351
|
|
|
352
|
+
/**
|
|
353
|
+
* The one path a task glob names, an escaped one included (`app/\[id\]`
|
|
354
|
+
* names `app/[id]`), or null for a pattern.
|
|
355
|
+
*/
|
|
356
|
+
export function literalPath(glob: string): string | null {
|
|
357
|
+
if (isLiteralPattern(glob)) return glob
|
|
358
|
+
return isLiteralPattern(glob.replace(/\\./g, '_')) ? glob.replace(/\\(.)/g, '$1') : null
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function namesOnePath(glob: string): boolean {
|
|
362
|
+
return literalPath(glob) !== null
|
|
363
|
+
}
|
|
364
|
+
|
|
356
365
|
/**
|
|
357
366
|
* An output list's positive globs, and what its `!` entries take back
|
|
358
367
|
* (A-44). A reader that only needs where outputs MIGHT land (an overlap
|
package/src/util/size.ts
CHANGED
|
@@ -4,13 +4,13 @@ import { parseDecimalInt } from './num.js'
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Parse a byte-size string: a bare integer (`"1048576"`) or an integer
|
|
7
|
-
* with a K/M/G/T suffix, powers of 1024, optional trailing `B`
|
|
8
|
-
* (`"512MB"`, `"8G"`). Returns `null` for anything else — including
|
|
7
|
+
* with a K/M/G/T suffix, powers of 1024, optional trailing `B` or `iB`
|
|
8
|
+
* (`"512MB"`, `"8G"`, `"32KiB"`). Returns `null` for anything else — including
|
|
9
9
|
* fractional sizes (`"1.5GB"`); callers wanting fractions must express
|
|
10
10
|
* them in a smaller unit.
|
|
11
11
|
*/
|
|
12
12
|
export function parseSize(input: string): number | null {
|
|
13
|
-
const m = input.match(/^(\d+)([KMGT])?B?$/i)
|
|
13
|
+
const m = input.match(/^(\d+)(?:([KMGT])(?:i?B)?|B)?$/i)
|
|
14
14
|
if (!m) return null
|
|
15
15
|
// Digits past 2^53 parse to a different number than the user typed.
|
|
16
16
|
const n = parseDecimalInt(m[1]!)
|
|
@@ -49,8 +49,7 @@ export function parseDuration(input: string): number | null {
|
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
51
|
* Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
|
|
52
|
-
* decimal of precision below 10 of any unit. Used by `vx info
|
|
53
|
-
* run's `cacheRetention` line.
|
|
52
|
+
* decimal of precision below 10 of any unit. Used by `vx info` and `vx cache clean`.
|
|
54
53
|
*/
|
|
55
54
|
export function formatBytes(n: number): string {
|
|
56
55
|
if (n < 1024) return `${n} B`
|
|
@@ -19,8 +19,6 @@ import {
|
|
|
19
19
|
CORE_VERBS,
|
|
20
20
|
nearest,
|
|
21
21
|
normalizeGlob,
|
|
22
|
-
parseDuration,
|
|
23
|
-
parseSize,
|
|
24
22
|
UserError,
|
|
25
23
|
BUN_GLOB_WILDCARDS,
|
|
26
24
|
EXTGLOB,
|
|
@@ -203,15 +201,6 @@ function validateDependencyRules(value: unknown, where: string): void {
|
|
|
203
201
|
/** `value` as the setting at `path` takes it, refused in `where`'s words. */
|
|
204
202
|
function validateSettingValue(value: unknown, path: string, where: string): void {
|
|
205
203
|
const { kind } = setting(path)
|
|
206
|
-
// The bound `vx cache clean` refuses too: each run would empty the cache,
|
|
207
|
-
// the entries it just saved included, so nothing ever hits (item 969).
|
|
208
|
-
if (
|
|
209
|
-
path === 'cache.retention.olderThan' &&
|
|
210
|
-
typeof value === 'string' &&
|
|
211
|
-
parseDuration(value) === 0
|
|
212
|
-
) {
|
|
213
|
-
throw new UserError(`${where} of 0 evicts every entry after every run`)
|
|
214
|
-
}
|
|
215
204
|
// A file's values are typed: a count or a switch is no string (only a
|
|
216
205
|
// share of the cores is), as a variable's or a flag's must be.
|
|
217
206
|
const untyped =
|
|
@@ -237,7 +226,6 @@ function validateSettingsExtra(config: WorkspaceConfig, configPath: string): voi
|
|
|
237
226
|
}
|
|
238
227
|
// `policy`'s grammar is the cache module's (`parseCachePolicy`), which
|
|
239
228
|
// this module may not import: the run parses it before any task (prepare.ts).
|
|
240
|
-
if (config.cache?.retention !== undefined) validateRetention(config.cache.retention, configPath)
|
|
241
229
|
}
|
|
242
230
|
|
|
243
231
|
/**
|
|
@@ -250,30 +238,6 @@ function shown(v: unknown): string {
|
|
|
250
238
|
return typeof v === 'bigint' ? `${v}n` : (JSON.stringify(v) ?? String(v))
|
|
251
239
|
}
|
|
252
240
|
|
|
253
|
-
function validateRetention(
|
|
254
|
-
retention: NonNullable<NonNullable<WorkspaceConfig['cache']>['retention']>,
|
|
255
|
-
configPath: string,
|
|
256
|
-
): void {
|
|
257
|
-
const { olderThan, maxSize } = retention
|
|
258
|
-
// An empty object would load and evict nothing, which reads as a policy.
|
|
259
|
-
if (olderThan === undefined && maxSize === undefined) {
|
|
260
|
-
throw new UserError(
|
|
261
|
-
`${configPath}: \`cache.retention\` names neither \`olderThan\` nor \`maxSize\``,
|
|
262
|
-
)
|
|
263
|
-
}
|
|
264
|
-
// As for olderThan (item 969).
|
|
265
|
-
if (maxSize !== undefined && parseSize(maxSize) === 0) {
|
|
266
|
-
throw new UserError(
|
|
267
|
-
`${configPath}: \`cache.retention.maxSize\` of 0 evicts every entry after every run`,
|
|
268
|
-
)
|
|
269
|
-
}
|
|
270
|
-
if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
|
|
271
|
-
throw new UserError(
|
|
272
|
-
`${configPath}: \`cache.retention.maxSize\` '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}MB', '${maxSize}GB')`,
|
|
273
|
-
)
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
241
|
/** A `plugins` entry that stands for no plugin: `undefined`, `null` or `false`. */
|
|
278
242
|
export function isSkippedPlugin(p: unknown): p is undefined | null | false {
|
|
279
243
|
return p === undefined || p === null || p === false
|