@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.
Files changed (48) hide show
  1. package/README.md +14 -10
  2. package/docs/caching.md +2250 -0
  3. package/docs/cli.md +3283 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +174 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2151 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +12 -5
  10. package/schemas/docs.json +28 -0
  11. package/schemas/error.json +5 -1
  12. package/schemas/init.json +57 -0
  13. package/schemas/lock.json +21 -0
  14. package/schemas/plan.json +4 -0
  15. package/skills/vx/SKILL.md +13 -3
  16. package/src/cache/cache.ts +556 -122
  17. package/src/cache/chained-cache.ts +9 -0
  18. package/src/cache/inputs.ts +71 -30
  19. package/src/cache/layer.ts +8 -4
  20. package/src/cache/layered-cache.ts +4 -0
  21. package/src/cache/schema.ts +42 -16
  22. package/src/cli/docs-corpus.ts +21 -0
  23. package/src/cli/docs-text.d.ts +5 -0
  24. package/src/cli/docs.ts +173 -0
  25. package/src/cli/help.ts +14 -3
  26. package/src/cli/index.ts +2 -0
  27. package/src/cli/init.ts +44 -8
  28. package/src/cli/lock.ts +43 -15
  29. package/src/cli/plan-format.ts +3 -1
  30. package/src/cli/run.ts +8 -2
  31. package/src/cli/upgrade.ts +18 -6
  32. package/src/config.ts +9 -0
  33. package/src/graph/priorities.ts +72 -6
  34. package/src/graph/scheduler.ts +17 -4
  35. package/src/graph/task-graph.ts +56 -18
  36. package/src/orchestrator/metrics.ts +11 -4
  37. package/src/orchestrator/miss-reason.ts +3 -5
  38. package/src/orchestrator/miss-save.ts +6 -5
  39. package/src/orchestrator/projects.ts +2 -2
  40. package/src/orchestrator/remote-prefetch.ts +8 -2
  41. package/src/orchestrator/run.ts +13 -1
  42. package/src/orchestrator/task-hash.ts +4 -4
  43. package/src/util/errors.ts +9 -2
  44. package/src/util/paths.ts +32 -0
  45. package/src/util/verbs.ts +1 -0
  46. package/src/workspace/config-schema.ts +26 -8
  47. package/src/workspace/lockfile.ts +29 -2
  48. 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
- process.stderr.write(`vx init: ${parsed.error}\n`)
86
- return 1
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
- /** Install what the workspace file imports, if missing, then run the repo's build. */
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
- return `${install} ${missing.join(' ')} && ${run}`
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 (const a of args) {
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
- check: false,
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
- process.stderr.write(`vx lock: ${parsed.error}\n`)
47
- return 1
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(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
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
- process.stderr.write(
161
- `vx lock --check: no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first\n`,
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
@@ -145,10 +145,12 @@ export interface PlanTaskJson {
145
145
  affected?: AffectedReason
146
146
  }
147
147
 
148
- export function formatPlanJson(plan: RunPlan): string {
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 resolved = await resolveRunOptions(parsed, cwd, tasks)
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
  }
@@ -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
- const tmp = `${dest}.upgrade-${process.pid}`
251
- const old = `${dest}.previous-${process.pid}`
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(tmp, { force: true })
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
- if (kept) await rm(old, { force: true })
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
@@ -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[i] = bitset over node
129
- // indices of i's transitive dependents.
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 = i * words
135
- for (const r of directReverse[i]!) {
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 = 0; w < words; w++) closure[base + w]! |= closure[rbase + 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 = 0; w < words; 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>,
@@ -5,7 +5,13 @@ import {
5
5
  isUserError,
6
6
  OUT_OF_FDS_HINT,
7
7
  } from '../util/index.js'
8
- import { computeReverseDepCount, mergePriorities, tieredReverseDepCount } from './priorities.js'
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.restoreTier !== undefined && options.restoreTier.size > 0
498
- ? tieredReverseDepCount(nodes, options.restoreTier)
499
- : computeReverseDepCount(nodes)
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
@@ -109,7 +109,7 @@ export function isGroupTask(node: TaskNode): boolean {
109
109
  return node.config.exec === undefined
110
110
  }
111
111
 
112
- /** The default `build` (orchestrator/projects.ts): a config cannot key a group, so it is the one keyed group. */
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: the default
1509
- * `build` reads `**` of a config-less dependency and runs nothing, and its
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
- function readsOutputs(x: Side, y: Side, field: 'files' | 'workspaceFiles'): void {
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 (outputTakenBack(go, takeBack)) continue
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
- function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
614
- return db
615
- .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
616
- .all(entryHash) as TaskInputComponent[]
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
  /**