@vzn/vx 0.0.632 → 0.0.634
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 +14 -10
- package/docs/caching.md +2250 -0
- package/docs/cli.md +3283 -0
- package/docs/execution.md +708 -0
- package/docs/features.md +174 -0
- package/docs/patterns.md +219 -0
- package/docs/schema.md +2151 -0
- package/docs/security.md +209 -0
- package/package.json +12 -5
- package/schemas/docs.json +28 -0
- package/schemas/error.json +5 -1
- package/schemas/init.json +57 -0
- package/schemas/lock.json +21 -0
- package/schemas/plan.json +4 -0
- package/skills/vx/SKILL.md +13 -3
- package/src/cache/cache.ts +556 -122
- package/src/cache/chained-cache.ts +9 -0
- package/src/cache/inputs.ts +71 -30
- package/src/cache/layer.ts +8 -4
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/schema.ts +42 -16
- package/src/cli/docs-corpus.ts +21 -0
- package/src/cli/docs-text.d.ts +5 -0
- package/src/cli/docs.ts +173 -0
- package/src/cli/help.ts +14 -3
- package/src/cli/index.ts +2 -0
- package/src/cli/init.ts +44 -8
- package/src/cli/lock.ts +43 -15
- package/src/cli/plan-format.ts +3 -1
- package/src/cli/run.ts +8 -2
- package/src/cli/upgrade.ts +18 -6
- package/src/config.ts +9 -0
- package/src/graph/priorities.ts +72 -6
- package/src/graph/scheduler.ts +17 -4
- package/src/graph/task-graph.ts +56 -18
- package/src/orchestrator/metrics.ts +11 -4
- package/src/orchestrator/miss-reason.ts +3 -5
- package/src/orchestrator/miss-save.ts +6 -5
- package/src/orchestrator/projects.ts +2 -2
- package/src/orchestrator/remote-prefetch.ts +8 -2
- package/src/orchestrator/run.ts +13 -1
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/util/errors.ts +9 -2
- package/src/util/paths.ts +32 -0
- package/src/util/verbs.ts +1 -0
- package/src/workspace/config-schema.ts +26 -8
- package/src/workspace/lockfile.ts +29 -2
- package/src/workspace/migration.ts +30 -3
package/src/cli/init.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `vx init [--dry] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere:
|
|
1
|
+
// `vx init [--dry [--format json]] [--force] [--mjs] [--native|--keep]` — a workspace from nowhere:
|
|
2
2
|
// one vx.config.ts per package from its package.json scripts, and the
|
|
3
3
|
// workspace file every run needs. In a Turbo or Nx repo it hands over to
|
|
4
4
|
// `@vzn/vx-migrate` (`migrate`), which asks a terminal whether to write
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { existsSync, readFileSync } from 'node:fs'
|
|
11
11
|
import { mkdir, unlink } from 'node:fs/promises'
|
|
12
12
|
import path from 'node:path'
|
|
13
|
-
import { flagHint, seeHelp } from './help.js'
|
|
13
|
+
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
14
14
|
import { PLUGIN_TEMPLATES } from './plugin-templates.js'
|
|
15
15
|
import { isCompiledBinary } from './upgrade.js'
|
|
16
16
|
import { VERSION } from '../version.js'
|
|
@@ -34,6 +34,8 @@ interface InitArgs {
|
|
|
34
34
|
plugin?: string
|
|
35
35
|
/** `--native` / `--keep` in a Turbo or Nx repo; unset, vx-migrate asks a terminal. */
|
|
36
36
|
mode?: 'native' | 'keep'
|
|
37
|
+
/** `--format json` (with `--dry`): the plan as one JSON document. */
|
|
38
|
+
json?: boolean
|
|
37
39
|
error?: string
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -51,6 +53,10 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
out.plugin = seam
|
|
56
|
+
} else if (a === '--format' || a.startsWith('--format=')) {
|
|
57
|
+
const fv = formatValue(a === '--format' ? args[++i] : a.slice('--format='.length), 'init')
|
|
58
|
+
if (typeof fv === 'object') return { ...out, error: fv.error }
|
|
59
|
+
out.json = fv === 'json'
|
|
54
60
|
} else if (a === '--dry') out.dry = true
|
|
55
61
|
else if (a === '--force') out.force = true
|
|
56
62
|
else if (a === '--mjs') out.mjs = true
|
|
@@ -81,9 +87,15 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
|
|
|
81
87
|
|
|
82
88
|
export async function initCmd(args: readonly string[]): Promise<number> {
|
|
83
89
|
const parsed = parseInitArgs(args)
|
|
84
|
-
if (parsed.error)
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
if (parsed.error) return refuse('init', args, parsed.error, 'VX_E_USAGE')
|
|
91
|
+
// In the verb, not the parser: completion asks the parser one flag at a time.
|
|
92
|
+
if (parsed.json === true && (!parsed.dry || parsed.plugin !== undefined)) {
|
|
93
|
+
return refuse(
|
|
94
|
+
'init',
|
|
95
|
+
args,
|
|
96
|
+
`--format json prints the workspace plan, so it needs --dry and no --plugin${seeHelp('init')}`,
|
|
97
|
+
'VX_E_USAGE',
|
|
98
|
+
)
|
|
87
99
|
}
|
|
88
100
|
if (parsed.plugin !== undefined) return scaffoldPlugin(parsed.plugin, parsed)
|
|
89
101
|
const reads: LoadReads = new Map()
|
|
@@ -163,6 +175,7 @@ export async function initCmd(args: readonly string[]): Promise<number> {
|
|
|
163
175
|
notes: [...(await unreadWorkspaces(root, workspace.packageGlobs)), ...unmapped],
|
|
164
176
|
unmapped: unmapped.length > 0,
|
|
165
177
|
dry: parsed.dry,
|
|
178
|
+
json: parsed.json === true,
|
|
166
179
|
force: parsed.force,
|
|
167
180
|
init: true,
|
|
168
181
|
format: parsed.mjs ? 'mjs' : 'ts',
|
|
@@ -211,6 +224,7 @@ async function migrate(root: string, args: InitArgs): Promise<number> {
|
|
|
211
224
|
args.force && '--force',
|
|
212
225
|
args.mjs && '--mjs',
|
|
213
226
|
args.mode === 'native' && '--native',
|
|
227
|
+
args.json === true && '--format=json',
|
|
214
228
|
].filter((f): f is string => typeof f === 'string')
|
|
215
229
|
return Bun.spawn(migrateCommand(root, flags), {
|
|
216
230
|
cwd: root,
|
|
@@ -257,7 +271,7 @@ async function adopt(
|
|
|
257
271
|
)
|
|
258
272
|
}
|
|
259
273
|
} else if (args.dry) {
|
|
260
|
-
process.stdout.write(`── ${name} ──\n${text}\n`)
|
|
274
|
+
if (args.json !== true) process.stdout.write(`── ${name} ──\n${text}\n`)
|
|
261
275
|
} else {
|
|
262
276
|
if (existing !== undefined && existing !== name) await unlink(path.join(root, existing))
|
|
263
277
|
await Bun.write(path.join(root, name), text)
|
|
@@ -278,6 +292,21 @@ async function adopt(
|
|
|
278
292
|
: kept
|
|
279
293
|
? ''
|
|
280
294
|
: `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
|
|
295
|
+
if (args.json === true) {
|
|
296
|
+
process.stdout.write(
|
|
297
|
+
`${JSON.stringify({
|
|
298
|
+
dry: true,
|
|
299
|
+
source,
|
|
300
|
+
files: kept ? [] : [{ path: name, contents: text }],
|
|
301
|
+
kept: kept ? [existing] : [],
|
|
302
|
+
replaced: !kept && existing !== undefined && existing !== name ? [existing] : [],
|
|
303
|
+
todos: [],
|
|
304
|
+
notes: cacheLine === '' ? [] : [cacheLine.trimEnd()],
|
|
305
|
+
next: adoptionNext(root, runner, source),
|
|
306
|
+
})}\n`,
|
|
307
|
+
)
|
|
308
|
+
return 0
|
|
309
|
+
}
|
|
281
310
|
process.stdout.write(
|
|
282
311
|
`vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until vx init --native writes native config; nothing else written.\n` +
|
|
283
312
|
`${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
|
|
@@ -392,12 +421,17 @@ const INSTALL: Record<PackageManager, string> = {
|
|
|
392
421
|
|
|
393
422
|
const EXEC: Record<PackageManager, string> = { pnpm: 'pnpm', yarn: 'yarn', bun: 'bunx', npm: 'npx' }
|
|
394
423
|
|
|
395
|
-
/**
|
|
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
|
+
*/
|
|
396
429
|
export function adoptionNext(
|
|
397
430
|
root: string,
|
|
398
431
|
runner: 'turbo' | 'nx',
|
|
399
432
|
source: string,
|
|
400
433
|
needs: readonly string[] = ['@vzn/vx', '@vzn/vx-migrate'],
|
|
434
|
+
version: string = VERSION,
|
|
401
435
|
): string {
|
|
402
436
|
const pm = LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1] ?? 'npm'
|
|
403
437
|
const missing = needs.filter(
|
|
@@ -414,7 +448,9 @@ export function adoptionNext(
|
|
|
414
448
|
pm === 'yarn' && !readText(path.join(root, 'yarn.lock')).includes('__metadata:')
|
|
415
449
|
? `${INSTALL.yarn} -W`
|
|
416
450
|
: INSTALL[pm]
|
|
417
|
-
|
|
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}`
|
|
418
454
|
}
|
|
419
455
|
|
|
420
456
|
function readText(file: string): string {
|
package/src/cli/lock.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// current environment. Design: docs/design/config-lock-2026-06.md.
|
|
4
4
|
|
|
5
5
|
import type { ProjectConfig } from '../config.js'
|
|
6
|
-
import { flagHint, refusedWord, seeHelp } from './help.js'
|
|
6
|
+
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
7
7
|
import { relPosix, secretMask, xxh3hex } from '../util/index.js'
|
|
8
8
|
import {
|
|
9
9
|
findWorkspaceRoot,
|
|
@@ -22,16 +22,22 @@ import { discoverCliProjects } from './workspace-config.js'
|
|
|
22
22
|
|
|
23
23
|
interface LockArgs {
|
|
24
24
|
check: boolean
|
|
25
|
+
format: 'pretty' | 'json'
|
|
25
26
|
error?: string
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export function parseLockArgs(args: readonly string[]): LockArgs {
|
|
29
|
-
const out: LockArgs = { check: false }
|
|
30
|
-
for (
|
|
30
|
+
const out: LockArgs = { check: false, format: 'pretty' }
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const a = args[i]!
|
|
31
33
|
if (a === '--check') out.check = true
|
|
32
|
-
else
|
|
34
|
+
else if (a === '--format' || a.startsWith('--format=')) {
|
|
35
|
+
const fv = formatValue(a === '--format' ? args[++i] : a.slice(9), 'lock')
|
|
36
|
+
if (typeof fv === 'object') return { ...out, ...fv }
|
|
37
|
+
out.format = fv
|
|
38
|
+
} else
|
|
33
39
|
return {
|
|
34
|
-
|
|
40
|
+
...out,
|
|
35
41
|
error: `${refusedWord(a)}: ${a}${flagHint('lock', a)}${seeHelp('lock')}`,
|
|
36
42
|
}
|
|
37
43
|
}
|
|
@@ -42,10 +48,15 @@ type ConfiguredMeta = ProjectMeta & { configPath: string }
|
|
|
42
48
|
|
|
43
49
|
export async function lockCmd(args: readonly string[]): Promise<number> {
|
|
44
50
|
const parsed = parseLockArgs(args)
|
|
45
|
-
if (parsed.error)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
if (parsed.error) return refuse('lock', args, parsed.error, 'VX_E_USAGE')
|
|
52
|
+
// Writing the lock prints one line; only the audit has an answer to shape.
|
|
53
|
+
if (parsed.format === 'json' && !parsed.check)
|
|
54
|
+
return refuse(
|
|
55
|
+
'lock',
|
|
56
|
+
args,
|
|
57
|
+
`--format json answers --check only${seeHelp('lock')}`,
|
|
58
|
+
'VX_E_USAGE',
|
|
59
|
+
)
|
|
49
60
|
const reads: LoadReads = new Map()
|
|
50
61
|
const root = await findWorkspaceRoot(process.cwd(), reads)
|
|
51
62
|
const workspace = await loadWorkspace(root, reads)
|
|
@@ -62,7 +73,7 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
|
|
|
62
73
|
// count above leaves out — `locked 0 project configs` on a turbo() repo
|
|
63
74
|
// read like an audit of something (2026-09-16).
|
|
64
75
|
const bare = all.length - metas.length
|
|
65
|
-
if (parsed.check) return await checkLock(root, metas, bare)
|
|
76
|
+
if (parsed.check) return await checkLock(root, metas, bare, parsed.format === 'json')
|
|
66
77
|
return await writeLock(root, metas, bare)
|
|
67
78
|
}
|
|
68
79
|
|
|
@@ -154,13 +165,29 @@ function secretsIn(projects: Record<string, LockfileEntry>): Array<{ at: string;
|
|
|
154
165
|
* catches eval-time env-var drift that file hashes cannot see —
|
|
155
166
|
* file bytes unchanged, resolved value changed.
|
|
156
167
|
*/
|
|
157
|
-
async function checkLock(
|
|
168
|
+
async function checkLock(
|
|
169
|
+
root: string,
|
|
170
|
+
metas: ConfiguredMeta[],
|
|
171
|
+
bare: number,
|
|
172
|
+
json: boolean,
|
|
173
|
+
): Promise<number> {
|
|
174
|
+
const answer = (problems: string[]): number => {
|
|
175
|
+
if (json) {
|
|
176
|
+
const out = {
|
|
177
|
+
upToDate: problems.length === 0,
|
|
178
|
+
audited: metas.length,
|
|
179
|
+
notAudited: bare,
|
|
180
|
+
problems,
|
|
181
|
+
}
|
|
182
|
+
process.stdout.write(`${JSON.stringify(out)}\n`)
|
|
183
|
+
}
|
|
184
|
+
return problems.length === 0 ? 0 : 1
|
|
185
|
+
}
|
|
158
186
|
const lock = await readLockfile(root)
|
|
159
187
|
if (!lock) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
)
|
|
163
|
-
return 1
|
|
188
|
+
const problem = `no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first`
|
|
189
|
+
if (!json) process.stderr.write(`vx lock --check: ${problem}\n`)
|
|
190
|
+
return answer([problem])
|
|
164
191
|
}
|
|
165
192
|
const results = await Promise.all(
|
|
166
193
|
metas.map(async (m): Promise<string | null> => {
|
|
@@ -200,6 +227,7 @@ async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): P
|
|
|
200
227
|
)
|
|
201
228
|
}
|
|
202
229
|
}
|
|
230
|
+
if (json) return answer(failures)
|
|
203
231
|
if (failures.length > 0) {
|
|
204
232
|
for (const f of failures) process.stderr.write(`vx lock --check: ${f}\n`)
|
|
205
233
|
return 1
|
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 {
|
package/src/cli/run.ts
CHANGED
|
@@ -521,6 +521,8 @@ export async function resolveRunOptions(
|
|
|
521
521
|
cwd: string,
|
|
522
522
|
tasks: readonly string[],
|
|
523
523
|
verb: 'run' | 'watch' = 'run',
|
|
524
|
+
/** Given, it learns the ref `--affected` diffed from (`--dry=json` states it). */
|
|
525
|
+
scopeOut?: { affectedBase?: string },
|
|
524
526
|
): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
|
|
525
527
|
for (const t of tasks) {
|
|
526
528
|
const idx = t.indexOf('#')
|
|
@@ -536,6 +538,9 @@ export async function resolveRunOptions(
|
|
|
536
538
|
const scope = await scopeFilters(parsed, cwd)
|
|
537
539
|
if ('error' in scope) return scope
|
|
538
540
|
const { filterStrings, affectedFilter } = scope
|
|
541
|
+
// `...[<base>]`: the base alone.
|
|
542
|
+
if (scopeOut !== undefined && affectedFilter !== undefined)
|
|
543
|
+
scopeOut.affectedBase = affectedFilter.slice(4, -1)
|
|
539
544
|
|
|
540
545
|
// Project scope applies to bare task names only. Anchored entries
|
|
541
546
|
// (pkg#task) resolve directly to their own project regardless.
|
|
@@ -709,7 +714,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
709
714
|
tasks = [picked]
|
|
710
715
|
}
|
|
711
716
|
|
|
712
|
-
const
|
|
717
|
+
const scopeOut: { affectedBase?: string } = {}
|
|
718
|
+
const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
|
|
713
719
|
if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
|
|
714
720
|
if ('nothingSelected' in resolved) {
|
|
715
721
|
process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
|
|
@@ -769,7 +775,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
769
775
|
}
|
|
770
776
|
}
|
|
771
777
|
} else if (parsed.dry === 'json') {
|
|
772
|
-
process.stdout.write(formatPlanJson(plan))
|
|
778
|
+
process.stdout.write(formatPlanJson(plan, scopeOut.affectedBase))
|
|
773
779
|
} else {
|
|
774
780
|
process.stdout.write(formatPlanText(plan))
|
|
775
781
|
}
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// deno upgrade — "update" is what package managers do to indexes.
|
|
14
14
|
|
|
15
15
|
import { constants } from 'node:fs'
|
|
16
|
-
import { access, chmod, chown, link, rename, rm, stat } from 'node:fs/promises'
|
|
16
|
+
import { access, chmod, chown, link, mkdtemp, rename, rm, stat } from 'node:fs/promises'
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import { flagHint, seeHelp } from './help.js'
|
|
19
19
|
import { UserError } from '../util/index.js'
|
|
@@ -247,8 +247,20 @@ export async function replaceBinary(
|
|
|
247
247
|
`vx upgrade: the download did not match the release's SHA-256 (expected ${sha256}, got ${got}) — nothing replaced; try again, and if it repeats the asset is not the one the release published`,
|
|
248
248
|
)
|
|
249
249
|
}
|
|
250
|
-
|
|
251
|
-
|
|
250
|
+
// A fresh directory beside the binary, not a name built from the pid:
|
|
251
|
+
// anyone who can write the install directory could plant a link at a
|
|
252
|
+
// predictable name, and a `sudo vx upgrade` wrote through it as root.
|
|
253
|
+
let stage: string
|
|
254
|
+
try {
|
|
255
|
+
stage = await mkdtemp(path.join(dir, '.vx-upgrade-'))
|
|
256
|
+
} catch (err) {
|
|
257
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
258
|
+
throw new UserError(
|
|
259
|
+
`vx upgrade: could not stage the new binary in ${dir} (${msg}) — nothing replaced; check the permissions of ${dir}`,
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
const tmp = path.join(stage, 'new')
|
|
263
|
+
const old = path.join(stage, 'previous')
|
|
252
264
|
let kept = false
|
|
253
265
|
try {
|
|
254
266
|
const was = await stat(dest).catch(() => null)
|
|
@@ -265,18 +277,18 @@ export async function replaceBinary(
|
|
|
265
277
|
}
|
|
266
278
|
await rename(tmp, dest)
|
|
267
279
|
} catch (err) {
|
|
268
|
-
await rm(
|
|
269
|
-
if (kept) await rm(old, { force: true })
|
|
280
|
+
await rm(stage, { recursive: true, force: true })
|
|
270
281
|
const msg = err instanceof Error ? err.message : String(err)
|
|
271
282
|
throw new UserError(
|
|
272
283
|
`vx upgrade: could not replace ${dest} (${msg}) — nothing replaced; check the permissions of ${dest} and its directory`,
|
|
273
284
|
)
|
|
274
285
|
}
|
|
275
286
|
if (starts === undefined || starts(dest)) {
|
|
276
|
-
|
|
287
|
+
await rm(stage, { recursive: true, force: true })
|
|
277
288
|
return
|
|
278
289
|
}
|
|
279
290
|
if (kept) await rename(old, dest)
|
|
291
|
+
await rm(stage, { recursive: true, force: true })
|
|
280
292
|
throw new UserError(
|
|
281
293
|
`vx upgrade: the new binary did not start on this machine (\`${dest} --version\` failed)${kept ? ' — the previous vx is back in place' : ''}. Nothing else changed; report it with this os/arch`,
|
|
282
294
|
)
|
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
|
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
|
/**
|
|
@@ -278,6 +284,11 @@ export interface ScheduleOptions {
|
|
|
278
284
|
* heuristic (the default and historically the only behavior).
|
|
279
285
|
*/
|
|
280
286
|
priorities?: ReadonlyMap<string, number>
|
|
287
|
+
/**
|
|
288
|
+
* The baseline ranking under `priorities` (`schedule` in vx.workspace.ts).
|
|
289
|
+
* Undefined or `most-work` is the reverse-deps count above.
|
|
290
|
+
*/
|
|
291
|
+
strategy?: ScheduleStrategy
|
|
281
292
|
/**
|
|
282
293
|
* Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
|
|
283
294
|
* local short-circuit). A restore-tier task:
|
|
@@ -494,9 +505,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
494
505
|
// use those weights. Falls back to the reverse-deps-count heuristic
|
|
495
506
|
// for nodes the caller didn't score, so partial coverage works.
|
|
496
507
|
const baseline =
|
|
497
|
-
options.
|
|
498
|
-
?
|
|
499
|
-
:
|
|
508
|
+
options.strategy !== undefined && options.strategy !== 'most-work'
|
|
509
|
+
? strategyPriorities(nodes, options.strategy)
|
|
510
|
+
: options.restoreTier !== undefined && options.restoreTier.size > 0
|
|
511
|
+
? tieredReverseDepCount(nodes, options.restoreTier)
|
|
512
|
+
: computeReverseDepCount(nodes)
|
|
500
513
|
const priority: ReadonlyMap<string, number> = options.priorities
|
|
501
514
|
? mergePriorities(baseline, options.priorities)
|
|
502
515
|
: baseline
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function isGroupTask(node: TaskNode): boolean {
|
|
|
109
109
|
return node.config.exec === undefined
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
/**
|
|
112
|
+
/** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `cache`. */
|
|
113
113
|
function isKeyedGroup(task: TaskConfig): boolean {
|
|
114
114
|
return task.exec === undefined && task.cache !== undefined
|
|
115
115
|
}
|
|
@@ -1505,8 +1505,8 @@ interface Side {
|
|
|
1505
1505
|
* `inputs.files` rebased to the root against another task's
|
|
1506
1506
|
* `outputs.workspaceFiles`, which may land in any project (X-135). A task's own outputs are
|
|
1507
1507
|
* already subtracted from its inputs, and an output the reader's `!` entries
|
|
1508
|
-
* take back whole is no overlap. A keyed group is exempt:
|
|
1509
|
-
* `build` reads `**` of a config-less dependency
|
|
1508
|
+
* take back whole is no overlap. A keyed group is exempt: it runs nothing
|
|
1509
|
+
* (the default `build` reads `**` of a config-less dependency), and its
|
|
1510
1510
|
* key waits as before.
|
|
1511
1511
|
*/
|
|
1512
1512
|
function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
|
|
@@ -1517,6 +1517,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1517
1517
|
const filesWriters: TaskNode[] = []
|
|
1518
1518
|
let projectReaders = false
|
|
1519
1519
|
let rootReaders = false
|
|
1520
|
+
const memo: TakeBackMemo = { bySide: new Map(), byList: new Map() }
|
|
1520
1521
|
const bucket = (name: string): Side[] => {
|
|
1521
1522
|
let list = byProject.get(name)
|
|
1522
1523
|
if (list === undefined) byProject.set(name, (list = []))
|
|
@@ -1557,7 +1558,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1557
1558
|
(s) => s.globs,
|
|
1558
1559
|
(s) => s.reads,
|
|
1559
1560
|
)) {
|
|
1560
|
-
readsOutputs(sides[i]!, sides[j]!, 'files')
|
|
1561
|
+
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1561
1562
|
}
|
|
1562
1563
|
}
|
|
1563
1564
|
}
|
|
@@ -1575,7 +1576,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1575
1576
|
(s) => s.globs,
|
|
1576
1577
|
(s) => s.reads,
|
|
1577
1578
|
)) {
|
|
1578
|
-
readsOutputs(sides[i]!, sides[j]!, 'files')
|
|
1579
|
+
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1579
1580
|
}
|
|
1580
1581
|
}
|
|
1581
1582
|
if (!rootReaders) return
|
|
@@ -1593,26 +1594,63 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1593
1594
|
(s) => s.globs,
|
|
1594
1595
|
(s) => s.reads,
|
|
1595
1596
|
)) {
|
|
1596
|
-
readsOutputs(rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1597
|
+
readsOutputs(memo, rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1597
1598
|
}
|
|
1598
1599
|
}
|
|
1599
1600
|
|
|
1600
|
-
|
|
1601
|
+
/**
|
|
1602
|
+
* Whether a reader's take-back list covers an output glob, asked once per
|
|
1603
|
+
* list and glob: a Turbo-mapped repo gives two thousand readers one
|
|
1604
|
+
* 400-entry list, and asking it per reader and writer pair was 2.7 s of an
|
|
1605
|
+
* astro plan (2026-10-09). One per `detectInputOverlaps` call.
|
|
1606
|
+
*/
|
|
1607
|
+
interface TakeBack {
|
|
1608
|
+
list: string[]
|
|
1609
|
+
verdicts: Map<string, boolean>
|
|
1610
|
+
}
|
|
1611
|
+
type TakeBackMemo = { bySide: Map<Side, TakeBack>; byList: Map<string, TakeBack> }
|
|
1612
|
+
|
|
1613
|
+
function takenBack(
|
|
1614
|
+
memo: TakeBackMemo,
|
|
1615
|
+
reader: Side,
|
|
1616
|
+
field: 'files' | 'workspaceFiles',
|
|
1617
|
+
go: string,
|
|
1618
|
+
): boolean {
|
|
1619
|
+
let tb = memo.bySide.get(reader)
|
|
1620
|
+
if (tb === undefined) {
|
|
1621
|
+
const cache = reader.node.config.cache!
|
|
1622
|
+
// A reader rebased to the root (X-135) speaks in its project's terms.
|
|
1623
|
+
const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
|
|
1624
|
+
// What the reader's key never reads: its `!` entries and its own outputs.
|
|
1625
|
+
const list = [
|
|
1626
|
+
...splitNegations(
|
|
1627
|
+
field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []),
|
|
1628
|
+
).negative,
|
|
1629
|
+
...splitNegations(
|
|
1630
|
+
field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
|
|
1631
|
+
).positive,
|
|
1632
|
+
].map((g) => at + g)
|
|
1633
|
+
const key = list.join('\0')
|
|
1634
|
+
tb = memo.byList.get(key)
|
|
1635
|
+
if (tb === undefined) memo.byList.set(key, (tb = { list, verdicts: new Map() }))
|
|
1636
|
+
memo.bySide.set(reader, tb)
|
|
1637
|
+
}
|
|
1638
|
+
let verdict = tb.verdicts.get(go)
|
|
1639
|
+
if (verdict === undefined) tb.verdicts.set(go, (verdict = outputTakenBack(go, tb.list)))
|
|
1640
|
+
return verdict
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function readsOutputs(
|
|
1644
|
+
memo: TakeBackMemo,
|
|
1645
|
+
x: Side,
|
|
1646
|
+
y: Side,
|
|
1647
|
+
field: 'files' | 'workspaceFiles',
|
|
1648
|
+
): void {
|
|
1601
1649
|
if (x.reads === y.reads || x.node === y.node) return
|
|
1602
1650
|
const [reader, writer] = x.reads ? [x, y] : [y, x]
|
|
1603
|
-
const cache = reader.node.config.cache!
|
|
1604
|
-
// A reader rebased to the root (X-135) speaks in its project's terms.
|
|
1605
1651
|
const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
|
|
1606
|
-
// What the reader's key never reads: its `!` entries and its own outputs.
|
|
1607
|
-
const takeBack = [
|
|
1608
|
-
...splitNegations(field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []))
|
|
1609
|
-
.negative,
|
|
1610
|
-
...splitNegations(
|
|
1611
|
-
field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
|
|
1612
|
-
).positive,
|
|
1613
|
-
].map((g) => at + g)
|
|
1614
1652
|
for (const go of writer.globs) {
|
|
1615
|
-
if (
|
|
1653
|
+
if (takenBack(memo, reader, field, go)) continue
|
|
1616
1654
|
for (const gi of reader.globs) {
|
|
1617
1655
|
if (!outputsOverlap(gi, go)) continue
|
|
1618
1656
|
const shown =
|
|
@@ -610,10 +610,17 @@ export interface CacheKeyDiff {
|
|
|
610
610
|
note: string
|
|
611
611
|
}
|
|
612
612
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
.
|
|
613
|
+
/** An entry's key components, from its one `entry_inputs` row (v34); none when it has none. */
|
|
614
|
+
export function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
|
|
615
|
+
const row = db
|
|
616
|
+
.query('SELECT components FROM entry_inputs WHERE entry_hash = ?')
|
|
617
|
+
.get(entryHash) as { components: string } | null
|
|
618
|
+
if (row === null) return []
|
|
619
|
+
return (JSON.parse(row.components) as [string, string, string][]).map(([kind, name, hash]) => ({
|
|
620
|
+
kind,
|
|
621
|
+
name,
|
|
622
|
+
hash,
|
|
623
|
+
}))
|
|
617
624
|
}
|
|
618
625
|
|
|
619
626
|
/**
|