@vzn/vx 0.0.50 → 0.0.52
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/README.md +38 -31
- package/package.json +5 -5
- package/src/bin.ts +1 -1
- package/src/cache/archive.ts +18 -6
- package/src/cache/cache.ts +150 -67
- package/src/cache/chained-cache.ts +9 -2
- package/src/cache/file-hashes.ts +32 -3
- package/src/cache/git-inputs.ts +96 -11
- package/src/cache/index.ts +1 -0
- package/src/cache/layer.ts +28 -3
- package/src/cache/layered-cache.ts +7 -2
- package/src/cache/output-index.ts +16 -7
- package/src/cache/run-history.ts +5 -1
- package/src/cache/zstd.ts +40 -8
- package/src/cli/cache.ts +35 -9
- package/src/cli/completions.ts +10 -7
- package/src/cli/help.ts +48 -4
- package/src/cli/index.ts +5 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/plugin-commands.ts +10 -2
- package/src/cli/run.ts +4 -0
- package/src/cli/select.ts +32 -2
- package/src/cli/upgrade.ts +110 -22
- package/src/cli/watch.ts +38 -20
- package/src/config.ts +18 -5
- package/src/exec/executor.ts +3 -2
- package/src/exec/index.ts +1 -1
- package/src/exec/runner.ts +102 -24
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +149 -40
- package/src/exec/sandbox-violations.ts +42 -10
- package/src/graph/scheduler.ts +11 -5
- package/src/graph/task-graph.ts +96 -12
- package/src/index.ts +1 -8
- package/src/orchestrator/admission.ts +72 -17
- package/src/orchestrator/execute-task.ts +39 -25
- package/src/orchestrator/hit-restore.ts +7 -1
- package/src/orchestrator/miss-save.ts +29 -1
- package/src/orchestrator/options.ts +8 -1
- package/src/orchestrator/placement.ts +23 -3
- package/src/orchestrator/plugin-host.ts +1 -1
- package/src/orchestrator/plugin.ts +9 -1
- package/src/orchestrator/prepare.ts +28 -10
- package/src/orchestrator/run.ts +75 -12
- package/src/orchestrator/sandbox-request.ts +19 -10
- package/src/orchestrator/summary.ts +26 -15
- package/src/orchestrator/task-hash.ts +7 -2
- package/src/orchestrator/telemetry.ts +14 -4
- package/src/util/paths.ts +16 -9
- package/src/workspace/affected.ts +201 -38
- package/src/workspace/config-cache.ts +0 -2
- package/src/workspace/config-eval.ts +4 -1
- package/src/workspace/config-schema.ts +70 -10
- package/src/workspace/fingerprint.ts +6 -0
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +53 -1
- package/src/workspace/workspace.ts +54 -14
|
@@ -282,9 +282,16 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
282
282
|
const sandbox = (exec as { sandbox?: unknown }).sandbox
|
|
283
283
|
if (sandbox !== undefined) validateSandbox(sandbox, `${where}.exec`)
|
|
284
284
|
const command = (exec as { command?: unknown }).command
|
|
285
|
-
|
|
285
|
+
// Whitespace alone ran as a shell no-op, green and cached (item 1099).
|
|
286
|
+
if (typeof command !== 'string' || command.trim().length === 0) {
|
|
286
287
|
throw new UserError(`${where}.exec.command must be a non-empty string`)
|
|
287
288
|
}
|
|
289
|
+
// No argv can carry a NUL: the spawn refused it and the task failed
|
|
290
|
+
// as exit 127, "not on this task's PATH", with the NUL printed as a
|
|
291
|
+
// space (D-4), as an env name with one did before item 999.
|
|
292
|
+
if (command.includes('\0')) {
|
|
293
|
+
throw new UserError(`${where}.exec.command holds a NUL, which no command line can carry`)
|
|
294
|
+
}
|
|
288
295
|
const timeout = (exec as { timeout?: unknown }).timeout
|
|
289
296
|
if (timeout !== undefined) {
|
|
290
297
|
if (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0) {
|
|
@@ -329,6 +336,16 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
329
336
|
`${where}.exec.env.passThrough must be an array of env var names (non-empty, no '=' or NUL)`,
|
|
330
337
|
)
|
|
331
338
|
}
|
|
339
|
+
// `VITE_*` passed nothing through and loaded clean (Turbo's
|
|
340
|
+
// passThroughEnv expands it), where `cache.inputs.env` refused the
|
|
341
|
+
// same text below: one refusal for the class (item 1090).
|
|
342
|
+
const wild = (passThrough as string[]).find((n) => BUN_GLOB_WILDCARDS.test(n))
|
|
343
|
+
if (wild !== undefined) {
|
|
344
|
+
throw new UserError(
|
|
345
|
+
`${where}.exec.env.passThrough: wildcards in env names are not supported ` +
|
|
346
|
+
`(got "${wild}") — list explicit env var names instead`,
|
|
347
|
+
)
|
|
348
|
+
}
|
|
332
349
|
}
|
|
333
350
|
const define = (env as { define?: unknown }).define
|
|
334
351
|
if (define !== undefined) {
|
|
@@ -582,9 +599,12 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
582
599
|
for (const field of ['runtime', 'workspaceRuntime'] as const) {
|
|
583
600
|
const list = (inputs as Record<string, unknown>)[field]
|
|
584
601
|
if (list !== undefined) {
|
|
585
|
-
if (
|
|
602
|
+
if (
|
|
603
|
+
!Array.isArray(list) ||
|
|
604
|
+
list.some((s) => typeof s !== 'string' || s.length === 0 || s.includes('\0'))
|
|
605
|
+
) {
|
|
586
606
|
throw new UserError(
|
|
587
|
-
`${where}.cache.inputs.${field} must be an array of non-empty shell command strings`,
|
|
607
|
+
`${where}.cache.inputs.${field} must be an array of non-empty shell command strings with no NUL`,
|
|
588
608
|
)
|
|
589
609
|
}
|
|
590
610
|
}
|
|
@@ -633,6 +653,40 @@ const CACHE_INPUT_FIELDS = new Set([
|
|
|
633
653
|
])
|
|
634
654
|
const CACHE_OUTPUT_FIELDS = new Set(['files', 'workspaceFiles'])
|
|
635
655
|
|
|
656
|
+
/** A field a release took out of the schema, and what took its place. */
|
|
657
|
+
interface Removal {
|
|
658
|
+
/** The vx version whose schema first refused it. */
|
|
659
|
+
removedIn: string
|
|
660
|
+
/** What to write instead, as a sentence fragment after "use". */
|
|
661
|
+
use: string
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Fields the schema once accepted, by the level that held them (keyed by
|
|
666
|
+
* that level's field set). A removed field is refused naming its
|
|
667
|
+
* replacement and the version that removed it, never as an unknown field a
|
|
668
|
+
* reader must research (docs/design/versioning-1.0.md § Deprecation). An
|
|
669
|
+
* entry stays for good: a config written against an old release meets it
|
|
670
|
+
* whenever it upgrades.
|
|
671
|
+
*/
|
|
672
|
+
export const REMOVED_FIELDS: ReadonlyMap<
|
|
673
|
+
ReadonlySet<string>,
|
|
674
|
+
Readonly<Record<string, Removal>>
|
|
675
|
+
> = new Map([
|
|
676
|
+
[
|
|
677
|
+
EXEC_FIELDS,
|
|
678
|
+
{
|
|
679
|
+
// Reservations left core with the `admit` seam (item 157).
|
|
680
|
+
resources: {
|
|
681
|
+
removedIn: '0.0.19',
|
|
682
|
+
use:
|
|
683
|
+
"`@vzn/vx-schedule-history`, which learns each task's reservation from its run " +
|
|
684
|
+
"history (declare one by hand with its `reservations: { 'pkg#task': { cpus, memory } }`)",
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
],
|
|
688
|
+
])
|
|
689
|
+
|
|
636
690
|
function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: string): void {
|
|
637
691
|
// `typeof [] === 'object'`, so an array reaches here and its indices read
|
|
638
692
|
// as fields: `outputs: ['dist/**']` (Turbo's spelling) was refused as
|
|
@@ -645,6 +699,12 @@ function assertKnownFields(value: object, allowed: ReadonlySet<string>, where: s
|
|
|
645
699
|
}
|
|
646
700
|
for (const key of Object.keys(value)) {
|
|
647
701
|
if (!allowed.has(key)) {
|
|
702
|
+
const removal = REMOVED_FIELDS.get(allowed)?.[key]
|
|
703
|
+
if (removal !== undefined) {
|
|
704
|
+
throw new UserError(
|
|
705
|
+
`${where} has field "${key}", which vx ${removal.removedIn} removed — use ${removal.use}`,
|
|
706
|
+
)
|
|
707
|
+
}
|
|
648
708
|
// The nearest accepted spelling first: the list says what the level
|
|
649
709
|
// takes, the hint says which one was meant.
|
|
650
710
|
const near = nearest(key, allowed)
|
|
@@ -934,6 +994,7 @@ const GRANT_FIELDS = new Set<string>([
|
|
|
934
994
|
'unixSockets',
|
|
935
995
|
])
|
|
936
996
|
const DENY_FIELDS = new Set(['network'])
|
|
997
|
+
const IGNORE_FIELDS = new Set(['read', 'write', 'systemInfo', 'network'])
|
|
937
998
|
|
|
938
999
|
function assertStringArray(v: unknown, where: string): void {
|
|
939
1000
|
if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
|
|
@@ -1021,17 +1082,16 @@ function validateSandbox(sandbox: unknown, where: string): void {
|
|
|
1021
1082
|
if (typeof ignore !== 'object' || ignore === null || Array.isArray(ignore)) {
|
|
1022
1083
|
throw new UserError(`${where}.sandbox.ignore must be an object`)
|
|
1023
1084
|
}
|
|
1024
|
-
|
|
1085
|
+
// Only these reach the report's filter (`matchesIgnore`): a denial is
|
|
1086
|
+
// classed read, write, systemInfo or network, and nothing else. Every
|
|
1087
|
+
// grant name loaded here once, so `ignore.machLookup` silenced nothing
|
|
1088
|
+
// and said so nowhere (D-4).
|
|
1089
|
+
assertKnownFields(ignore, IGNORE_FIELDS, `${where}.sandbox.ignore`)
|
|
1025
1090
|
const g = ignore as Record<string, unknown>
|
|
1026
1091
|
// Patterns, not prefixes — `ignore` is vx's own filter over lines it
|
|
1027
1092
|
// already holds, so a glob costs nothing and works on every platform.
|
|
1028
|
-
for (const f of
|
|
1093
|
+
for (const f of IGNORE_FIELDS) {
|
|
1029
1094
|
if (g[f] !== undefined) assertStringArray(g[f], `${where}.sandbox.ignore.${f}`)
|
|
1030
1095
|
}
|
|
1031
|
-
for (const f of GRANT_BOOL_FIELDS) {
|
|
1032
|
-
if (g[f] !== undefined) {
|
|
1033
|
-
throw new UserError(`${where}.sandbox.ignore.${f} is a flag, not something to ignore`)
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
1096
|
}
|
|
1037
1097
|
}
|
|
@@ -30,6 +30,12 @@ export const WORKSPACE_FINGERPRINT_FILES = [
|
|
|
30
30
|
// (turborepo#12635). pnpm's catalogs live in pnpm-workspace.yaml, and
|
|
31
31
|
// its lockfile records each importer's resolved version besides.
|
|
32
32
|
'.yarnrc.yml',
|
|
33
|
+
// Install settings no lockfile records: bun's `linker = "isolated"` and
|
|
34
|
+
// pnpm's `node-linker` / hoisting lay `node_modules` out anew under a
|
|
35
|
+
// byte-identical lockfile, and a build that imported an undeclared
|
|
36
|
+
// hoisted package kept its green hit where it now fails (D-7).
|
|
37
|
+
'.npmrc',
|
|
38
|
+
'bunfig.toml',
|
|
33
39
|
]
|
|
34
40
|
|
|
35
41
|
// DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs}`. Everything it can
|
|
@@ -101,7 +101,7 @@ export interface MigrationPlan {
|
|
|
101
101
|
projects: GeneratedProject[]
|
|
102
102
|
/** extra root-relative files (e.g. the preset) */
|
|
103
103
|
extraFiles: { relPath: string; contents: string }[]
|
|
104
|
-
/** trailing report lines (e.g.
|
|
104
|
+
/** trailing report lines (e.g. what a turbo.json holds that vx has no place for) */
|
|
105
105
|
notes: string[]
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
3
4
|
import { UserError, xxh3hex } from '../util/index.js'
|
|
4
5
|
import { validateProjectConfig, validateWorkspace } from './config-schema.js'
|
|
5
6
|
import { beginEvalRound, evaluateConfigFresh } from './config-eval.js'
|
|
6
7
|
import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
configEvalKey,
|
|
10
|
+
configEvalKeyFromClosure,
|
|
11
|
+
configImports,
|
|
12
|
+
type ConfigEvalStore,
|
|
13
|
+
} from './config-cache.js'
|
|
8
14
|
import { readOnce } from './load-reads.js'
|
|
9
15
|
|
|
10
16
|
// The validator lives in config-schema.ts; re-exported so a reader that
|
|
@@ -420,6 +426,41 @@ export async function loadProjectConfig(
|
|
|
420
426
|
return config!
|
|
421
427
|
}
|
|
422
428
|
|
|
429
|
+
/** When this process first began loading each workspace config, in ms. */
|
|
430
|
+
const workspaceLoadedAt = new Map<string, number>()
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* A repeat load in one process (`vx mcp`'s every call, `vx watch`'s every
|
|
434
|
+
* cycle) busts the workspace config's own URL, but Bun answers what it
|
|
435
|
+
* imports from the module registry: an edited local plugin kept its first
|
|
436
|
+
* version and `listTasks` served its old tasks with no word (item 1046).
|
|
437
|
+
* Bun cannot evaluate an imported module again, so the load is refused,
|
|
438
|
+
* naming the file, as `vx watch` already says when it sees the edit. Only
|
|
439
|
+
* a repeat load pays for the walk; a CLI verb loads once.
|
|
440
|
+
*/
|
|
441
|
+
async function refuseStaleWorkspaceImports(configPath: string, since: number): Promise<void> {
|
|
442
|
+
for (const file of await configImports(configPath)) {
|
|
443
|
+
let mtime: number
|
|
444
|
+
try {
|
|
445
|
+
mtime = statSync(file).mtimeMs
|
|
446
|
+
} catch {
|
|
447
|
+
continue
|
|
448
|
+
}
|
|
449
|
+
// `since` is whole milliseconds and an mtime is not: a file written in
|
|
450
|
+
// the millisecond the first load began (1000.4 against 1000) read as
|
|
451
|
+
// changed, and the control reload was refused one run in ten. An edit
|
|
452
|
+
// inside that millisecond is one the load may have read anyway.
|
|
453
|
+
if (Math.floor(mtime) > since) {
|
|
454
|
+
// The walk names real paths: a root reached through a symlink (macOS's
|
|
455
|
+
// temp dir) named `../target/helper.mjs` from the link.
|
|
456
|
+
const from = path.dirname(realpathSync(configPath))
|
|
457
|
+
throw new UserError(
|
|
458
|
+
`${path.basename(configPath)} imports ${path.relative(from, file)}, which changed after this process loaded it; a running process cannot evaluate an imported module again — restart it to apply the edit`,
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
423
464
|
/**
|
|
424
465
|
* Find and load `vx.workspace.{ts,mts,js,mjs}` from the workspace
|
|
425
466
|
* root. Returns `null` if no such file exists (the common case;
|
|
@@ -430,8 +471,19 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
|
|
|
430
471
|
for (const configPath of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
|
|
431
472
|
const bytes = await readOnce(undefined, configPath)
|
|
432
473
|
if (bytes === null) continue
|
|
474
|
+
const since = workspaceLoadedAt.get(configPath)
|
|
475
|
+
if (since !== undefined) await refuseStaleWorkspaceImports(configPath, since)
|
|
476
|
+
const startedAt = Date.now()
|
|
433
477
|
const mod = (await loadDefaultExport(configPath, 'Workspace', bytes)) as WorkspaceConfig
|
|
478
|
+
// Checked again once awaited, as a project config is: a Promise default
|
|
479
|
+
// passed the first check, and `Promise.resolve(null)` crashed the
|
|
480
|
+
// validator with a stack while `Promise.resolve(42)` loaded as no
|
|
481
|
+
// config at all (D-6).
|
|
482
|
+
assertDefaultObject(mod, 'Workspace', configPath)
|
|
434
483
|
validateWorkspace(mod, configPath)
|
|
484
|
+
// Set by a load that succeeded: a failed one may have left nothing in
|
|
485
|
+
// the registry, and its fix must not be refused.
|
|
486
|
+
if (since === undefined) workspaceLoadedAt.set(configPath, startedAt)
|
|
435
487
|
return mod
|
|
436
488
|
}
|
|
437
489
|
return null
|
|
@@ -416,25 +416,65 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
|
|
|
416
416
|
}
|
|
417
417
|
return dirs
|
|
418
418
|
}
|
|
419
|
-
const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
|
|
420
|
-
const glob = new Bun.Glob(globPattern)
|
|
421
419
|
const dirs: string[] = []
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
420
|
+
// `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
|
|
421
|
+
// slash, though its `match` reads one: `packages/{a,nested/b}` listed
|
|
422
|
+
// neither package, and both ran under no verb while the root still
|
|
423
|
+
// claimed them (D-2). Such a pattern is scanned as its expansions.
|
|
424
|
+
for (const expanded of slashBraceExpansions(normalized)) {
|
|
425
|
+
const globPattern = expanded === '' ? 'package.json' : `${expanded}/package.json`
|
|
426
|
+
const glob = new Bun.Glob(globPattern)
|
|
427
|
+
// A linked member is a member, as the readdir path above has it:
|
|
428
|
+
// `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
|
|
429
|
+
// `pack*/*` did not (item 987). Followed only where the depth is bounded:
|
|
430
|
+
// under `**` the scan would walk every pnpm `node_modules` link.
|
|
431
|
+
const followSymlinks = !expanded.includes('**')
|
|
432
|
+
for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false, followSymlinks })) {
|
|
433
|
+
// Skip nested node_modules — workspace package globs shouldn't
|
|
434
|
+
// ever reach into them, but a pathological pattern like `**`
|
|
435
|
+
// would. Avoid splitting the path on the hot loop.
|
|
436
|
+
if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
|
|
437
|
+
if (rel.startsWith(`node_modules${path.sep}`)) continue
|
|
438
|
+
dirs.push(path.dirname(path.resolve(root, rel)))
|
|
439
|
+
}
|
|
434
440
|
}
|
|
435
441
|
return dirs
|
|
436
442
|
}
|
|
437
443
|
|
|
444
|
+
/**
|
|
445
|
+
* `pattern` with its first brace group expanded, recursively, while that
|
|
446
|
+
* group holds a `/`; any other pattern as itself, since `Bun.Glob`
|
|
447
|
+
* expands a slash-free brace on its own. An unbalanced or escaped brace
|
|
448
|
+
* is left to the glob.
|
|
449
|
+
*/
|
|
450
|
+
function slashBraceExpansions(pattern: string): string[] {
|
|
451
|
+
if (pattern.includes('\\')) return [pattern]
|
|
452
|
+
for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
|
|
453
|
+
let depth = 0
|
|
454
|
+
const cuts: number[] = []
|
|
455
|
+
let close = -1
|
|
456
|
+
for (let i = open; i < pattern.length && close === -1; i++) {
|
|
457
|
+
const c = pattern[i]
|
|
458
|
+
if (c === '{') depth++
|
|
459
|
+
else if (c === '}' && --depth === 0) close = i
|
|
460
|
+
else if (c === ',' && depth === 1) cuts.push(i)
|
|
461
|
+
}
|
|
462
|
+
if (close === -1) return [pattern]
|
|
463
|
+
const body = pattern.slice(open + 1, close)
|
|
464
|
+
if (cuts.length === 0 || !body.includes('/')) continue
|
|
465
|
+
const head = pattern.slice(0, open)
|
|
466
|
+
const tail = pattern.slice(close + 1)
|
|
467
|
+
const bounds = [open, ...cuts, close]
|
|
468
|
+
const out: string[] = []
|
|
469
|
+
for (let k = 0; k + 1 < bounds.length; k++) {
|
|
470
|
+
const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
|
|
471
|
+
out.push(...slashBraceExpansions(head + alt + tail))
|
|
472
|
+
}
|
|
473
|
+
return [...new Set(out)]
|
|
474
|
+
}
|
|
475
|
+
return [pattern]
|
|
476
|
+
}
|
|
477
|
+
|
|
438
478
|
/**
|
|
439
479
|
* The project's config file, by `PROJECT_CONFIG_FILENAMES` precedence, or null.
|
|
440
480
|
*
|