@vzn/vx 0.0.49 → 0.0.51
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 +1 -0
- package/src/cli/select.ts +29 -2
- package/src/cli/upgrade.ts +110 -22
- package/src/cli/watch.ts +35 -18
- 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/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/placement.ts +23 -3
- package/src/orchestrator/plugin-host.ts +1 -1
- package/src/orchestrator/plugin.ts +9 -1
- package/src/orchestrator/prepare.ts +1 -0
- 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-eval.ts +4 -1
- package/src/workspace/config-schema.ts +70 -10
- package/src/workspace/fingerprint.ts +6 -0
- package/src/workspace/migrate-scripts.ts +11 -5
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +53 -1
- package/src/workspace/workspace.ts +54 -14
|
@@ -90,13 +90,20 @@ function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
|
|
|
90
90
|
return raw as Record<string, unknown>
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** The cache block the task that builds should declare, as a TODO on it. */
|
|
94
|
+
const CACHE_TODO =
|
|
95
|
+
"cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task"
|
|
96
|
+
|
|
93
97
|
/**
|
|
94
98
|
* A `build` that only delegates (`build: pnpm run compile`) is a group over
|
|
95
99
|
* its target, and the `^build` edge every `build` carries belongs on the
|
|
96
100
|
* task that does the work: the group ran `compile` with no edge at all, so
|
|
97
101
|
* a package compiled before the ones it imports had built (item 907). On
|
|
98
102
|
* the group itself it would not hold — the group waits on both, `compile`
|
|
99
|
-
* on neither. Followed through a chain of groups to the first command
|
|
103
|
+
* on neither. Followed through a chain of groups to the first command,
|
|
104
|
+
* which takes the cache TODO too: a group has no command to cache, and the
|
|
105
|
+
* TODO a delegating `build` never got was the one the header promised
|
|
106
|
+
* (item 1045).
|
|
100
107
|
*/
|
|
101
108
|
function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
|
|
102
109
|
const byName = new Map(tasks.map((t) => [t.name, t]))
|
|
@@ -111,6 +118,7 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
|
|
|
111
118
|
if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
|
|
112
119
|
const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
|
|
113
120
|
if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
|
|
121
|
+
if (!at.todos.includes(CACHE_TODO)) at.todos.push(CACHE_TODO)
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
@@ -175,9 +183,7 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
175
183
|
}
|
|
176
184
|
if (name === 'build') {
|
|
177
185
|
task['dependsOn'] = ['^build']
|
|
178
|
-
todos.push(
|
|
179
|
-
"cache: add `cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } }` with this package's real inputs and outputs — without it the task always runs and every file here, what it writes included, folds into the key its dependents fold; a block with EMPTY outputs would be a cached no-op, not an uncached task",
|
|
180
|
-
)
|
|
186
|
+
todos.push(CACHE_TODO)
|
|
181
187
|
} else if (AFTER_BUILD.has(name) && hasBuild) {
|
|
182
188
|
task['dependsOn'] = ['build']
|
|
183
189
|
}
|
|
@@ -197,7 +203,7 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
|
|
|
197
203
|
}
|
|
198
204
|
return {
|
|
199
205
|
headerNotes: [
|
|
200
|
-
'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block — `build
|
|
206
|
+
'each script became a task with its command verbatim; caching needs declared inputs and outputs, so no task got a cache block — `build`, or the script it delegates to, carries a TODO showing the one to add',
|
|
201
207
|
],
|
|
202
208
|
projects,
|
|
203
209
|
extraFiles: [],
|
|
@@ -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
|
*
|