@vzn/vx 0.0.634 → 0.0.636

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 (42) hide show
  1. package/README.md +51 -52
  2. package/docs/caching.md +14 -2
  3. package/docs/cli.md +94 -36
  4. package/docs/execution.md +1 -1
  5. package/docs/features.md +4 -3
  6. package/docs/patterns.md +8 -7
  7. package/docs/schema.md +73 -3
  8. package/docs/security.md +7 -1
  9. package/launcher.cjs +0 -0
  10. package/launcher.sh +50 -0
  11. package/package.json +17 -8
  12. package/schemas/last.json +5 -1
  13. package/schemas/show.json +8 -0
  14. package/schemas/why.json +4 -1
  15. package/src/cache/layered-cache.ts +3 -1
  16. package/src/cli/help.ts +3 -1
  17. package/src/cli/last.ts +9 -1
  18. package/src/cli/plugin-templates.ts +1 -1
  19. package/src/cli/run.ts +35 -7
  20. package/src/cli/select.ts +17 -11
  21. package/src/cli/show.ts +2 -2
  22. package/src/config.ts +36 -3
  23. package/src/graph/scheduler.ts +14 -3
  24. package/src/graph/task-graph.ts +22 -5
  25. package/src/index.ts +7 -1
  26. package/src/orchestrator/execute-task.ts +62 -6
  27. package/src/orchestrator/framed-output.ts +27 -1
  28. package/src/orchestrator/history.ts +75 -1
  29. package/src/orchestrator/index.ts +1 -0
  30. package/src/orchestrator/options.ts +2 -0
  31. package/src/orchestrator/path-links.ts +5 -3
  32. package/src/orchestrator/plugin.ts +3 -1
  33. package/src/orchestrator/prepare.ts +17 -14
  34. package/src/orchestrator/run-failures.ts +20 -3
  35. package/src/orchestrator/run.ts +25 -2
  36. package/src/orchestrator/sandbox-fix.ts +123 -0
  37. package/src/orchestrator/task-hash.ts +7 -1
  38. package/src/workspace/affected.ts +16 -4
  39. package/src/workspace/config-schema.ts +85 -4
  40. package/src/workspace/index.ts +2 -0
  41. package/src/workspace/migration.ts +51 -4
  42. package/src/workspace/project-loader.ts +3 -1
package/src/config.ts CHANGED
@@ -42,7 +42,8 @@ export interface WorkspaceConfig {
42
42
  * then the trusted ones, and writes go to the scope's only, so a PR never
43
43
  * writes what the default branch reads. A client-side convention, not a
44
44
  * security boundary: only a cache server that scopes writes by token
45
- * can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it. Never
45
+ * can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it, and
46
+ * `vx run --cache-scope` beats both. Never
46
47
  * folded into a cache key.
47
48
  */
48
49
  cacheScope?: string
@@ -55,6 +56,12 @@ export interface WorkspaceConfig {
55
56
  * `schedule` plugin's weights still come first; this breaks their ties.
56
57
  */
57
58
  schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
59
+ /**
60
+ * The `cache.inputStability` of every task that does not set its own
61
+ * field. `VX_INPUT_STABILITY` / `VX_INPUT_STABILITY_SEVERITY` override
62
+ * both for the run.
63
+ */
64
+ inputStability?: InputStability
58
65
  /**
59
66
  * Graph rules a run checks before it schedules anything. Each is on unless
60
67
  * set to `false`; turning one off allows a shape vx handles correctly but
@@ -65,9 +72,11 @@ export interface WorkspaceConfig {
65
72
  * Plugins registered for this workspace, consulted in this order once
66
73
  * per `vx run`: each fills any of the pipeline stages and capabilities
67
74
  * below, and core's own executor and cache store sit at the tail of
68
- * every list. See `docs/design/pipeline-2026-09.md`.
75
+ * every list. See `docs/design/pipeline-2026-09.md`. An `undefined`,
76
+ * `null` or `false` entry is skipped, so a factory that declines
77
+ * (`github()` off Actions) or `cond && plugin()` needs no filter.
69
78
  */
70
- plugins?: readonly Plugin[]
79
+ plugins?: readonly (Plugin | undefined | null | false)[]
71
80
  }
72
81
 
73
82
  /** `WorkspaceConfig.rules`. */
@@ -488,6 +497,30 @@ export interface CacheConfig {
488
497
  inputs: CacheInputs
489
498
  /** What this task produces. Captured for restore on a cache hit. */
490
499
  outputs: CacheOutputs
500
+ /**
501
+ * What a write to an input while the command runs means. Each field,
502
+ * highest first: its env var, this, the workspace's `inputStability`,
503
+ * the default (`'immutable'`, `'warn'`).
504
+ */
505
+ inputStability?: InputStability
506
+ }
507
+
508
+ export interface InputStability {
509
+ /**
510
+ * `'immutable'` (the default): any write to an input during the command
511
+ * withholds the entry, whatever the file holds after, since an edit
512
+ * undone mid-run cannot be told from no edit. `'mutable'`: a file
513
+ * rewritten to the bytes the key folded counts as unchanged, for a task
514
+ * that rewrites its own input identically (tsdown's `package.json`).
515
+ * `'unchecked'`: no check.
516
+ */
517
+ strategy?: 'immutable' | 'mutable' | 'unchecked'
518
+ /**
519
+ * What a withheld entry does to the run. `'warn'` (the default): the
520
+ * result stands, vx says why, and the run goes on. `'error'`: the task
521
+ * fails, so the run fails.
522
+ */
523
+ severity?: 'warn' | 'error'
491
524
  }
492
525
 
493
526
  export interface CacheOutputs {
@@ -208,6 +208,15 @@ export interface TaskOutcome {
208
208
  * inline in the task's block instead of as loose status output.
209
209
  */
210
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
+ }
211
220
  }
212
221
 
213
222
  export type ContinueMode = 'never' | 'deps-ok' | 'always'
@@ -804,14 +813,16 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
804
813
  // tasks are never asked, so they never park.
805
814
  const takeFitting = (): string | undefined => {
806
815
  if (holding) return undefined
807
- // No pools, no policy and a full exec lane: nothing on the
808
- // 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
809
820
  // would pop and re-park every ready exec task on every tick, and
810
821
  // with a wide frontier that is O(R) per tick, O(R²) per run (the
811
822
  // 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
812
823
  // first cut let the scan run past a full exec lane, 2026-09-10).
813
824
  // This is the legacy O(1) gate, kept per lane.
814
- const execAdmissible = poolOf !== undefined || admitActive || execRoom()
825
+ const execAdmissible = poolOf !== undefined || execRoom()
815
826
  if (execAdmissible) {
816
827
  while (execReady.size > 0) {
817
828
  const seq = execReady.peekSeq()
@@ -302,6 +302,7 @@ export interface BuildGraphOptions {
302
302
  export function undeclaredDepsError(taskId: string, name: string): UserError {
303
303
  return new UserError(
304
304
  `Task ${taskId} depends on ^${name} but no project in the workspace declares ${name}`,
305
+ 'VX_E_DEPENDS_ON',
305
306
  )
306
307
  }
307
308
 
@@ -492,7 +493,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
492
493
  spec = parseDependencySpec(raw)
493
494
  } catch (err) {
494
495
  if (err instanceof DependencySpecError) {
495
- throw new UserError(`Task ${id}: ${err.message}`)
496
+ throw new UserError(`Task ${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
496
497
  }
497
498
  throw err
498
499
  }
@@ -503,14 +504,21 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
503
504
  // operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
504
505
  // they name a namespace of tasks to add (Nx 19.5 parity).
505
506
  if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
506
- throw new UserError(`Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`)
507
+ throw new UserError(
508
+ `Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`,
509
+ 'VX_E_DEPENDS_ON',
510
+ )
507
511
  }
508
512
  if (spec.negated) {
509
- throw new UserError(`Task ${id}: dependsOn does not accept negation (got "${raw}")`)
513
+ throw new UserError(
514
+ `Task ${id}: dependsOn does not accept negation (got "${raw}")`,
515
+ 'VX_E_DEPENDS_ON',
516
+ )
510
517
  }
511
518
  if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
512
519
  throw new UserError(
513
520
  `Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
521
+ 'VX_E_DEPENDS_ON',
514
522
  )
515
523
  }
516
524
  if (spec.kind === 'self') {
@@ -530,6 +538,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
530
538
  if (!visit(frame, projectName, spec.task, false)) {
531
539
  throw new UserError(
532
540
  `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
541
+ 'VX_E_DEPENDS_ON',
533
542
  )
534
543
  }
535
544
  goPast(frame, projectName, spec.task, new Set([projectName]))
@@ -594,9 +603,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
594
603
  if (spec.project === '//')
595
604
  throw new UserError(
596
605
  `Task ${id} depends on //#${spec.task}, Turbo's root package, but the workspace root is no project here: give it a package.json name and a vx.config with that task`,
606
+ 'VX_E_DEPENDS_ON',
597
607
  )
598
608
  throw new UserError(
599
609
  `Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
610
+ 'VX_E_DEPENDS_ON',
600
611
  )
601
612
  }
602
613
  goPast(frame, spec.project, spec.task, new Set([projectName, spec.project]))
@@ -1109,7 +1120,7 @@ function collideAcross(
1109
1120
  `cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
1110
1121
  `cache-hit restore, so whichever of these runs second DELETES the other's output`
1111
1122
  if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
1112
- throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
1123
+ throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
1113
1124
  }
1114
1125
  if (reaches(files.id, ws.id)) {
1115
1126
  ;(files.addsToOutputsOf ??= []).push(ws.id)
@@ -1132,11 +1143,13 @@ function collideAcross(
1132
1143
  `${head}. ${ws.id} depends on ${files.id}, but adding to its output needs every ` +
1133
1144
  `cache.outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
1134
1145
  `and ${JSON.stringify(outside)} is not. Give each task its own output path.`,
1146
+ 'VX_E_OUTPUT_OVERLAP',
1135
1147
  )
1136
1148
  }
1137
1149
  throw new UserError(
1138
1150
  `${head} and the run still reports success. Give each task its own output path` +
1139
1151
  (exclusive ? '.' : ', or make one depend on the other.'),
1152
+ 'VX_E_OUTPUT_OVERLAP',
1140
1153
  )
1141
1154
  }
1142
1155
  }
@@ -1373,7 +1386,8 @@ function collide(
1373
1386
  // `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
1374
1387
  // in either order, and the refusal below stands.
1375
1388
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
1376
- if (up !== undefined && exclusive) throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
1389
+ if (up !== undefined && exclusive)
1390
+ throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
1377
1391
  if (up !== undefined && down !== undefined) {
1378
1392
  const downGlobs = splitNegations(
1379
1393
  (field === 'files'
@@ -1386,6 +1400,7 @@ function collide(
1386
1400
  }
1387
1401
  throw new UserError(
1388
1402
  `${head} and the run still reports success. Give each task its own output path.`,
1403
+ 'VX_E_OUTPUT_OVERLAP',
1389
1404
  )
1390
1405
  }
1391
1406
  }
@@ -1440,6 +1455,7 @@ function refuseSelfClean(node: TaskNode): void {
1440
1455
  `its own output — vx removes a task's outputs before it runs, so the task would ` +
1441
1456
  `delete its own sources. A task that rewrites files in place (a formatter) declares ` +
1442
1457
  `no outputs.`,
1458
+ 'VX_E_INPUT_IS_OUTPUT',
1443
1459
  )
1444
1460
  }
1445
1461
  }
@@ -1663,6 +1679,7 @@ function readsOutputs(
1663
1679
  `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1664
1680
  `cache.inputs.${field}, or set rules: { upfrontKeys: false } in vx.workspace.ts to ` +
1665
1681
  `let it wait for its producer.`,
1682
+ 'VX_E_INPUT_IS_OUTPUT',
1666
1683
  )
1667
1684
  }
1668
1685
  }
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export type {
48
48
  CacheConfig,
49
49
  CacheInputs,
50
50
  CacheOutputs,
51
+ InputStability,
51
52
  SandboxConfig,
52
53
  SandboxGrants,
53
54
  SandboxDenials,
@@ -65,7 +66,12 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
65
66
  // (`@vzn/vx-schedule-history` does; a plugin package reaches core only
66
67
  // through this façade, which is what put these here).
67
68
  export { LocalHistoryProvider } from './orchestrator/index.js'
68
- export type { HistoryProvider, HistoryTable, TaskHistory } from './orchestrator/index.js'
69
+ export type {
70
+ HistoryProvider,
71
+ HistoryTable,
72
+ TaskHistory,
73
+ TaskResources,
74
+ } from './orchestrator/index.js'
69
75
  export type { PreparedRun } from './orchestrator/index.js'
70
76
  // `RunOptions.affected`: what `--affected`'s diff changed, per project.
71
77
  export type { AffectedChanges } from './workspace/index.js'
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path'
2
- import type { ExecConfig, TaskConfig, CacheConfig } from '../config.js'
2
+ import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
3
3
  import {
4
4
  ArtifactVanishedError,
5
5
  type CacheEntry,
@@ -78,6 +78,7 @@ import type { FingerprintWatch } from './fingerprint-watch.js'
78
78
  import { restoreHit } from './hit-restore.js'
79
79
  import type { MissExplainer } from './miss-reason.js'
80
80
  import { shellVerdict } from './shell-verdict.js'
81
+ import { sandboxFix } from './sandbox-fix.js'
81
82
  // The hit path's entry stays importable from here (tests).
82
83
  export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
83
84
  import type { DeferredOutputs } from './deferred-outputs.js'
@@ -174,6 +175,11 @@ export interface ExecuteArgs {
174
175
  * `exec.timeout` wins. Threaded as an option only — never hashed.
175
176
  */
176
177
  timeout?: number
178
+ /**
179
+ * The run's `inputStability` layers around the task's own: `run` from
180
+ * the env vars (above the task), `workspace` from `vx.workspace` (below).
181
+ */
182
+ inputStability?: { run: InputStability; workspace?: InputStability }
177
183
  /** Per-run memo for `git ls-files` (one entry per project dir). */
178
184
  gitFilesCache?: GitFilesCache
179
185
  /** Per-run memo for derived hashes (package.json bytes + task config). */
@@ -336,6 +342,24 @@ async function restoreOrMiss(
336
342
  }
337
343
  }
338
344
 
345
+ /**
346
+ * What a write to an input during the command means, each field highest
347
+ * first: the env var (`layers.run`), the task's `cache.inputStability`,
348
+ * the workspace's, then `'immutable'` and `'warn'` (owner, 2026-10-12), so
349
+ * by default any such write withholds the save, on CI too, since a
350
+ * concurrent task can change an input and put it back.
351
+ */
352
+ function inputStabilityOf(
353
+ cache: CacheConfig | undefined,
354
+ layers: ExecuteArgs['inputStability'],
355
+ ): Required<InputStability> {
356
+ const task = cache?.inputStability
357
+ return {
358
+ strategy: layers?.run.strategy ?? task?.strategy ?? layers?.workspace?.strategy ?? 'immutable',
359
+ severity: layers?.run.severity ?? task?.severity ?? layers?.workspace?.severity ?? 'warn',
360
+ }
361
+ }
362
+
339
363
  /**
340
364
  * The workspace fingerprint every key here folded has moved (a task
341
365
  * rewrote the lockfile), said once per run.
@@ -1449,6 +1473,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1449
1473
 
1450
1474
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
1451
1475
 
1476
+ const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
1477
+ let movedFails = false
1452
1478
  let own: Awaited<ReturnType<typeof ownOutputs>>
1453
1479
  if (effectiveExitCode === 0 && willSave && deferralRequested) {
1454
1480
  // The outputs never landed here: no artifact, no rows. A partial local
@@ -1558,7 +1584,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1558
1584
  */
1559
1585
  async function movedSinceKey(): Promise<string | null | undefined> {
1560
1586
  if (described!.hash !== hash) return null
1561
- return (await movedInput(described!.facts, cache, described!.describedAt)) ?? described!.added()
1587
+ return (
1588
+ (await movedInput(
1589
+ described!.facts,
1590
+ cache,
1591
+ described!.describedAt,
1592
+ stability.strategy === 'mutable',
1593
+ )) ?? described!.added()
1594
+ )
1562
1595
  }
1563
1596
 
1564
1597
  /**
@@ -1577,6 +1610,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1577
1610
  unkeyed = true
1578
1611
  return false
1579
1612
  }
1613
+ // `'unchecked'` skips the check of the command's own window; a key the
1614
+ // re-derive before the command already disagreed with still withholds,
1615
+ // since it never described the bytes the command read.
1616
+ if (stability.strategy === 'unchecked' && described!.hash === hash) return true
1580
1617
  const endCheck = span('miss: recheck inputs')
1581
1618
  const moved = await movedSinceKey()
1582
1619
  endCheck()
@@ -1591,15 +1628,25 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1591
1628
  moved === null || path.basename(moved) === 'package.json'
1592
1629
  ? ''
1593
1630
  : '; if the task writes it, declare it in cache.outputs'
1594
- log.status(
1595
- `[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
1596
- `but is not saved under a key that no longer describes it${hint}`,
1597
- )
1631
+ if (stability.severity === 'error') {
1632
+ log.status(
1633
+ `[vx] ${node.id}: ${what} changed after its key was taken — the result is not saved, ` +
1634
+ `and inputStability.severity is 'error', so the task fails${hint}`,
1635
+ )
1636
+ movedFails = true
1637
+ } else {
1638
+ log.status(
1639
+ `[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
1640
+ `but is not saved under a key that no longer describes it${hint}`,
1641
+ )
1642
+ }
1598
1643
  forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
1599
1644
  unkeyed = true
1600
1645
  return false
1601
1646
  }
1602
1647
 
1648
+ if (movedFails) effectiveExitCode = 1
1649
+
1603
1650
  if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
1604
1651
  log.status(
1605
1652
  `[vx] ${node.id}: ran over ${unkeyedUpstream.node.id}'s outputs, which its key no longer ` +
@@ -1608,6 +1655,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1608
1655
  }
1609
1656
 
1610
1657
  const finalViolations = violations
1658
+ const fixOf = (fix: TaskOutcome['sandboxFix']) => (fix === undefined ? {} : { sandboxFix: fix })
1611
1659
 
1612
1660
  return {
1613
1661
  node,
@@ -1640,6 +1688,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1640
1688
  sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
1641
1689
  // A denied path is the task's own spelling, a secret in it too.
1642
1690
  sandboxViolationLines: finalViolations.map((v) => secrets?.mask(v.line) ?? v.line),
1691
+ ...fixOf(
1692
+ sandboxFix(
1693
+ finalViolations,
1694
+ node.projectDir,
1695
+ (t) => secrets?.mask(t) ?? t,
1696
+ cacheCfg?.inputs.files,
1697
+ ),
1698
+ ),
1643
1699
  }
1644
1700
  : {}),
1645
1701
  }
@@ -32,6 +32,7 @@ import { paint, type ColorSupport } from './colors.js'
32
32
  import { neverStarted } from './summary.js'
33
33
  import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
34
34
  import { fileMemo, linkPaths } from './path-links.js'
35
+ import { sandboxFixLine } from './sandbox-fix.js'
35
36
 
36
37
  const NO_COLOR: ColorSupport = { enabled: false }
37
38
 
@@ -529,7 +530,32 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
529
530
  // every record for telemetry.
530
531
  const body = [...new Set(vlines)]
531
532
  // Red, not amber: a violation fails the task, so it reads as a failure.
532
- return [sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors), '', ...body, '']
533
+ // vx's note, after the evidence: the grants that would have allowed it.
534
+ // A read the output depends on is a key input too; the sandbox never
535
+ // adds one (owner, 2026-10-09).
536
+ const fix = outcome.sandboxFix
537
+ const line = fix === undefined ? undefined : sandboxFixLine(fix)
538
+ const dim = (text: string): string => paint('', text, colors, { dim: true })
539
+ const note =
540
+ fix === undefined
541
+ ? []
542
+ : [
543
+ ...(line !== undefined ? [dim(`fix: ${line}`)] : []),
544
+ ...(fix.read !== undefined
545
+ ? [dim('a read the output depends on belongs in cache.inputs.files too')]
546
+ : []),
547
+ ...(fix.withheld !== undefined
548
+ ? [dim(`credential path, grant it only on purpose: ${fix.withheld.join(', ')}`)]
549
+ : []),
550
+ '',
551
+ ]
552
+ return [
553
+ sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors),
554
+ '',
555
+ ...body,
556
+ '',
557
+ ...note,
558
+ ]
533
559
  }
534
560
 
535
561
  export function formatFrameClose(
@@ -21,7 +21,8 @@ const DEFAULT_RECENT = 50
21
21
 
22
22
  // A hit is recorded as `cache-hit` / `cache-hit-remote` and replays a
23
23
  // success; the list is derived so a new status cannot miss it.
24
- const PASS_STATUSES = `(${TASK_STATUSES.filter(isPassStatus)
24
+ /** The pass set as SQL, `(…)`: one definition, `isPassStatus`. */
25
+ export const PASS_STATUSES = `(${TASK_STATUSES.filter(isPassStatus)
25
26
  .map((s) => `'${s}'`)
26
27
  .join(', ')})`
27
28
 
@@ -58,6 +59,12 @@ export interface TaskHistory {
58
59
  /** Map keyed by `project#task`. */
59
60
  export type HistoryTable = ReadonlyMap<string, TaskHistory>
60
61
 
62
+ /** What a scheduler learns from a task's history: its p50 and its resource maxima. */
63
+ export type TaskResources = Pick<
64
+ TaskHistory,
65
+ 'p50DurationMs' | 'maxPeakRssBytes' | 'maxCpuParallelism'
66
+ >
67
+
61
68
  export interface HistoryProvider {
62
69
  loadFor(taskIds: readonly string[]): Promise<HistoryTable>
63
70
  /**
@@ -213,6 +220,73 @@ export class LocalHistoryProvider implements HistoryProvider {
213
220
  return out
214
221
  }
215
222
 
223
+ /**
224
+ * `loadFor`'s p50 and resource maxima over the same window, without its
225
+ * rates. The rates group every row of the window and join each hit to
226
+ * its entry: 407 ms at 168,000 rows (8,002 tasks, 21 runs), most of a
227
+ * nothing-changed run's schedule stage. Here the executed successes are
228
+ * grouped alone, and the hits only as distinct keys, each joined to its
229
+ * entry once: 148 ms on the same table, the same numbers.
230
+ */
231
+ async resourcesFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, TaskResources>> {
232
+ const out = new Map<string, TaskResources>()
233
+ if (taskIds.length === 0) return out
234
+ const floor = this.floor()
235
+ const wanted = new Set(taskIds)
236
+ type Executed = {
237
+ project: string
238
+ task: string
239
+ ds: string
240
+ rss: number | null
241
+ cpu: number | null
242
+ }
243
+ const executed = this.db
244
+ .query(
245
+ `SELECT project, task, GROUP_CONCAT(duration_ms) AS ds, MAX(peak_rss_bytes) AS rss,
246
+ MAX(CASE WHEN cpu_ms IS NOT NULL AND duration_ms > 0
247
+ THEN cpu_ms * 1.0 / duration_ms END) AS cpu
248
+ FROM runs
249
+ WHERE id >= ? AND status = 'success' AND (cache_hit IS NULL OR cache_hit = 0)
250
+ GROUP BY project, task`,
251
+ )
252
+ .all(floor) as Executed[]
253
+ for (const row of executed) {
254
+ const key = `${row.project}#${row.task}`
255
+ if (!wanted.has(key)) continue
256
+ const durations = row.ds.split(',').map(Number)
257
+ durations.sort((a, b) => a - b)
258
+ out.set(key, {
259
+ p50DurationMs: pickPercentile(durations, 0.5),
260
+ ...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
261
+ ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
262
+ })
263
+ }
264
+ type Hit = { project: string; task: string; rss: number | null; cpu: number | null }
265
+ const hits = this.db
266
+ .query(
267
+ `SELECT h.project, h.task, MAX(e.peak_rss_bytes) AS rss,
268
+ MAX(CASE WHEN e.cpu_ms IS NOT NULL AND e.duration_ms > 0
269
+ THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
270
+ FROM (SELECT DISTINCT project, task, hash FROM runs WHERE id >= ? AND cache_hit = 1) h
271
+ JOIN entries e ON e.hash = h.hash
272
+ GROUP BY h.project, h.task`,
273
+ )
274
+ .all(floor) as Hit[]
275
+ for (const row of hits) {
276
+ const key = `${row.project}#${row.task}`
277
+ if (!wanted.has(key) || (row.rss === null && row.cpu === null)) continue
278
+ const had = out.get(key)
279
+ const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
280
+ const cpu = Math.max(had?.maxCpuParallelism ?? -Infinity, row.cpu ?? -Infinity)
281
+ out.set(key, {
282
+ p50DurationMs: had?.p50DurationMs,
283
+ ...(rss !== -Infinity ? { maxPeakRssBytes: rss } : {}),
284
+ ...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
285
+ })
286
+ }
287
+ return out
288
+ }
289
+
216
290
  /** The first `runs` id of the newest `recent` invocations (see `loadFor`). */
217
291
  private floor(): number {
218
292
  const row = this.db
@@ -56,6 +56,7 @@ export {
56
56
  type HistoryTable,
57
57
  LocalHistoryProvider,
58
58
  type TaskHistory,
59
+ type TaskResources,
59
60
  } from './history.js'
60
61
  export {
61
62
  detectFlaky,
@@ -86,6 +86,8 @@ export interface RunOptions {
86
86
  * saying it is not.
87
87
  */
88
88
  remoteRequested?: boolean
89
+ /** This run's `cacheScope` (the CLI's `--cache-scope`), above `VX_CACHE_SCOPE` and `vx.workspace`. */
90
+ cacheScope?: string
89
91
  /**
90
92
  * The `cacheScope` a run takes when `vx.workspace` names none. The CLI
91
93
  * passes `'read-only'` off CI (`CI` unset, `0` or `false`) unless
@@ -35,7 +35,9 @@ export function linkPaths(text: string, dir: string, isFile: (abs: string) => bo
35
35
 
36
36
  /**
37
37
  * The files `text` names that exist (resolved against `dir`), each with the
38
- * line and column it gave, unique, in order of first appearance.
38
+ * line and column it gave, unique, in order of first appearance. A file also
39
+ * named with a line drops its bare mention: bun test heads a failure with
40
+ * `app.test.js:` and gives the line only in the stack below it.
39
41
  */
40
42
  export function fileLocations(
41
43
  text: string,
@@ -56,9 +58,9 @@ export function fileLocations(
56
58
  ...(line !== undefined ? { line: Number(line) } : {}),
57
59
  ...(col !== undefined ? { col: Number(col) } : {}),
58
60
  })
59
- if (out.length === LOCATIONS_CAP) break
60
61
  }
61
- return out
62
+ const lined = new Set(out.filter((l) => l.line !== undefined).map((l) => l.file))
63
+ return out.filter((l) => l.line !== undefined || !lined.has(l.file)).slice(0, LOCATIONS_CAP)
62
64
  }
63
65
 
64
66
  /** A file test that asks the file system once per path. */
@@ -127,7 +127,9 @@ export interface VxPlugin {
127
127
  /**
128
128
  * Scheduling priorities: task id → weight, higher runs first among READY
129
129
  * tasks (merged over the structural baseline, which stays the tie-break).
130
- * Runs once, after the graph is final. A later plugin's weight for a task
130
+ * Runs once, after the graph is final and the local hits are known, and
131
+ * only when a task will start: a run of confirmed local hits restores
132
+ * in no order a weight changes. A later plugin's weight for a task
131
133
  * overrides an earlier one's. Return undefined to leave the baseline.
132
134
  */
133
135
  schedule?(
@@ -102,9 +102,11 @@ export interface PreparedRun {
102
102
  /**
103
103
  * Scheduling priorities from plugins' `schedule` stage (task id → weight,
104
104
  * merged over the structural baseline by the scheduler). Empty when no
105
- * plugin declares the stage.
105
+ * plugin declares the stage. Asked only by a run with a task to start:
106
+ * the stage reads history (148 ms at 8,002 tasks) to order work a
107
+ * nothing-changed run never dispatches. Runs the stage once.
106
108
  */
107
- priorities: ReadonlyMap<string, number>
109
+ priorities: () => Promise<ReadonlyMap<string, number>>
108
110
  nodes: Map<string, TaskNode>
109
111
  /**
110
112
  * The tasks `--exclude-dependencies` took out of the schedule: keyed as a
@@ -286,13 +288,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
286
288
 
287
289
  // The local cache opens BEFORE the configs load: it is also where their
288
290
  // cached evaluations live.
289
- // `VX_CACHE_SCOPE` wins over the workspace (and `github()`'s guess):
291
+ // `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
290
292
  // a CI job that knows its trust level says so without editing config.
291
293
  const envScope = process.env['VX_CACHE_SCOPE'] || undefined
292
294
  if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
293
295
  const policy: CachePolicy = scopeCachePolicy(
294
296
  options.cache ?? FULL_CACHE_POLICY,
295
- envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
297
+ options.cacheScope ?? envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
296
298
  )
297
299
  const localCache = new Cache(
298
300
  cacheDir,
@@ -491,7 +493,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
491
493
  localCache,
492
494
  hasRemoteLayer,
493
495
  cachePolicy: policy,
494
- priorities: new Map(),
496
+ priorities: () => Promise.resolve(new Map()),
495
497
  nodes: new Map(),
496
498
  keyOnly: new Map(),
497
499
  unresolvedTasks,
@@ -687,15 +689,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
687
689
  })
688
690
  }
689
691
  }
690
- let priorities: ReadonlyMap<string, number> = new Map()
691
- if (hasHook(plugins, 'schedule')) {
692
- priorities = await applyScheduleHooks(plugins, nodes, {
693
- workspaceRoot,
694
- cacheDir,
695
- warn: (m) => log.status(m),
696
- localCache,
697
- })
698
- }
692
+ let scheduled: Promise<ReadonlyMap<string, number>> | undefined
693
+ const priorities = (): Promise<ReadonlyMap<string, number>> =>
694
+ (scheduled ??= hasHook(plugins, 'schedule')
695
+ ? applyScheduleHooks(plugins, nodes, {
696
+ workspaceRoot,
697
+ cacheDir,
698
+ warn: (m) => log.status(m),
699
+ localCache,
700
+ })
701
+ : Promise.resolve(new Map()))
699
702
 
700
703
  return {
701
704
  workspaceRoot,