@vzn/vx 0.0.50 → 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/migration.ts +1 -1
- package/src/workspace/project-loader.ts +53 -1
- package/src/workspace/workspace.ts +54 -14
|
@@ -326,23 +326,34 @@ export function formatRunSummary(
|
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
/**
|
|
329
|
-
* Post-summary section naming the tasks a shutdown signal killed
|
|
330
|
-
* task did no work, so it is in
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
* ever prints for a run that reached its summary WITH a task killed by some
|
|
335
|
-
* other signal — an external `kill`, a supervisor, a self-terminating child.
|
|
336
|
-
* Empty when nothing aborted.
|
|
329
|
+
* Post-summary section naming the tasks a shutdown signal killed, and those
|
|
330
|
+
* the stop reached before they ran. An aborted task did no work, so it is in
|
|
331
|
+
* no tally bucket and no history row — but the run still exits non-zero, and
|
|
332
|
+
* without this the user reads a red exit over a fully green summary that
|
|
333
|
+
* names nothing. Empty when nothing aborted.
|
|
337
334
|
*/
|
|
338
335
|
export function formatAbortedSection(outcomes: readonly TaskOutcome[]): string[] {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
336
|
+
// The scheduler marks what the stop reached before dispatch `aborted` too,
|
|
337
|
+
// with an invented exit 1: listed as killed, a task that never ran read as
|
|
338
|
+
// one the signal cut short (item 1062). Only a started one carries a
|
|
339
|
+
// wall-clock start. A group is no task here, as in the skipped section.
|
|
340
|
+
const aborted = outcomes.filter((o) => o.status === 'aborted' && !isGroupTask(o.node))
|
|
341
|
+
const killed = aborted.filter((o) => o.wallclockStartNs !== undefined)
|
|
342
|
+
const unstarted = aborted.filter((o) => o.wallclockStartNs === undefined)
|
|
343
|
+
const tasks = (n: number): string => `${n} task${n === 1 ? '' : 's'}`
|
|
344
|
+
const lines: string[] = []
|
|
345
|
+
if (killed.length > 0) {
|
|
346
|
+
lines.push(
|
|
347
|
+
'',
|
|
348
|
+
` Aborted: ${tasks(killed.length)} killed by a shutdown signal — not counted above`,
|
|
349
|
+
)
|
|
350
|
+
for (const o of killed) lines.push(` ✗ ${o.node.id} — exit ${o.exitCode}, nothing cached`)
|
|
351
|
+
}
|
|
352
|
+
if (unstarted.length > 0) {
|
|
353
|
+
const they = unstarted.length === 1 ? 'it' : 'they'
|
|
354
|
+
lines.push('', ` Not started: ${tasks(unstarted.length)} the run stopped before ${they} ran`)
|
|
355
|
+
for (const o of unstarted) lines.push(` · ${o.node.id}`)
|
|
356
|
+
}
|
|
346
357
|
return lines
|
|
347
358
|
}
|
|
348
359
|
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type CacheKeyInput,
|
|
7
7
|
type CacheLayer,
|
|
8
8
|
FILE_HASH_RACY_MS,
|
|
9
|
+
racyWindowMs,
|
|
9
10
|
resolveInputs,
|
|
10
11
|
type GitFilesCache,
|
|
11
12
|
WORKSPACE_OUTPUT_PREFIX,
|
|
@@ -222,8 +223,12 @@ export async function movedInput(
|
|
|
222
223
|
} catch {
|
|
223
224
|
return f.path
|
|
224
225
|
}
|
|
225
|
-
|
|
226
|
-
|
|
226
|
+
// A whole-second stamp may stand for any write in its second, one
|
|
227
|
+
// during the command included (`racyWindowMs`).
|
|
228
|
+
if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
|
|
229
|
+
return f.path
|
|
230
|
+
}
|
|
231
|
+
if (ctimeMs >= f.since - racyWindowMs(ctimeMs, FILE_HASH_RACY_MS)) suspects.push(f)
|
|
227
232
|
}
|
|
228
233
|
for (const f of suspects) {
|
|
229
234
|
const now = await cache.hashFile(f.path).catch(() => undefined)
|
|
@@ -107,7 +107,7 @@ export interface RunContextRecord {
|
|
|
107
107
|
/** ULID, shared by every record in one `vx run`. */
|
|
108
108
|
runId: string
|
|
109
109
|
vxVersion: string
|
|
110
|
-
/** The invocation command line (process.argv-derived). */
|
|
110
|
+
/** The invocation command line (process.argv-derived), what follows `--` counted, not quoted. */
|
|
111
111
|
command: string
|
|
112
112
|
requestedTasks: readonly string[]
|
|
113
113
|
/** Compact cache-policy flags, e.g. `'lR,lW,rR,rW'`. */
|
|
@@ -318,8 +318,14 @@ export interface TelemetrySink {
|
|
|
318
318
|
* Losing a slow sink's telemetry is strictly better than the alternative:
|
|
319
319
|
* `run()` never returns, so the cache never closes and `vx` exits 0 on a
|
|
320
320
|
* failed run.
|
|
321
|
+
*
|
|
322
|
+
* `signal` aborts at that deadline. Abandoning a flush does not end its
|
|
323
|
+
* I/O: a request still in flight keeps the event loop, and so the
|
|
324
|
+
* process, alive after the run has returned — a hanging GitHub API held
|
|
325
|
+
* `vx run` open until the CI job's own timeout (item 1055). A sink passes
|
|
326
|
+
* the signal to its `fetch` (or closes its socket on it).
|
|
321
327
|
*/
|
|
322
|
-
flush?(): Promise<void>
|
|
328
|
+
flush?(signal: AbortSignal): Promise<void>
|
|
323
329
|
}
|
|
324
330
|
|
|
325
331
|
/** Read-only context a sink is created with. No mutable run handle — the
|
|
@@ -496,12 +502,13 @@ export function createTelemetrySource(args: {
|
|
|
496
502
|
// pending — Bun exits 0 and a FAILED run reports green, with the cache's
|
|
497
503
|
// accessed_at bumps and every later plugin's teardown lost with it.
|
|
498
504
|
// Sinks race concurrently, so each still gets the whole budget.
|
|
505
|
+
const deadline = new AbortController()
|
|
499
506
|
const settled = await settleWithin(
|
|
500
507
|
Promise.all(
|
|
501
508
|
sinks.map(async (sink) => {
|
|
502
509
|
if (disabled.has(sink) || sink.flush === undefined) return
|
|
503
510
|
try {
|
|
504
|
-
await sink.flush()
|
|
511
|
+
await sink.flush(deadline.signal)
|
|
505
512
|
} catch (err) {
|
|
506
513
|
// A flush failure can never break the run — but it still costs
|
|
507
514
|
// this sink its export, so say so rather than dropping it.
|
|
@@ -513,7 +520,10 @@ export function createTelemetrySource(args: {
|
|
|
513
520
|
),
|
|
514
521
|
ms,
|
|
515
522
|
)
|
|
516
|
-
if (!settled)
|
|
523
|
+
if (!settled) {
|
|
524
|
+
deadline.abort(new Error(`telemetry flush deadline (${ms}ms)`))
|
|
525
|
+
warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
|
|
526
|
+
}
|
|
517
527
|
},
|
|
518
528
|
}
|
|
519
529
|
}
|
package/src/util/paths.ts
CHANGED
|
@@ -69,21 +69,28 @@ function prefixOf(glob: string, wildcards: RegExp): string {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* The directories a task's declared outputs cover WHOLE — every glob is
|
|
72
|
-
* `<dir>/**`
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
72
|
+
* `<dir>/**` or a bare literal (which `asTrees` reads as the path or the
|
|
73
|
+
* tree under it), with a plain, non-root, non-escaping `<dir>` — or `null`
|
|
74
|
+
* when any glob is shaped otherwise. The directory-mtime short-circuit on a
|
|
75
|
+
* warm hit (`Cache.outputDirsCurrent`) is sound only for whole subtrees:
|
|
76
|
+
* with every directory under `<dir>` recorded, a file added or removed
|
|
77
|
+
* anywhere the glob could see bumps a recorded directory's mtime (its
|
|
78
|
+
* parent, or a new directory whose creation bumped a recorded ancestor). A
|
|
79
|
+
* root-anchored `**\/*.js` has no such closed set, so it keeps the walk.
|
|
80
|
+
* A literal that names a FILE is no directory: the snapshot refuses it
|
|
81
|
+
* whole (`recordOutputDirs`) and that task keeps the walk too, so the
|
|
82
|
+
* literal costs nothing it did not cost before; one that names a directory
|
|
83
|
+
* gets the same stats a `<dir>/**` does (Next 27: an nx() output file
|
|
84
|
+
* had to be `<dir>/**` to keep the short-circuit, and saved nothing).
|
|
79
85
|
*/
|
|
80
86
|
export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null {
|
|
81
87
|
if (globs.length === 0) return null
|
|
82
88
|
const out: string[] = []
|
|
83
89
|
for (const g of globs.map(normalizeGlob)) {
|
|
84
90
|
const m = /^([^*?{}!]+?)\/\*\*$/.exec(g)
|
|
85
|
-
|
|
86
|
-
|
|
91
|
+
const whole = m?.[1] ?? (isLiteralPattern(g) && !g.startsWith('!') ? g : null)
|
|
92
|
+
if (whole === null) return null
|
|
93
|
+
const dir = whole.replace(/\/+$/, '')
|
|
87
94
|
if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
|
|
88
95
|
return null
|
|
89
96
|
out.push(dir)
|
|
@@ -21,6 +21,7 @@ import { configImportOwners } from './config-imports.js'
|
|
|
21
21
|
import { configImports } from './config-cache.js'
|
|
22
22
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
23
23
|
import { WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
|
|
24
|
+
import { buildPackageGraph } from './package-graph.js'
|
|
24
25
|
import type { PackageJson, ProjectMeta } from './workspace.js'
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -42,9 +43,19 @@ function spawnGitSync(
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
function spawnGit(
|
|
46
|
+
function spawnGit(
|
|
47
|
+
args: string[],
|
|
48
|
+
cwd: string,
|
|
49
|
+
stdin?: Uint8Array,
|
|
50
|
+
): Bun.Subprocess<Uint8Array | 'ignore', 'pipe', 'pipe'> {
|
|
46
51
|
try {
|
|
47
|
-
return Bun.spawn({
|
|
52
|
+
return Bun.spawn({
|
|
53
|
+
cmd: [executablePath('git'), ...args],
|
|
54
|
+
cwd,
|
|
55
|
+
stdin: stdin ?? 'ignore',
|
|
56
|
+
stdout: 'pipe',
|
|
57
|
+
stderr: 'pipe',
|
|
58
|
+
})
|
|
48
59
|
} catch (err) {
|
|
49
60
|
if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
|
|
50
61
|
throw err
|
|
@@ -79,6 +90,13 @@ export interface AffectedArgs {
|
|
|
79
90
|
* — the common one — never needs it.
|
|
80
91
|
*/
|
|
81
92
|
fingerprintClaims?: () => Promise<FingerprintClaims>
|
|
93
|
+
/**
|
|
94
|
+
* The cross-project `dependsOn` edges, project → the projects its tasks
|
|
95
|
+
* name. Asked only when a package's name moved or it was removed: a
|
|
96
|
+
* config naming `dependsOn: ['lib#build']` breaks on it, which the
|
|
97
|
+
* package graph cannot see (item 1085).
|
|
98
|
+
*/
|
|
99
|
+
taskEdges?: () => Promise<ReadonlyMap<string, readonly string[]>>
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
export interface FingerprintClaims {
|
|
@@ -225,13 +243,28 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
225
243
|
const owned = projectsContaining(args.workspaceRoot, changed, args.projects)
|
|
226
244
|
for (const name of claimedOwned) owned.add(name)
|
|
227
245
|
|
|
228
|
-
// A
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
246
|
+
// A manifest edit can drop an edge today's graph no longer shows: a
|
|
247
|
+
// package the change REMOVED is no project now (a `git rm -r pkgs/lib`,
|
|
248
|
+
// or `lib` dropped from `workspaces`, item 959), and one whose `version`
|
|
249
|
+
// or `name` moved no longer satisfies what a dependent declares. Either
|
|
250
|
+
// re-keys the dependent through its upstream, while containment maps the
|
|
251
|
+
// change to the package alone and the dependents walk sees only today's
|
|
252
|
+
// graph, so `--affected` left `app#build` out (D-3).
|
|
253
|
+
const manifests = changed.filter((rel) => path.posix.basename(rel) === 'package.json')
|
|
254
|
+
if (manifests.length > 0) {
|
|
255
|
+
const atBase = await gitBlobsAt(args.workspaceRoot, base, manifests)
|
|
256
|
+
const dependents = await dependentsAtBase(
|
|
257
|
+
args.workspaceRoot,
|
|
258
|
+
atBase,
|
|
259
|
+
args.projects,
|
|
260
|
+
args.taskEdges,
|
|
261
|
+
)
|
|
262
|
+
if (dependents === undefined) return new Set(args.projects.map((p) => p.name))
|
|
263
|
+
for (const name of dependents) owned.add(name)
|
|
264
|
+
for (const name of parentsOfNewNested(args.workspaceRoot, atBase, args.projects)) {
|
|
265
|
+
owned.add(name)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
235
268
|
|
|
236
269
|
// THIRD CHANNEL: a project whose `vx.config.*` IMPORTS a changed file.
|
|
237
270
|
// Resolved-config hashing folds those values into the key, so the same
|
|
@@ -254,22 +287,27 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
254
287
|
}
|
|
255
288
|
|
|
256
289
|
/**
|
|
257
|
-
* The projects
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
* base,
|
|
290
|
+
* The projects whose workspace dependencies at the base differ from
|
|
291
|
+
* today's: the package graph is built again over the changed manifests as
|
|
292
|
+
* the base had them, and every project whose `directDeps` (the edges its
|
|
293
|
+
* key folds) moved is selected. `undefined` (every project) when the root
|
|
294
|
+
* manifest's `workspaces` moved: which packages left is a discovery at the
|
|
295
|
+
* base, and selection cannot tell cheaply. `atBase` holds each changed
|
|
296
|
+
* manifest as the base had it, read in one `git cat-file --batch`.
|
|
263
297
|
*/
|
|
264
|
-
async function
|
|
298
|
+
async function dependentsAtBase(
|
|
265
299
|
workspaceRoot: string,
|
|
266
|
-
|
|
267
|
-
changed: readonly string[],
|
|
300
|
+
atBase: ReadonlyMap<string, Uint8Array | null>,
|
|
268
301
|
projects: readonly ProjectMeta[],
|
|
302
|
+
taskEdges: AffectedArgs['taskEdges'],
|
|
269
303
|
): Promise<Set<string> | undefined> {
|
|
270
|
-
const
|
|
271
|
-
const
|
|
272
|
-
|
|
304
|
+
const byDir = new Map<string, ProjectMeta>()
|
|
305
|
+
for (const p of projects) byDir.set(p.dir, p)
|
|
306
|
+
const nameNow = new Map(projects.map((p) => [p.dir, p.name]))
|
|
307
|
+
// Base names no project holds today: a removed or renamed package.
|
|
308
|
+
const gone = new Set<string>()
|
|
309
|
+
const changedDirs = new Set<string>()
|
|
310
|
+
for (const rel of atBase.keys()) {
|
|
273
311
|
if (rel === 'package.json') {
|
|
274
312
|
const workspacesOf = (bytes: Uint8Array | null): string => {
|
|
275
313
|
if (bytes === null) return ''
|
|
@@ -281,37 +319,141 @@ async function dependentsOfRemoved(
|
|
|
281
319
|
return 'unreadable'
|
|
282
320
|
}
|
|
283
321
|
}
|
|
284
|
-
const before = workspacesOf(
|
|
322
|
+
const before = workspacesOf(atBase.get(rel) ?? null)
|
|
285
323
|
const after = workspacesOf(await bytesOrNull(path.join(workspaceRoot, rel)))
|
|
286
324
|
if (before !== after) return undefined
|
|
287
|
-
continue
|
|
288
325
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
326
|
+
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
|
|
327
|
+
changedDirs.add(dir)
|
|
328
|
+
byDir.delete(dir)
|
|
329
|
+
const bytes = atBase.get(rel)
|
|
330
|
+
if (bytes === undefined || bytes === null) continue
|
|
331
|
+
let pkg: PackageJson
|
|
293
332
|
try {
|
|
294
|
-
|
|
295
|
-
if (typeof name === 'string' && name !== '') removed.add(name)
|
|
333
|
+
pkg = JSON.parse(new TextDecoder().decode(bytes)) as PackageJson
|
|
296
334
|
} catch {
|
|
297
335
|
// A manifest that did not parse at the base named no package.
|
|
336
|
+
continue
|
|
298
337
|
}
|
|
338
|
+
if (
|
|
339
|
+
pkg === null ||
|
|
340
|
+
typeof pkg !== 'object' ||
|
|
341
|
+
typeof pkg.name !== 'string' ||
|
|
342
|
+
pkg.name === ''
|
|
343
|
+
) {
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
byDir.set(dir, { name: pkg.name, dir, packageJson: pkg, configPath: null })
|
|
347
|
+
if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
|
|
299
348
|
}
|
|
300
|
-
|
|
349
|
+
const now = buildPackageGraph([...projects])
|
|
350
|
+
const then = buildPackageGraph([...byDir.values()])
|
|
351
|
+
const out = new Set<string>()
|
|
301
352
|
for (const p of projects) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
353
|
+
// A project whose own manifest changed is owned by containment.
|
|
354
|
+
if (changedDirs.has(p.dir)) continue
|
|
355
|
+
const a = now.directDeps(p.name)
|
|
356
|
+
const b = then.directDeps(p.name)
|
|
357
|
+
if (a.length !== b.length || a.some((d, i) => d !== b[i])) out.add(p.name)
|
|
358
|
+
}
|
|
359
|
+
if (gone.size > 0 && taskEdges !== undefined) {
|
|
360
|
+
for (const [project, targets] of await taskEdges()) {
|
|
361
|
+
if (targets.some((t) => gone.has(t))) out.add(project)
|
|
310
362
|
}
|
|
311
363
|
}
|
|
312
364
|
return out
|
|
313
365
|
}
|
|
314
366
|
|
|
367
|
+
/**
|
|
368
|
+
* The projects a NEW nested project took files from. A project's inputs
|
|
369
|
+
* stop at every project below it, so a `package.json` that makes an
|
|
370
|
+
* existing directory a project re-keys the project above it — its
|
|
371
|
+
* `**` lost the directory's files — while containment maps the change to
|
|
372
|
+
* the new project alone, and `--affected` left the re-keyed parent out.
|
|
373
|
+
* "New" is judged at the base: no manifest there, or one with no name,
|
|
374
|
+
* which discovery skips. `atBase` holds each changed manifest there.
|
|
375
|
+
*/
|
|
376
|
+
function parentsOfNewNested(
|
|
377
|
+
workspaceRoot: string,
|
|
378
|
+
atBase: ReadonlyMap<string, Uint8Array | null>,
|
|
379
|
+
projects: readonly ProjectMeta[],
|
|
380
|
+
): Set<string> {
|
|
381
|
+
const out = new Set<string>()
|
|
382
|
+
const dirToName = new Map<string, string>()
|
|
383
|
+
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
384
|
+
for (const [rel, bytes] of atBase) {
|
|
385
|
+
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
|
|
386
|
+
if (!dirToName.has(dir)) continue
|
|
387
|
+
let parent: string | undefined
|
|
388
|
+
for (let d = path.dirname(dir); parent === undefined; d = path.dirname(d)) {
|
|
389
|
+
parent = dirToName.get(d)
|
|
390
|
+
if (path.dirname(d) === d) break
|
|
391
|
+
}
|
|
392
|
+
if (parent === undefined || out.has(parent)) continue
|
|
393
|
+
let named = false
|
|
394
|
+
if (bytes !== null) {
|
|
395
|
+
try {
|
|
396
|
+
const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
|
|
397
|
+
named = typeof name === 'string' && name !== ''
|
|
398
|
+
} catch {
|
|
399
|
+
// A manifest that did not parse at the base named no project.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (!named) out.add(parent)
|
|
403
|
+
}
|
|
404
|
+
return out
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Each of `files` (workspace-root-relative) as `ref` has it, in one `git
|
|
409
|
+
* cat-file --batch`: null for a file the ref does not have. `./` anchors
|
|
410
|
+
* each path to the cwd, the workspace root, as `gitBytesAt` does.
|
|
411
|
+
*/
|
|
412
|
+
async function gitBlobsAt(
|
|
413
|
+
workspaceRoot: string,
|
|
414
|
+
ref: string,
|
|
415
|
+
files: readonly string[],
|
|
416
|
+
): Promise<Map<string, Uint8Array | null>> {
|
|
417
|
+
const blobs = new Map<string, Uint8Array | null>()
|
|
418
|
+
// The batch reads one path per line; a name holding a newline asks alone.
|
|
419
|
+
const batched: string[] = []
|
|
420
|
+
for (const file of files) {
|
|
421
|
+
if (file.includes('\n')) blobs.set(file, await gitBytesAt(workspaceRoot, ref, file))
|
|
422
|
+
else batched.push(file)
|
|
423
|
+
}
|
|
424
|
+
if (batched.length === 0) return blobs
|
|
425
|
+
const proc = spawnGit(
|
|
426
|
+
['cat-file', '--batch'],
|
|
427
|
+
workspaceRoot,
|
|
428
|
+
new TextEncoder().encode(batched.map((f) => `${ref}:./${f}\n`).join('')),
|
|
429
|
+
)
|
|
430
|
+
const [out, stderr, exit] = await Promise.all([
|
|
431
|
+
new Response(proc.stdout).bytes(),
|
|
432
|
+
new Response(proc.stderr).text(),
|
|
433
|
+
proc.exited,
|
|
434
|
+
])
|
|
435
|
+
if (exit !== 0) {
|
|
436
|
+
throw new UserError(`git cat-file --batch failed (exit ${exit}): ${stderr.trim()}`)
|
|
437
|
+
}
|
|
438
|
+
let at = 0
|
|
439
|
+
for (const file of batched) {
|
|
440
|
+
const eol = out.indexOf(0x0a, at)
|
|
441
|
+
if (eol === -1) throw new UserError(`git cat-file --batch ended before ${ref}:./${file}`)
|
|
442
|
+
const header = new TextDecoder().decode(out.subarray(at, eol))
|
|
443
|
+
at = eol + 1
|
|
444
|
+
// `<oid> <type> <size>`, or `<spec> missing` / `<spec> ambiguous`.
|
|
445
|
+
const m = /^[0-9a-f]{40,64} (\w+) (\d+)$/.exec(header)
|
|
446
|
+
if (m === null) {
|
|
447
|
+
blobs.set(file, null)
|
|
448
|
+
continue
|
|
449
|
+
}
|
|
450
|
+
const size = Number(m[2])
|
|
451
|
+
blobs.set(file, m[1] === 'blob' ? out.slice(at, at + size) : null)
|
|
452
|
+
at += size + 1
|
|
453
|
+
}
|
|
454
|
+
return blobs
|
|
455
|
+
}
|
|
456
|
+
|
|
315
457
|
/**
|
|
316
458
|
* Whether `changed` holds the workspace config or a file it imports by
|
|
317
459
|
* relative specifier. Its plugins' `config` and `project` stages shape
|
|
@@ -505,6 +647,14 @@ function isDirectory(abs: string): boolean {
|
|
|
505
647
|
}
|
|
506
648
|
}
|
|
507
649
|
|
|
650
|
+
function realpathOr(p: string): string {
|
|
651
|
+
try {
|
|
652
|
+
return realpathSync(p)
|
|
653
|
+
} catch {
|
|
654
|
+
return p
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
508
658
|
function projectsContaining(
|
|
509
659
|
workspaceRoot: string,
|
|
510
660
|
changedRelPaths: readonly string[],
|
|
@@ -519,6 +669,19 @@ function projectsContaining(
|
|
|
519
669
|
// pays for.
|
|
520
670
|
const dirToName = new Map<string, string>()
|
|
521
671
|
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
672
|
+
// A member linked in from elsewhere in the tree (`pkgs/b -> ../ext/b`) is
|
|
673
|
+
// indexed by its link, and git reports its files at their real place
|
|
674
|
+
// (`ext/b/src/a.txt`), which resolved to no project: an edit there
|
|
675
|
+
// selected nothing (item 1079). Its real place under the root is indexed
|
|
676
|
+
// too. One realpath per member, and only on an --affected run.
|
|
677
|
+
const realRoot = realpathOr(workspaceRoot)
|
|
678
|
+
for (const p of projects) {
|
|
679
|
+
const real = realpathOr(p.dir)
|
|
680
|
+
const rel = path.relative(realRoot, real)
|
|
681
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) continue
|
|
682
|
+
const spelled = path.resolve(workspaceRoot, rel)
|
|
683
|
+
if (spelled !== p.dir && !dirToName.has(spelled)) dirToName.set(spelled, p.name)
|
|
684
|
+
}
|
|
522
685
|
const owned = new Set<string>()
|
|
523
686
|
for (const rel of changedRelPaths) {
|
|
524
687
|
let dir = path.resolve(workspaceRoot, rel)
|
|
@@ -45,7 +45,10 @@ self.onmessage = async (e) => {
|
|
|
45
45
|
const { id, path } = e.data
|
|
46
46
|
try {
|
|
47
47
|
const ns = await import(path)
|
|
48
|
-
|
|
48
|
+
// Awaited as the in-process load's async return flattens it: a Promise
|
|
49
|
+
// default loaded on a run and was refused as "an instance of Promise"
|
|
50
|
+
// on every later evaluation in the process (D-5).
|
|
51
|
+
const mod = await ns?.default
|
|
49
52
|
const isObject = mod !== null && typeof mod === 'object'
|
|
50
53
|
const nonJson = isObject ? nonJsonPaths(mod) : []
|
|
51
54
|
postMessage({
|
|
@@ -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
|
|