@vzn/vx 0.0.633 → 0.0.635
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 +51 -52
- package/docs/caching.md +11 -2
- package/docs/cli.md +192 -56
- package/docs/execution.md +1 -1
- package/docs/features.md +6 -3
- package/docs/patterns.md +8 -5
- package/docs/schema.md +48 -2
- package/docs/security.md +6 -0
- package/launcher.cjs +0 -0
- package/launcher.sh +50 -0
- package/package.json +17 -8
- package/schemas/error.json +5 -1
- package/schemas/last.json +5 -1
- package/schemas/plan.json +4 -0
- package/schemas/show.json +2 -1
- package/schemas/why.json +4 -1
- package/skills/vx/SKILL.md +7 -1
- package/src/cache/cache.ts +60 -5
- package/src/cache/inputs.ts +66 -30
- package/src/cache/layered-cache.ts +3 -1
- package/src/cli/docs.ts +9 -3
- package/src/cli/help.ts +2 -1
- package/src/cli/init.ts +9 -2
- package/src/cli/last.ts +9 -1
- package/src/cli/plan-format.ts +3 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +17 -8
- package/src/cli/select.ts +17 -11
- package/src/cli/show.ts +2 -2
- package/src/config.ts +23 -2
- package/src/graph/priorities.ts +72 -6
- package/src/graph/scheduler.ts +31 -7
- package/src/graph/task-graph.ts +75 -20
- package/src/index.ts +6 -1
- package/src/orchestrator/execute-task.ts +30 -1
- package/src/orchestrator/framed-output.ts +27 -1
- package/src/orchestrator/history.ts +75 -1
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/path-links.ts +5 -3
- package/src/orchestrator/plugin.ts +3 -1
- package/src/orchestrator/prepare.ts +15 -12
- package/src/orchestrator/remote-prefetch.ts +8 -2
- package/src/orchestrator/run-failures.ts +20 -3
- package/src/orchestrator/run.ts +24 -2
- package/src/orchestrator/sandbox-fix.ts +123 -0
- package/src/orchestrator/task-hash.ts +7 -1
- package/src/util/errors.ts +9 -2
- package/src/util/paths.ts +32 -0
- package/src/workspace/affected.ts +16 -4
- package/src/workspace/config-schema.ts +30 -2
- package/src/workspace/lockfile.ts +29 -2
- package/src/workspace/migration.ts +51 -4
- package/src/workspace/project-loader.ts +3 -1
package/src/cli/init.ts
CHANGED
|
@@ -421,12 +421,17 @@ const INSTALL: Record<PackageManager, string> = {
|
|
|
421
421
|
|
|
422
422
|
const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
|
|
423
423
|
|
|
424
|
-
/**
|
|
424
|
+
/**
|
|
425
|
+
* Install what the workspace file imports, if missing, then run the repo's
|
|
426
|
+
* build. Pinned to this vx's version: every @vzn package releases in
|
|
427
|
+
* lockstep, and `latest` beside an older vx mismatched them (agent trial 2).
|
|
428
|
+
*/
|
|
425
429
|
export function adoptionNext(
|
|
426
430
|
root: string,
|
|
427
431
|
runner: 'turbo' | 'nx',
|
|
428
432
|
source: string,
|
|
429
433
|
needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
|
|
434
|
+
version: string = VERSION,
|
|
430
435
|
): string {
|
|
431
436
|
const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
|
|
432
437
|
const missing = needs.filter(
|
|
@@ -443,7 +448,9 @@ export function adoptionNext(
|
|
|
443
448
|
pm === 'yarn' && !readText(path.join(root, 'yarn.lock')).includes('__metadata:')
|
|
444
449
|
? `${INSTALL.yarn} -W`
|
|
445
450
|
: INSTALL[pm]
|
|
446
|
-
|
|
451
|
+
// A source checkout is 0.0.0, which npm never had.
|
|
452
|
+
const pinned = version === '0.0.0' ? missing : missing.map((p) => `${p}@${version}`)
|
|
453
|
+
return `${install} ${pinned.join(' ')} && ${run}`
|
|
447
454
|
}
|
|
448
455
|
|
|
449
456
|
function readText(file: string): string {
|
package/src/cli/last.ts
CHANGED
|
@@ -384,7 +384,15 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
384
384
|
)
|
|
385
385
|
const tasks = (detail?.tasks ?? []).map((t) => {
|
|
386
386
|
const f = failures.get(`${t.project}#${t.task}`)
|
|
387
|
-
return f === undefined
|
|
387
|
+
return f === undefined
|
|
388
|
+
? t
|
|
389
|
+
: {
|
|
390
|
+
...t,
|
|
391
|
+
output: f.output,
|
|
392
|
+
locations: f.locations,
|
|
393
|
+
...(f.sandboxFix !== undefined ? { sandboxFix: f.sandboxFix } : {}),
|
|
394
|
+
...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
|
|
395
|
+
}
|
|
388
396
|
})
|
|
389
397
|
process.stdout.write(`${JSON.stringify({ invocation: inv, tasks })}\n`)
|
|
390
398
|
return 0
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -145,10 +145,12 @@ export interface PlanTaskJson {
|
|
|
145
145
|
affected?: AffectedReason
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
/** `affectedBase`: the ref a bare `--affected` guessed (or was given), so a reader knows what the diff was against. */
|
|
149
|
+
export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
|
|
149
150
|
return (
|
|
150
151
|
JSON.stringify(
|
|
151
152
|
{
|
|
153
|
+
...(affectedBase !== undefined ? { affectedBase } : {}),
|
|
152
154
|
tasks: plan.tasks.map((t): PlanTaskJson => {
|
|
153
155
|
const description = shownDescription(t)
|
|
154
156
|
return {
|
|
@@ -10,7 +10,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
10
10
|
},
|
|
11
11
|
cache: {
|
|
12
12
|
plugin:
|
|
13
|
-
"// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { mkdir,
|
|
13
|
+
"// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\n },\n })\n}\n",
|
|
14
14
|
test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
|
|
15
15
|
},
|
|
16
16
|
commands: {
|
package/src/cli/run.ts
CHANGED
|
@@ -479,7 +479,9 @@ function onCI(v: string | undefined): boolean {
|
|
|
479
479
|
async function scopeFilters(
|
|
480
480
|
parsed: RunArgs,
|
|
481
481
|
cwd: string,
|
|
482
|
-
): Promise<
|
|
482
|
+
): Promise<
|
|
483
|
+
{ filterStrings: string[]; affectedFilter?: string } | { error: string; code?: string }
|
|
484
|
+
> {
|
|
483
485
|
// `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
|
|
484
486
|
// changed projects AND their dependents — what a CI gate must run, what
|
|
485
487
|
// the flag's name says, what the guides promised while the sugar was the
|
|
@@ -521,7 +523,9 @@ export async function resolveRunOptions(
|
|
|
521
523
|
cwd: string,
|
|
522
524
|
tasks: readonly string[],
|
|
523
525
|
verb: 'run' | 'watch' = 'run',
|
|
524
|
-
|
|
526
|
+
/** Given, it learns the ref `--affected` diffed from (`--dry=json` states it). */
|
|
527
|
+
scopeOut?: { affectedBase?: string },
|
|
528
|
+
): Promise<RunOptions | { error: string; code?: string } | { nothingSelected: string }> {
|
|
525
529
|
for (const t of tasks) {
|
|
526
530
|
const idx = t.indexOf('#')
|
|
527
531
|
if (idx >= 0) {
|
|
@@ -536,6 +540,9 @@ export async function resolveRunOptions(
|
|
|
536
540
|
const scope = await scopeFilters(parsed, cwd)
|
|
537
541
|
if ('error' in scope) return scope
|
|
538
542
|
const { filterStrings, affectedFilter } = scope
|
|
543
|
+
// `...[<base>]`: the base alone.
|
|
544
|
+
if (scopeOut !== undefined && affectedFilter !== undefined)
|
|
545
|
+
scopeOut.affectedBase = affectedFilter.slice(4, -1)
|
|
539
546
|
|
|
540
547
|
// Project scope applies to bare task names only. Anchored entries
|
|
541
548
|
// (pkg#task) resolve directly to their own project regardless.
|
|
@@ -561,7 +568,7 @@ export async function resolveRunOptions(
|
|
|
561
568
|
affectedFilter,
|
|
562
569
|
)
|
|
563
570
|
: undefined
|
|
564
|
-
if (resolved !== undefined && 'error' in resolved) return
|
|
571
|
+
if (resolved !== undefined && 'error' in resolved) return resolved
|
|
565
572
|
if (bareTasks.length === 0) {
|
|
566
573
|
projects = undefined
|
|
567
574
|
} else if (resolved !== undefined) {
|
|
@@ -691,11 +698,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
691
698
|
// `--filter` / `--affected` scope the menu, as they scope a bare task:
|
|
692
699
|
// the menu listed every project and the anchored pick ran outside it.
|
|
693
700
|
const scope = await scopeFilters(parsed, cwd)
|
|
694
|
-
if ('error' in scope) return refuse('run', args, scope.error, 'VX_E_USAGE')
|
|
701
|
+
if ('error' in scope) return refuse('run', args, scope.error, scope.code ?? 'VX_E_USAGE')
|
|
695
702
|
let only: ReadonlySet<string> | undefined
|
|
696
703
|
if (scope.filterStrings.length > 0) {
|
|
697
704
|
const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
|
|
698
|
-
if ('error' in selected)
|
|
705
|
+
if ('error' in selected)
|
|
706
|
+
return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
|
|
699
707
|
if ('empty' in selected) {
|
|
700
708
|
process.stderr.write(`vx run: ${selected.empty}\n`)
|
|
701
709
|
return 0
|
|
@@ -709,8 +717,9 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
709
717
|
tasks = [picked]
|
|
710
718
|
}
|
|
711
719
|
|
|
712
|
-
const
|
|
713
|
-
|
|
720
|
+
const scopeOut: { affectedBase?: string } = {}
|
|
721
|
+
const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
|
|
722
|
+
if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
|
|
714
723
|
if ('nothingSelected' in resolved) {
|
|
715
724
|
process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
|
|
716
725
|
return 0
|
|
@@ -769,7 +778,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
769
778
|
}
|
|
770
779
|
}
|
|
771
780
|
} else if (parsed.dry === 'json') {
|
|
772
|
-
process.stdout.write(formatPlanJson(plan))
|
|
781
|
+
process.stdout.write(formatPlanJson(plan, scopeOut.affectedBase))
|
|
773
782
|
} else {
|
|
774
783
|
process.stdout.write(formatPlanText(plan))
|
|
775
784
|
}
|
package/src/cli/select.ts
CHANGED
|
@@ -197,7 +197,7 @@ export type FilterResolution =
|
|
|
197
197
|
/** What the other includes selected (`RunOptions.selectedOutright`). */
|
|
198
198
|
outright?: string[]
|
|
199
199
|
}
|
|
200
|
-
|
|
|
200
|
+
| Refusal
|
|
201
201
|
| { empty: string }
|
|
202
202
|
|
|
203
203
|
/**
|
|
@@ -239,16 +239,23 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
|
|
|
239
239
|
return out
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/** A refusal a resolver returns; `code` is the thrown one's own, when it has one. */
|
|
243
|
+
type Refusal = { error: string; code?: string }
|
|
244
|
+
|
|
245
|
+
function refusalOf(err: unknown): Refusal {
|
|
246
|
+
if (!(err instanceof Error)) return { error: String(err) }
|
|
247
|
+
return isUserError(err) && err.code !== 'VX_E_REFUSED'
|
|
248
|
+
? { error: err.message, code: err.code }
|
|
249
|
+
: { error: err.message }
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
/**
|
|
243
253
|
* The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
|
|
244
254
|
* projects and their dependents. An empty base is the workspace's
|
|
245
255
|
* `affectedBase` (or a plugin's `config` stage, from nx.json's
|
|
246
256
|
* `defaultBase` or `TURBO_SCM_BASE`), then the guess.
|
|
247
257
|
*/
|
|
248
|
-
export async function affectedFilterFor(
|
|
249
|
-
cwd: string,
|
|
250
|
-
affected: string,
|
|
251
|
-
): Promise<string | { error: string }> {
|
|
258
|
+
export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
|
|
252
259
|
const root = await findWorkspaceRoot(cwd)
|
|
253
260
|
let base = affected
|
|
254
261
|
if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
|
|
@@ -257,7 +264,7 @@ export async function affectedFilterFor(
|
|
|
257
264
|
base = await defaultAffectedBase(root)
|
|
258
265
|
} catch (err) {
|
|
259
266
|
if (!isUserError(err)) throw err
|
|
260
|
-
return
|
|
267
|
+
return refusalOf(err)
|
|
261
268
|
}
|
|
262
269
|
}
|
|
263
270
|
return `...[${base}]`
|
|
@@ -276,7 +283,7 @@ export async function resolveFilters(
|
|
|
276
283
|
try {
|
|
277
284
|
parsed = raw.map((r) => parseFilter(r, root))
|
|
278
285
|
} catch (err) {
|
|
279
|
-
return
|
|
286
|
+
return refusalOf(err)
|
|
280
287
|
}
|
|
281
288
|
// `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
|
|
282
289
|
const walksGraph = parsed.some(
|
|
@@ -299,7 +306,7 @@ export async function resolveFilters(
|
|
|
299
306
|
try {
|
|
300
307
|
edges = taskEdgesFrom(await stagedOnce())
|
|
301
308
|
} catch (err) {
|
|
302
|
-
return
|
|
309
|
+
return refusalOf(err)
|
|
303
310
|
}
|
|
304
311
|
}
|
|
305
312
|
// A tag lives in the config, so a `tag:` selector needs the staged load
|
|
@@ -309,7 +316,7 @@ export async function resolveFilters(
|
|
|
309
316
|
try {
|
|
310
317
|
tags = new Map([...(await stagedOnce()).values()].map((p) => [p.name, p.config.tags ?? []]))
|
|
311
318
|
} catch (err) {
|
|
312
|
-
return
|
|
319
|
+
return refusalOf(err)
|
|
313
320
|
}
|
|
314
321
|
}
|
|
315
322
|
const graph = buildPackageGraph(projects, edges)
|
|
@@ -353,8 +360,7 @@ export async function resolveFilters(
|
|
|
353
360
|
}
|
|
354
361
|
affectedByFilter.set(f, names)
|
|
355
362
|
} catch (err) {
|
|
356
|
-
|
|
357
|
-
return { error: msg }
|
|
363
|
+
return refusalOf(err)
|
|
358
364
|
}
|
|
359
365
|
}
|
|
360
366
|
|
package/src/cli/show.ts
CHANGED
|
@@ -145,12 +145,12 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
145
145
|
let affected: string | undefined
|
|
146
146
|
if (parsed.affected !== undefined) {
|
|
147
147
|
const f = await affectedFilterFor(process.cwd(), parsed.affected)
|
|
148
|
-
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, 'VX_E_USAGE')
|
|
148
|
+
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, f.code ?? 'VX_E_USAGE')
|
|
149
149
|
affected = f
|
|
150
150
|
raw.unshift(f)
|
|
151
151
|
}
|
|
152
152
|
const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
|
|
153
|
-
if ('error' in r) throw new UserError(`vx show: ${r.error}`, 'VX_E_USAGE')
|
|
153
|
+
if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
|
|
154
154
|
if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
|
|
155
155
|
selected = new Set('empty' in r ? [] : r.names)
|
|
156
156
|
if ('names' in r && r.affected !== undefined)
|
package/src/config.ts
CHANGED
|
@@ -46,6 +46,15 @@ export interface WorkspaceConfig {
|
|
|
46
46
|
* folded into a cache key.
|
|
47
47
|
*/
|
|
48
48
|
cacheScope?: string
|
|
49
|
+
/**
|
|
50
|
+
* Which ready task starts first when there are more than workers, with no
|
|
51
|
+
* timings: `most-work` (the default) the one the most work waits on at
|
|
52
|
+
* any depth, `critical-path` the head of the longest chain of tasks,
|
|
53
|
+
* `direct-dependents` the one the most tasks wait on directly (Nx's
|
|
54
|
+
* rule), `ready-order` the one that became ready first (Turbo's). A
|
|
55
|
+
* `schedule` plugin's weights still come first; this breaks their ties.
|
|
56
|
+
*/
|
|
57
|
+
schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
49
58
|
/**
|
|
50
59
|
* Graph rules a run checks before it schedules anything. Each is on unless
|
|
51
60
|
* set to `false`; turning one off allows a shape vx handles correctly but
|
|
@@ -56,9 +65,11 @@ export interface WorkspaceConfig {
|
|
|
56
65
|
* Plugins registered for this workspace, consulted in this order once
|
|
57
66
|
* per `vx run`: each fills any of the pipeline stages and capabilities
|
|
58
67
|
* below, and core's own executor and cache store sit at the tail of
|
|
59
|
-
* every list. See `docs/design/pipeline-2026-09.md`.
|
|
68
|
+
* every list. See `docs/design/pipeline-2026-09.md`. An `undefined`,
|
|
69
|
+
* `null` or `false` entry is skipped, so a factory that declines
|
|
70
|
+
* (`github()` off Actions) or `cond && plugin()` needs no filter.
|
|
60
71
|
*/
|
|
61
|
-
plugins?: readonly Plugin[]
|
|
72
|
+
plugins?: readonly (Plugin | undefined | null | false)[]
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/** `WorkspaceConfig.rules`. */
|
|
@@ -479,6 +490,16 @@ export interface CacheConfig {
|
|
|
479
490
|
inputs: CacheInputs
|
|
480
491
|
/** What this task produces. Captured for restore on a cache hit. */
|
|
481
492
|
outputs: CacheOutputs
|
|
493
|
+
/**
|
|
494
|
+
* What a write to an input while the command runs means. `'withhold'`
|
|
495
|
+
* (the default): the entry is not saved, whatever the file holds after,
|
|
496
|
+
* since an edit undone mid-run cannot be told from no edit. `'compare'`:
|
|
497
|
+
* a file rewritten to the bytes the key folded counts as unchanged, for
|
|
498
|
+
* a task that rewrites its own input identically (tsdown's
|
|
499
|
+
* `package.json`). `VX_REWRITTEN_INPUTS=compare` sets the default for
|
|
500
|
+
* every task that does not say.
|
|
501
|
+
*/
|
|
502
|
+
rewrittenInputs?: 'withhold' | 'compare'
|
|
482
503
|
}
|
|
483
504
|
|
|
484
505
|
export interface CacheOutputs {
|
package/src/graph/priorities.ts
CHANGED
|
@@ -125,20 +125,26 @@ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// Reverse-topo sweep: every direct dependent's closure is final
|
|
128
|
-
// before its dependency folds it in. closure[
|
|
129
|
-
//
|
|
128
|
+
// before its dependency folds it in. closure[t] = bitset over topo
|
|
129
|
+
// POSITIONS of the node at position t's transitive dependents. A
|
|
130
|
+
// dependent sits after its dependency, so a closure holds only bits
|
|
131
|
+
// past its own position, and each OR starts at the dependent's word:
|
|
132
|
+
// half the words of indexing by insertion order on a layered graph.
|
|
133
|
+
const pos = new Int32Array(n)
|
|
134
|
+
for (let t = 0; t < tail; t++) pos[topo[t]!] = t
|
|
130
135
|
const closure = new Uint32Array(n * words)
|
|
131
136
|
const counts = new Map<string, number>()
|
|
132
137
|
for (let t = tail - 1; t >= 0; t--) {
|
|
133
138
|
const i = topo[t]!
|
|
134
|
-
const base =
|
|
135
|
-
for (const
|
|
139
|
+
const base = t * words
|
|
140
|
+
for (const ri of directReverse[i]!) {
|
|
141
|
+
const r = pos[ri]!
|
|
136
142
|
closure[base + (r >>> 5)]! |= 1 << (r & 31)
|
|
137
143
|
const rbase = r * words
|
|
138
|
-
for (let w =
|
|
144
|
+
for (let w = r >>> 5; w < words; w++) closure[base + w]! |= closure[rbase + w]!
|
|
139
145
|
}
|
|
140
146
|
let count = 0
|
|
141
|
-
for (let w =
|
|
147
|
+
for (let w = t >>> 5; w < words; w++) {
|
|
142
148
|
let v = closure[base + w]!
|
|
143
149
|
v = v - ((v >>> 1) & 0x55555555)
|
|
144
150
|
v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
|
|
@@ -153,6 +159,66 @@ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string
|
|
|
153
159
|
return counts
|
|
154
160
|
}
|
|
155
161
|
|
|
162
|
+
/**
|
|
163
|
+
* The ready-queue baseline a workspace may pick in place of the default
|
|
164
|
+
* (`schedule` in vx.workspace.ts). Each ranks with no timings; a `schedule`
|
|
165
|
+
* plugin's weights still sort above it, and it breaks their ties.
|
|
166
|
+
* - `most-work`: transitive dependents (`computeReverseDepCount`), the default.
|
|
167
|
+
* - `critical-path`: steps on the longest chain of dependents below a task.
|
|
168
|
+
* - `direct-dependents`: direct dependents only, Nx's rule.
|
|
169
|
+
* - `ready-order`: none; the task that became ready first starts first, Turbo's rule.
|
|
170
|
+
*/
|
|
171
|
+
export type ScheduleStrategy = 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
172
|
+
|
|
173
|
+
/** The baseline for a strategy other than `most-work`, over the whole graph. */
|
|
174
|
+
export function strategyPriorities(
|
|
175
|
+
nodes: Map<string, TaskNode>,
|
|
176
|
+
strategy: Exclude<ScheduleStrategy, 'most-work'>,
|
|
177
|
+
): Map<string, number> {
|
|
178
|
+
const out = new Map<string, number>()
|
|
179
|
+
if (strategy === 'ready-order') {
|
|
180
|
+
for (const id of nodes.keys()) out.set(id, 0)
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
const dependents = new Map<string, string[]>()
|
|
184
|
+
for (const node of nodes.values()) {
|
|
185
|
+
for (const dep of node.deps) {
|
|
186
|
+
if (!nodes.has(dep)) continue
|
|
187
|
+
const list = dependents.get(dep)
|
|
188
|
+
if (list === undefined) dependents.set(dep, [node.id])
|
|
189
|
+
else list.push(node.id)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (strategy === 'direct-dependents') {
|
|
193
|
+
for (const id of nodes.keys()) out.set(id, dependents.get(id)?.length ?? 0)
|
|
194
|
+
return out
|
|
195
|
+
}
|
|
196
|
+
// Longest chain below each task, dependents first: a task's depth is one
|
|
197
|
+
// more than its deepest dependent's. Iterative, as a deep chain would
|
|
198
|
+
// overflow a recursive walk.
|
|
199
|
+
const left = new Map<string, number>()
|
|
200
|
+
const ready: string[] = []
|
|
201
|
+
for (const id of nodes.keys()) {
|
|
202
|
+
const n = dependents.get(id)?.length ?? 0
|
|
203
|
+
left.set(id, n)
|
|
204
|
+
if (n === 0) ready.push(id)
|
|
205
|
+
}
|
|
206
|
+
while (ready.length > 0) {
|
|
207
|
+
const id = ready.pop()!
|
|
208
|
+
let depth = 0
|
|
209
|
+
for (const d of dependents.get(id) ?? []) depth = Math.max(depth, out.get(d)! + 1)
|
|
210
|
+
out.set(id, depth)
|
|
211
|
+
for (const dep of nodes.get(id)!.deps) {
|
|
212
|
+
if (!nodes.has(dep)) continue
|
|
213
|
+
const n = left.get(dep)! - 1
|
|
214
|
+
left.set(dep, n)
|
|
215
|
+
if (n === 0) ready.push(dep)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const id of nodes.keys()) if (!out.has(id)) out.set(id, 0)
|
|
219
|
+
return out
|
|
220
|
+
}
|
|
221
|
+
|
|
156
222
|
export function mergePriorities(
|
|
157
223
|
baseline: ReadonlyMap<string, number>,
|
|
158
224
|
overrides: ReadonlyMap<string, number>,
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -5,7 +5,13 @@ import {
|
|
|
5
5
|
isUserError,
|
|
6
6
|
OUT_OF_FDS_HINT,
|
|
7
7
|
} from '../util/index.js'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
computeReverseDepCount,
|
|
10
|
+
mergePriorities,
|
|
11
|
+
strategyPriorities,
|
|
12
|
+
tieredReverseDepCount,
|
|
13
|
+
type ScheduleStrategy,
|
|
14
|
+
} from './priorities.js'
|
|
9
15
|
import { isGroupTask, type TaskNode } from './task-graph.js'
|
|
10
16
|
|
|
11
17
|
/**
|
|
@@ -202,6 +208,15 @@ export interface TaskOutcome {
|
|
|
202
208
|
* inline in the task's block instead of as loose status output.
|
|
203
209
|
*/
|
|
204
210
|
sandboxViolationLines?: string[]
|
|
211
|
+
/** The `exec.sandbox.allow` entries that would have granted the denials. */
|
|
212
|
+
sandboxFix?: {
|
|
213
|
+
read?: string[]
|
|
214
|
+
write?: string[]
|
|
215
|
+
network?: string[]
|
|
216
|
+
systemInfo?: string[]
|
|
217
|
+
/** Credential paths denied and left out of the grants: an agent applies the fix as given. */
|
|
218
|
+
withheld?: string[]
|
|
219
|
+
}
|
|
205
220
|
}
|
|
206
221
|
|
|
207
222
|
export type ContinueMode = 'never' | 'deps-ok' | 'always'
|
|
@@ -278,6 +293,11 @@ export interface ScheduleOptions {
|
|
|
278
293
|
* heuristic (the default and historically the only behavior).
|
|
279
294
|
*/
|
|
280
295
|
priorities?: ReadonlyMap<string, number>
|
|
296
|
+
/**
|
|
297
|
+
* The baseline ranking under `priorities` (`schedule` in vx.workspace.ts).
|
|
298
|
+
* Undefined or `most-work` is the reverse-deps count above.
|
|
299
|
+
*/
|
|
300
|
+
strategy?: ScheduleStrategy
|
|
281
301
|
/**
|
|
282
302
|
* Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
|
|
283
303
|
* local short-circuit). A restore-tier task:
|
|
@@ -494,9 +514,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
494
514
|
// use those weights. Falls back to the reverse-deps-count heuristic
|
|
495
515
|
// for nodes the caller didn't score, so partial coverage works.
|
|
496
516
|
const baseline =
|
|
497
|
-
options.
|
|
498
|
-
?
|
|
499
|
-
:
|
|
517
|
+
options.strategy !== undefined && options.strategy !== 'most-work'
|
|
518
|
+
? strategyPriorities(nodes, options.strategy)
|
|
519
|
+
: options.restoreTier !== undefined && options.restoreTier.size > 0
|
|
520
|
+
? tieredReverseDepCount(nodes, options.restoreTier)
|
|
521
|
+
: computeReverseDepCount(nodes)
|
|
500
522
|
const priority: ReadonlyMap<string, number> = options.priorities
|
|
501
523
|
? mergePriorities(baseline, options.priorities)
|
|
502
524
|
: baseline
|
|
@@ -791,14 +813,16 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
791
813
|
// tasks are never asked, so they never park.
|
|
792
814
|
const takeFitting = (): string | undefined => {
|
|
793
815
|
if (holding) return undefined
|
|
794
|
-
// No pools
|
|
795
|
-
//
|
|
816
|
+
// No pools and a full exec lane: nothing on the exec queue can be
|
|
817
|
+
// admitted, so it is not scanned — an admit policy only narrows
|
|
818
|
+
// the count gate, never widens it (scanning under one cost the
|
|
819
|
+
// history plugin's cold bench run 10 s, 2026-10-09) — scanning it
|
|
796
820
|
// would pop and re-park every ready exec task on every tick, and
|
|
797
821
|
// with a wide frontier that is O(R) per tick, O(R²) per run (the
|
|
798
822
|
// 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
|
|
799
823
|
// first cut let the scan run past a full exec lane, 2026-09-10).
|
|
800
824
|
// This is the legacy O(1) gate, kept per lane.
|
|
801
|
-
const execAdmissible = poolOf !== undefined ||
|
|
825
|
+
const execAdmissible = poolOf !== undefined || execRoom()
|
|
802
826
|
if (execAdmissible) {
|
|
803
827
|
while (execReady.size > 0) {
|
|
804
828
|
const seq = execReady.peekSeq()
|