@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.
Files changed (53) hide show
  1. package/README.md +51 -52
  2. package/docs/caching.md +11 -2
  3. package/docs/cli.md +192 -56
  4. package/docs/execution.md +1 -1
  5. package/docs/features.md +6 -3
  6. package/docs/patterns.md +8 -5
  7. package/docs/schema.md +48 -2
  8. package/docs/security.md +6 -0
  9. package/launcher.cjs +0 -0
  10. package/launcher.sh +50 -0
  11. package/package.json +17 -8
  12. package/schemas/error.json +5 -1
  13. package/schemas/last.json +5 -1
  14. package/schemas/plan.json +4 -0
  15. package/schemas/show.json +2 -1
  16. package/schemas/why.json +4 -1
  17. package/skills/vx/SKILL.md +7 -1
  18. package/src/cache/cache.ts +60 -5
  19. package/src/cache/inputs.ts +66 -30
  20. package/src/cache/layered-cache.ts +3 -1
  21. package/src/cli/docs.ts +9 -3
  22. package/src/cli/help.ts +2 -1
  23. package/src/cli/init.ts +9 -2
  24. package/src/cli/last.ts +9 -1
  25. package/src/cli/plan-format.ts +3 -1
  26. package/src/cli/plugin-templates.ts +1 -1
  27. package/src/cli/run.ts +17 -8
  28. package/src/cli/select.ts +17 -11
  29. package/src/cli/show.ts +2 -2
  30. package/src/config.ts +23 -2
  31. package/src/graph/priorities.ts +72 -6
  32. package/src/graph/scheduler.ts +31 -7
  33. package/src/graph/task-graph.ts +75 -20
  34. package/src/index.ts +6 -1
  35. package/src/orchestrator/execute-task.ts +30 -1
  36. package/src/orchestrator/framed-output.ts +27 -1
  37. package/src/orchestrator/history.ts +75 -1
  38. package/src/orchestrator/index.ts +1 -0
  39. package/src/orchestrator/path-links.ts +5 -3
  40. package/src/orchestrator/plugin.ts +3 -1
  41. package/src/orchestrator/prepare.ts +15 -12
  42. package/src/orchestrator/remote-prefetch.ts +8 -2
  43. package/src/orchestrator/run-failures.ts +20 -3
  44. package/src/orchestrator/run.ts +24 -2
  45. package/src/orchestrator/sandbox-fix.ts +123 -0
  46. package/src/orchestrator/task-hash.ts +7 -1
  47. package/src/util/errors.ts +9 -2
  48. package/src/util/paths.ts +32 -0
  49. package/src/workspace/affected.ts +16 -4
  50. package/src/workspace/config-schema.ts +30 -2
  51. package/src/workspace/lockfile.ts +29 -2
  52. package/src/workspace/migration.ts +51 -4
  53. 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
- /** 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
+ */
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
- 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}`
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 ? t : { ...t, output: f.output, locations: f.locations }
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
@@ -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 {
@@ -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, rename } 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 renamed: a reader never sees half an artifact.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n await rename(tmp, path.join(this.endpoint, hash))\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",
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<{ filterStrings: string[]; affectedFilter?: string } | { error: string }> {
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
- ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
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 { error: resolved.error }
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) return refuse('run', args, selected.error, 'VX_E_USAGE')
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 resolved = await resolveRunOptions(parsed, cwd, tasks)
713
- if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
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
- | { error: string }
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 { error: err.message }
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 { error: err instanceof Error ? err.message : String(err) }
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 { error: err instanceof Error ? err.message : String(err) }
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 { error: err instanceof Error ? err.message : String(err) }
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
- const msg = err instanceof Error ? err.message : String(err)
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 {
@@ -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
  /**
@@ -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.restoreTier !== undefined && options.restoreTier.size > 0
498
- ? tieredReverseDepCount(nodes, options.restoreTier)
499
- : computeReverseDepCount(nodes)
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, no policy and a full exec lane: nothing on the
795
- // exec queue can be admitted, so it is not scanned — scanning it
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 || admitActive || execRoom()
825
+ const execAdmissible = poolOf !== undefined || execRoom()
802
826
  if (execAdmissible) {
803
827
  while (execReady.size > 0) {
804
828
  const seq = execReady.peekSeq()