@vzn/vx 0.0.635 → 0.0.637

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 (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -3,7 +3,7 @@
3
3
  // so the layers can be swapped without touching the others.
4
4
 
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
- import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
6
+ import { inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
7
7
  import { realpathSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import {
@@ -37,8 +37,6 @@ import {
37
37
  stageTimes,
38
38
  killGraceMs,
39
39
  MAX_TIMEOUT_MS,
40
- parseDuration,
41
- parseSize,
42
40
  printTimings,
43
41
  ulid,
44
42
  isLiteralPattern,
@@ -49,10 +47,11 @@ import {
49
47
  teardownTimeoutMs,
50
48
  secretMask,
51
49
  isFsRefusal,
50
+ isUserError,
52
51
  } from '../util/index.js'
53
52
  import { keyedProjects } from './keyed-projects.js'
54
53
  import { isDefaultBuild } from './projects.js'
55
- import { prepareSandbox } from './sandbox-request.js'
54
+ import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
56
55
  import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
57
56
  import { admitTasks, taintTracker } from './admission.js'
58
57
  import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
@@ -62,6 +61,7 @@ import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './
62
61
  import { installPlugins, PluginSetupError } from './plugin.js'
63
62
  import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from './plugin-host.js'
64
63
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
64
+ import { claimOutput, guardedOutput } from './output-host.js'
65
65
  import { assembleRunSummary, isPassStatus } from './telemetry.js'
66
66
  import type { RunContextRecord } from './telemetry.js'
67
67
  import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
@@ -121,10 +121,13 @@ import {
121
121
  type RunSummaryJson,
122
122
  } from './run-artifacts.js'
123
123
  import { createSaveLane } from './save-lane.js'
124
+ import { commandWindows } from './command-windows.js'
124
125
  import { formatOutcomeTable, formatRunSummary } from './summary.js'
125
126
  import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
126
127
  import { createMissExplainer } from './miss-reason.js'
127
128
  import type { HeldServer, RunOptions, RunSummary } from './options.js'
129
+ import { withRunSettings } from './run-settings.js'
130
+ import { resolveSetting } from '../settings.js'
128
131
 
129
132
  // Per run, never shared: a `vx watch` process runs many, and a shared map
130
133
  // is one `preProbed.set` away from leaking a hit across cycles.
@@ -135,28 +138,6 @@ const emptyShortCircuit = (): ShortCircuit => ({
135
138
  groupKeys: new Map(),
136
139
  })
137
140
 
138
- /**
139
- * Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
140
- * timeout default. A missing/empty/non-positive-integer value yields
141
- * `undefined` (ignored), so a typo never silently disables a task's own
142
- * `exec.timeout`. A value past `MAX_TIMEOUT_MS` is clamped to it — see below.
143
- *
144
- * Exported for `tests/options-resolve.test.ts`, which pins every accepted
145
- * and ignored form of this rung; it has no other caller outside this file.
146
- */
147
- export function readTaskTimeoutEnv(): number | undefined {
148
- const raw = process.env['VX_TASK_TIMEOUT']
149
- if (raw === undefined || raw === '') return undefined
150
- const n = Number(raw)
151
- if (!Number.isInteger(n) || n <= 0) return undefined
152
- // CLAMPED, not refused — unlike `exec.timeout` and `--timeout`, this rung's
153
- // contract is already "unusable value → fall back", so it never throws. But
154
- // falling back is wrong here: someone who typed a huge number wants a long
155
- // timeout, and the largest expressible one IS long (~24.8 days). Handing it
156
- // to `setTimeout` unbounded would mean 1 ms — killing every task instantly.
157
- return Math.min(n, MAX_TIMEOUT_MS)
158
- }
159
-
160
141
  /**
161
142
  * Compact the 4-axis cache policy into the `invocations.cache_policy`
162
143
  * column string. Each enabled axis contributes its flag; a fully-on
@@ -177,7 +158,7 @@ function compactCachePolicy(p: CachePolicy): string {
177
158
  * local cache writes NOWHERE, yet the write axis reads as on. Normalising
178
159
  * here — the ONE place both `run()` and `planRun()` derive it from — is
179
160
  * what keeps `--dry` describing the run you are about to get: reading the
180
- * raw request made the plan label `--cache=local:,remote:rw` "cache miss —
161
+ * raw request made the plan label `--cache-policy=local:,remote:rw` "cache miss —
181
162
  * would exec" (a result that will be stored) for a run in which caching is
182
163
  * entirely off.
183
164
  */
@@ -196,7 +177,7 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
196
177
  * ingests hits into local for execute-task's lazy probe. The gate asks
197
178
  * the layer (`hasRemote`), not `instanceof LayeredCache` — a
198
179
  * third-party remote layer stalls on the classify just as hard.
199
- * - the policy reads locally (a `--no-cache`/`--force`/`--cache=local:`
180
+ * - the policy reads locally (a `--no-cache`/`--force`/`--cache-policy=local:`
200
181
  * run reads nothing locally → nothing to restore → skip);
201
182
  * - there is at least one task.
202
183
  * A flat graph has no ordering to bypass, but the classify pass is worth
@@ -289,6 +270,12 @@ function refuseRunOptions(options: RunOptions): void {
289
270
  oneOf('outputLogs', options.outputLogs, ['full', 'errors-only', 'none', 'hash-only'])
290
271
  oneOf('download', options.download, ['all', 'toplevel', 'none'])
291
272
  oneOf('flow', options.flow, ['focused', 'broad'])
273
+ oneOf('schedule', options.schedule, [
274
+ 'most-work',
275
+ 'critical-path',
276
+ 'direct-dependents',
277
+ 'ready-order',
278
+ ])
292
279
  const exclude = options.excludeDependencies
293
280
  if (exclude !== undefined && exclude !== 'all' && !names(exclude))
294
281
  refuse('excludeDependencies', exclude, "'all' or an array of task names")
@@ -299,11 +286,15 @@ function refuseRunOptions(options: RunOptions): void {
299
286
  refuse('concurrency', concurrency, 'a positive integer')
300
287
  if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
301
288
  refuse('retries', retries, 'a non-negative integer')
302
- if (
303
- timeout !== undefined &&
304
- !(Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS)
305
- )
306
- refuse('timeout', timeout, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
289
+ for (const [name, ms] of [
290
+ ['timeout', timeout],
291
+ ['killGrace', options.killGrace],
292
+ ['teardownTimeout', options.teardownTimeout],
293
+ ['readyNotice', options.readyNotice],
294
+ ] as const) {
295
+ if (ms !== undefined && !(Number.isInteger(ms) && ms > 0 && ms <= MAX_TIMEOUT_MS))
296
+ refuse(name, ms, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
297
+ }
307
298
  }
308
299
 
309
300
  export async function run(options: RunOptions): Promise<RunSummary> {
@@ -332,6 +323,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
332
323
  coalesce: true,
333
324
  ...(options.tty === true ? { tty: true } : {}),
334
325
  ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
326
+ ...(options.footer === false ? { footer: false } : {}),
335
327
  },
336
328
  )
337
329
  : null
@@ -344,16 +336,26 @@ export async function run(options: RunOptions): Promise<RunSummary> {
344
336
  // Tasks run with colour forced (exec/env.ts); where the terminal prints
345
337
  // plain, their escapes are stripped. A custom logger gets the task's
346
338
  // bytes as written, as the cache holds them: it formats its own output.
347
- const unsubscribeTerminal = bus.subscribe(
348
- terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink)),
349
- )
339
+ const render = terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink))
340
+ let unsubscribeTerminal = bus.subscribe(render)
350
341
  const detach = (): void => {
351
342
  unsubscribeTerminal()
352
343
  terminal?.settle()
353
344
  }
345
+ // A plugin's `output` renders the run from its start; core's renderer
346
+ // stays the fallback. Only core's own renderer is replaceable: an
347
+ // embedder's `log` is never consulted away.
348
+ const takeOutput =
349
+ terminal === null
350
+ ? undefined
351
+ : (claimed: { logger: Logger; owner: string }): void => {
352
+ terminal.settle()
353
+ unsubscribeTerminal()
354
+ unsubscribeTerminal = bus.subscribe(guardedOutput(claimed, render))
355
+ }
354
356
  let held = false
355
357
  try {
356
- const summary = await runOnBus(options, bus, colors, terminal)
358
+ const summary = await runOnBus(options, bus, colors, terminal, takeOutput)
357
359
  if (summary.persistent === undefined) return summary
358
360
  // Servers handed back still running still write: `vx watch dev` printed
359
361
  // none of its server's log while it idled, the renderer gone with this
@@ -377,6 +379,7 @@ async function runOnBus(
377
379
  bus: EventBus,
378
380
  colors: ColorSupport,
379
381
  terminal: DefaultLogger | null,
382
+ takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
380
383
  ): Promise<RunSummary> {
381
384
  const log = busLogger(bus)
382
385
  // A server's output goes through a cell of its own: a later watch cycle
@@ -393,6 +396,7 @@ async function runOnBus(
393
396
  }
394
397
 
395
398
  const prepared = await prepareRun(options, log)
399
+ options = withRunSettings(options, prepared.workspaceConfig)
396
400
  mark('plugin stages')
397
401
  // Every exit from here tears the plugins down: their capability
398
402
  // factories ran in prepareRun, and teardown is what releases what those
@@ -494,14 +498,13 @@ async function runOnBus(
494
498
  cacheDir,
495
499
  cache,
496
500
  nodes,
497
- workspaceFingerprint,
498
501
  fingerprintWatch,
499
502
  nestedDirsByProject,
500
503
  gitFilesCache,
501
504
  hashCache,
502
505
  workspaceProjectCount,
503
506
  } = prepared
504
- const concurrency = options.concurrency ?? workspaceConfig?.concurrency ?? machineParallelism()
507
+ const concurrency = options.concurrency ?? machineParallelism()
505
508
  // An earlier cycle's servers: the ones this graph starts again unchanged
506
509
  // stay up; the others go now, before anything binds their ports. A stop
507
510
  // that lands meanwhile ends the run here, having run nothing: an aborted
@@ -593,7 +596,7 @@ async function runOnBus(
593
596
  }
594
597
  }
595
598
 
596
- // `--download` (default `all`) decides ONCE per task, here, whether a
599
+ // `cache.download` (default `all`) decides ONCE per task, here, whether a
597
600
  // remote execution's outputs come home.
598
601
  const downloadPolicy = options.download ?? 'all'
599
602
  const localPlaced = locallyPlaced(placements, nodes.keys())
@@ -604,12 +607,19 @@ async function runOnBus(
604
607
  remoteOnly: placements.remoteOnly,
605
608
  })
606
609
 
607
- // Run-level default task timeout (ms), applied to any task WITHOUT its own
608
- // `exec.timeout`. Precedence, highest first: `--timeout`/RunOptions.timeout
609
- // → `VX_TASK_TIMEOUT` env → workspace `timeout`. A malformed env value is
610
- // ignored (a bad timeout must not silently disable a task's own limit).
611
- const taskTimeoutDefault =
612
- options.timeout ?? readTaskTimeoutEnv() ?? workspaceConfig?.timeout ?? undefined
610
+ // `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
611
+ const taskTimeoutDefault = options.timeout
612
+ // Read once, before any task: a bad value is refused here, not mid-run.
613
+ const inputStability = {
614
+ run: inputStabilityEnv(),
615
+ ...(workspaceConfig?.cache?.inputStability !== undefined
616
+ ? { workspace: workspaceConfig.cache.inputStability }
617
+ : {}),
618
+ }
619
+
620
+ const cleanOutputs = resolveSetting('cache.outputs.clean', workspaceConfig).value as
621
+ | boolean
622
+ | undefined
613
623
 
614
624
  // Run-scoped registries of live subprocesses:
615
625
  // - `liveChildren`: in-flight children. The runner adds/removes
@@ -698,7 +708,7 @@ async function runOnBus(
698
708
  // bumps are flushed, so a throw between opening the cache and the
699
709
  // normal close leaked the SQLite handle (it matters in a long-lived
700
710
  // host) AND lost this run's touch record, after which an LRU
701
- // `vx cache prune` can evict entries the run just hit. Once-only
711
+ // `vx cache clean` can evict entries the run just hit. Once-only
702
712
  // because the normal path closes before the persistent-task wait —
703
713
  // holding the handle open for a dev server's whole lifetime would be
704
714
  // worse — and because close() re-runs its retention DELETEs.
@@ -739,7 +749,7 @@ async function runOnBus(
739
749
  const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
740
750
  if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
741
751
  log.status(
742
- 'vx: --cache named the remote cache, but no cache plugin in vx.workspace.* supplies one — the remote axes are off for this run',
752
+ 'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
743
753
  )
744
754
  }
745
755
  const deferredOutputs = new DeferredOutputs({
@@ -756,7 +766,11 @@ async function runOnBus(
756
766
  // fails a run). `dirty` reuses the `git status --porcelain` the
757
767
  // GitFilesCache populate already ran for input enumeration — no
758
768
  // second status spawn.
759
- const gitContext = captureGitContext(workspaceRoot, gitFilesCache.worktreeDirty)
769
+ // `run.branch` names the branch over git's (`prepared.branch`).
770
+ const gitContext = {
771
+ ...captureGitContext(workspaceRoot, gitFilesCache.worktreeDirty),
772
+ branch: prepared.branch(),
773
+ }
760
774
  const ciContext = detectCi(process.env)
761
775
  const hostContext = captureHostContext()
762
776
 
@@ -772,7 +786,7 @@ async function runOnBus(
772
786
  let runContextRecord: RunContextRecord | undefined
773
787
  // Gate on a plugin that can actually CONTRIBUTE a sink, not on declaring
774
788
  // any plugin at all: a cache-only plugin has no telemetry hook to
775
- // consult, so paying for the record (2 git spawns + a `.vx/workspace-id`
789
+ // consult, so paying for the record (2 git spawns + a `.vx/cache/workspace-id`
776
790
  // write on a remote-less repo) buys nothing. A plugin that has the hook
777
791
  // but declines still pays — its answer is only knowable by asking.
778
792
  const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
@@ -857,6 +871,13 @@ async function runOnBus(
857
871
  log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
858
872
  )
859
873
  const deferredSaves = new Map<string, Promise<SaveFacts>>()
874
+ const windows = commandWindows()
875
+ // A server held from an earlier run (`vx watch`) runs through this one
876
+ // too, so it may write a mutable input while it is described.
877
+ for (const id of heldBefore.kept.keys()) {
878
+ const node = nodes.get(id)!
879
+ windows.open(id, node.projectDir, commandWriteReach(node, workspaceRoot))
880
+ }
860
881
 
861
882
  // Focused flow: a requested GROUP has no output of its own, so
862
883
  // surface the same-project, non-group tasks it chains (one level)
@@ -895,6 +916,20 @@ async function runOnBus(
895
916
  workspaceProjectCount,
896
917
  }
897
918
 
919
+ // Asked only when a plugin declares `output`: a plain run pays nothing.
920
+ if (takeOutput !== undefined && prepared.plugins.some((p) => p.output !== undefined)) {
921
+ const json = options.json === true
922
+ const claimed = await claimOutput(prepared.plugins, {
923
+ workspaceRoot,
924
+ cacheDir,
925
+ warn: (m: string) => log.status(m),
926
+ isTTY: (json ? process.stderr : process.stdout).isTTY === true,
927
+ ci: ciContext.ci,
928
+ json,
929
+ })
930
+ if (claimed !== undefined) takeOutput(claimed)
931
+ }
932
+
898
933
  // Lifecycle hooks drive the default logger's dynamic status line
899
934
  // (TTY-only); custom loggers may ignore them.
900
935
  log.runStart?.({
@@ -909,7 +944,11 @@ async function runOnBus(
909
944
  terminal?.forecast(async () => {
910
945
  const ids: string[] = []
911
946
  for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
912
- const p50s = await new LocalHistoryProvider(prepared.localCache.dbHandle()).p50sFor(ids)
947
+ const p50s = await new LocalHistoryProvider(
948
+ prepared.localCache.dbHandle(),
949
+ undefined,
950
+ prepared.branch(),
951
+ ).p50sFor(ids)
913
952
  return createForecast(nodes, p50s, concurrency)
914
953
  })
915
954
 
@@ -926,7 +965,7 @@ async function runOnBus(
926
965
  nodes,
927
966
  cache,
928
967
  workspaceRoot,
929
- workspaceFingerprint,
968
+ claimedFiles: fingerprintWatch.names,
930
969
  forwardArgs: options.forwardArgs,
931
970
  nestedDirsByProject,
932
971
  gitFilesCache,
@@ -958,7 +997,7 @@ async function runOnBus(
958
997
  nodes,
959
998
  cache,
960
999
  workspaceRoot,
961
- workspaceFingerprint,
1000
+ claimedFiles: fingerprintWatch.names,
962
1001
  forwardArgs: options.forwardArgs,
963
1002
  nestedDirsByProject,
964
1003
  gitFilesCache,
@@ -1031,7 +1070,6 @@ async function runOnBus(
1031
1070
  node,
1032
1071
  upstream,
1033
1072
  workspaceRoot,
1034
- workspaceFingerprint,
1035
1073
  cache,
1036
1074
  cachePolicy: policy,
1037
1075
  forwardArgs: options.forwardArgs,
@@ -1051,11 +1089,15 @@ async function runOnBus(
1051
1089
  outputDirSnapshots,
1052
1090
  deferSave: saveLane.defer,
1053
1091
  deferredSaves,
1092
+ commandWindows: windows,
1054
1093
  stopSignal: stopRun.signal,
1055
1094
  failFast: failFast.signal,
1056
1095
  }
1057
1096
  if (options.retries !== undefined) a.retries = options.retries
1058
1097
  if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
1098
+ if (options.readyNotice !== undefined) a.readyNotice = options.readyNotice
1099
+ a.inputStability = inputStability
1100
+ if (cleanOutputs !== undefined) a.cleanOutputs = cleanOutputs
1059
1101
  if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
1060
1102
  if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
1061
1103
  if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
@@ -1077,7 +1119,6 @@ async function runOnBus(
1077
1119
  shortCircuit,
1078
1120
  hashArgs: {
1079
1121
  workspaceRoot,
1080
- workspaceFingerprint,
1081
1122
  cache,
1082
1123
  forwardArgs: options.forwardArgs,
1083
1124
  nestedDirsByProject,
@@ -1135,6 +1176,7 @@ async function runOnBus(
1135
1176
  wallclockEndNs: at,
1136
1177
  }
1137
1178
  }
1179
+ let lockBroken: boolean | undefined
1138
1180
  const outcomes = await runGraph({
1139
1181
  nodes,
1140
1182
  concurrency,
@@ -1156,7 +1198,15 @@ async function runOnBus(
1156
1198
  log.taskComplete(o.node, o)
1157
1199
  narrowDemand(o.node.id)
1158
1200
  },
1159
- onError: (node, line) => log.taskStderr(node, line),
1201
+ // A crash a hand-broken lock entry caused is named as that; asked
1202
+ // only here, so a run that does not crash pays nothing.
1203
+ onError: (node, line, err) =>
1204
+ log.taskStderr(
1205
+ node,
1206
+ !isUserError(err) && !isFsRefusal(err) && (lockBroken ??= lockEntryBroken(workspaceRoot))
1207
+ ? `[vx] ${node.id}: ${LOCK_INVALID}\n`
1208
+ : line,
1209
+ ),
1160
1210
  execute: (node, upstream) => {
1161
1211
  const kept = keepServer(node)
1162
1212
  return kept !== undefined
@@ -1182,9 +1232,7 @@ async function runOnBus(
1182
1232
  // baseline as the tie-break. Empty map → baseline only.
1183
1233
  ...(priorities.size > 0 ? { priorities } : {}),
1184
1234
  // The baseline under them; `most-work` when the workspace names none.
1185
- ...(prepared.workspaceConfig?.schedule !== undefined
1186
- ? { strategy: prepared.workspaceConfig.schedule }
1187
- : {}),
1235
+ ...(options.schedule !== undefined ? { strategy: options.schedule } : {}),
1188
1236
  // Local short-circuit: confirmed stable local hits the scheduler
1189
1237
  // runs ahead of their deps as low-priority worker-slot backfill.
1190
1238
  // Empty when the short-circuit didn't fire → byte-identical.
@@ -1276,7 +1324,7 @@ async function runOnBus(
1276
1324
  const stillDeferred = deferredOutputs.pending()
1277
1325
  if (stillDeferred.length > 0) {
1278
1326
  log.status(
1279
- ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
1327
+ ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
1280
1328
  )
1281
1329
  }
1282
1330
 
@@ -1506,7 +1554,8 @@ async function runOnBus(
1506
1554
  if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
1507
1555
  const above = options.beforeFooter?.(list, ok)
1508
1556
  if (above) log.status(above.replace(/\n$/, ''))
1509
- for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1557
+ if (options.footer !== false)
1558
+ for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1510
1559
 
1511
1560
  // Edge case the summary already reported: the user requested a
1512
1561
  // persistent task (dev server / watcher). The run is "done" in every
@@ -1652,18 +1701,16 @@ async function runOnBus(
1652
1701
  }
1653
1702
 
1654
1703
  /**
1655
- * The workspace's `cacheRetention`, after every save and upload of this run
1704
+ * `cache.retention`, after every save and upload of this run
1656
1705
  * has landed (so nothing this run wrote is mid-flight) and before the cache
1657
1706
  * closes. Housekeeping, not the run's work: silent, and a failure is never
1658
1707
  * a failed run. Declared nowhere → one property read.
1659
1708
  */
1660
1709
  async function applyCacheRetention(prepared: PreparedRun): Promise<void> {
1661
- const retention = prepared.workspaceConfig?.cacheRetention
1662
- if (retention === undefined) return
1663
- // Validated at load (`validateRetention`), so both parse.
1664
- const maxAgeMs =
1665
- retention.olderThan === undefined ? undefined : parseDuration(retention.olderThan)!
1666
- const maxBytes = retention.maxSize === undefined ? undefined : parseSize(retention.maxSize)!
1710
+ const ws = prepared.workspaceConfig
1711
+ const maxAgeMs = resolveSetting('cache.retention.olderThan', ws).value as number | undefined
1712
+ const maxBytes = resolveSetting('cache.retention.maxSize', ws).value as number | undefined
1713
+ if (maxAgeMs === undefined && maxBytes === undefined) return
1667
1714
  try {
1668
1715
  await prepared.localCache.evictIfDue({
1669
1716
  ...(maxAgeMs !== undefined ? { maxAgeMs } : {}),
@@ -1708,6 +1755,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1708
1755
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
1709
1756
  const reasons = new Map<string, AffectedReason>()
1710
1757
  const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
1758
+ options = withRunSettings(options, prepared.workspaceConfig)
1711
1759
  try {
1712
1760
  if (prepared.unresolvedTasks.length > 0) {
1713
1761
  return {
@@ -1735,7 +1783,6 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1735
1783
  const planned = await plan({
1736
1784
  nodes: prepared.nodes,
1737
1785
  workspaceRoot: prepared.workspaceRoot,
1738
- workspaceFingerprint: prepared.workspaceFingerprint,
1739
1786
  cache: prepared.cache,
1740
1787
  cachePolicy: policy,
1741
1788
  forwardArgs: options.forwardArgs,
@@ -1747,7 +1794,11 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1747
1794
  // per-task p50s + a predicted wall-clock. `--dry` is an explicit
1748
1795
  // inspection command, so the history read's cost is fine here even
1749
1796
  // though it stays OFF the default run path.
1750
- history: new LocalHistoryProvider(prepared.localCache.dbHandle()),
1797
+ history: new LocalHistoryProvider(
1798
+ prepared.localCache.dbHandle(),
1799
+ undefined,
1800
+ prepared.branch(),
1801
+ ),
1751
1802
  // Placement, by the same rules the run applies. Only worth showing
1752
1803
  // when there is a choice to show — with one declared executor every
1753
1804
  // line would carry the same label. Resolving the executors here is
@@ -1797,7 +1848,7 @@ async function planRestorable(
1797
1848
  nodes,
1798
1849
  cache: prepared.cache,
1799
1850
  workspaceRoot: prepared.workspaceRoot,
1800
- workspaceFingerprint: prepared.workspaceFingerprint,
1851
+ claimedFiles: prepared.fingerprintWatch.names,
1801
1852
  forwardArgs,
1802
1853
  nestedDirsByProject: prepared.nestedDirsByProject,
1803
1854
  gitFilesCache: prepared.gitFilesCache,
@@ -1815,25 +1866,13 @@ function elsewhereHint(names: readonly string[]): string {
1815
1866
  }
1816
1867
 
1817
1868
  /**
1818
- * The first-run case, told apart from a typo: no package in the workspace
1869
+ * The first-run case, told apart from a typo: no project in the workspace
1819
1870
  * has a `vx.config.*` at all, so no name could have resolved. Reached only
1820
- * on the error path. A workspace whose package globs match NOTHING is
1821
- * told that instead — `vx init` would find no package to write for.
1871
+ * on the error path.
1822
1872
  */
1823
- async function initHint(prepared: {
1824
- anyProjectConfig: boolean
1825
- workspaceProjectCount: number
1826
- workspaceRoot: string
1827
- }): Promise<string> {
1873
+ async function initHint(prepared: { anyProjectConfig: boolean }): Promise<string> {
1828
1874
  if (prepared.anyProjectConfig) return ''
1829
- if (prepared.workspaceProjectCount === 0) {
1830
- return " No package matched the workspace's package globs — check `workspaces` in package.json (or pnpm-workspace.yaml)."
1831
- }
1832
- // Single-project mode with packages the root's missing `workspaces`
1833
- // never reaches: `vx init` would find no scripts either (item 248).
1834
- const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
1835
- if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
1836
- return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
1875
+ return ' No directory holds a vx.config — run `vx init` to write them, then `vx lock`.'
1837
1876
  }
1838
1877
 
1839
1878
  /**
@@ -1879,7 +1918,7 @@ function didYouMean(
1879
1918
  // `//` is two edits from any two-letter name: it hinted `ui#build` (X-13).
1880
1919
  if (proj === '//') {
1881
1920
  lists.add(
1882
- "`//` is 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.",
1921
+ '`//` is the workspace root, which is no project here: add a vx.config there with that task and run `vx lock`.',
1883
1922
  )
1884
1923
  continue
1885
1924
  }
@@ -1,5 +1,5 @@
1
1
  // The `exec.sandbox.allow` entries that would have granted what a failed
2
- // task was denied, so the report names the line to add (CLI, JSON, MCP)
2
+ // task was denied, so the report names the line to add (CLI, JSON)
3
3
  // rather than leaving the user to turn absolute paths into grants. Built on
4
4
  // the failure path only: a passing run never calls it.
5
5
 
@@ -9,26 +9,22 @@ import path from 'node:path'
9
9
  import type { SandboxViolation } from '../exec/index.js'
10
10
  import type { TaskOutcome } from '../graph/index.js'
11
11
  import { isLiteralPattern } from '../util/index.js'
12
+ import { CREDENTIAL_STORES } from './sandbox-request.js'
12
13
 
13
14
  /** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
14
15
  type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
15
- type Kind = Exclude<keyof SandboxFix, 'withheld'>
16
-
17
- // Where credentials live. A denial here is never turned into a grant: an
18
- // agent applies `sandboxFix` as given, so a dependency probing ~/.ssh would
19
- // get its grant suggested and applied (Security, 2026-10-12).
20
- const CREDENTIAL_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.config/gh', '.config/gcloud']
21
- const CREDENTIAL_FILES = ['.netrc', '.npmrc', '.docker/config.json', '.git-credentials', '.pypirc']
16
+ type Kind = Exclude<keyof SandboxFix, 'withheld' | 'inputs'>
22
17
 
18
+ // A denial in a credential store is never turned into a grant: an agent
19
+ // applies `sandboxFix` as given, so a dependency probing ~/.ssh would get
20
+ // its grant suggested and applied (Security, 2026-10-12). The stores are
21
+ // the ones the sandbox denies: a hand copy here missed half of them.
23
22
  function isCredential(abs: string): boolean {
24
23
  const base = path.basename(abs)
25
24
  if (base === '.env' || base.startsWith('.env.')) return true
26
25
  const rel = path.relative(os.homedir(), abs)
27
26
  if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
28
- return (
29
- CREDENTIAL_FILES.includes(rel) ||
30
- CREDENTIAL_DIRS.some((d) => rel === d || rel.startsWith(d + path.sep))
31
- )
27
+ return CREDENTIAL_STORES.some((d) => rel === d || rel.startsWith(d + path.sep))
32
28
  }
33
29
 
34
30
  /**
@@ -63,6 +59,16 @@ function readsDir(g: string, top: string): boolean {
63
59
  return first === top || !isLiteralPattern(first)
64
60
  }
65
61
 
62
+ const inProject = (grant: string): boolean => !grant.startsWith('~/') && !path.isAbsolute(grant)
63
+
64
+ /** Whether a project-relative path is one the task's input globs match. */
65
+ function keyedBy(globs: readonly string[]): (rel: string) => boolean {
66
+ const strip = (g: string) => g.replace(/^\.\//, '')
67
+ const take = globs.filter((g) => !g.startsWith('!')).map((g) => new Bun.Glob(strip(g)))
68
+ const drop = globs.filter((g) => g.startsWith('!')).map((g) => new Bun.Glob(strip(g.slice(1))))
69
+ return (rel) => take.some((g) => g.match(rel)) && !drop.some((g) => g.match(rel))
70
+ }
71
+
66
72
  function kindOf(v: SandboxViolation): Kind | undefined {
67
73
  const can = v.ignorable ?? []
68
74
  if (can.length === 1) return can[0]
@@ -78,16 +84,22 @@ export function sandboxFix(
78
84
  violations: readonly SandboxViolation[],
79
85
  projectDir: string,
80
86
  mask: (text: string) => string = (t) => t,
81
- /** The task's `cache.inputs.files`: a write under one is granted exactly. */
82
- inputs: readonly string[] = [],
87
+ /**
88
+ * The task's `cache.inputs.files`, undefined when it does not cache: a
89
+ * write under one is granted exactly, and a read of the project no glob
90
+ * matches is named for the key too.
91
+ */
92
+ inputs?: readonly string[],
83
93
  ): SandboxFix | undefined {
84
- const fix: Record<Kind | 'withheld', Set<string>> = {
94
+ const fix: Record<Kind | 'withheld' | 'inputs', Set<string>> = {
85
95
  read: new Set(),
86
96
  write: new Set(),
87
97
  network: new Set(),
88
98
  systemInfo: new Set(),
89
99
  withheld: new Set(),
100
+ inputs: new Set(),
90
101
  }
102
+ const keyed = inputs === undefined ? undefined : keyedBy(inputs)
91
103
  for (const v of violations) {
92
104
  if (v.hint === true || v.target === undefined) continue
93
105
  const kind = kindOf(v)
@@ -96,16 +108,19 @@ export function sandboxFix(
96
108
  else if (kind === 'systemInfo') fix.systemInfo.add(v.target)
97
109
  else if (v.path === undefined) continue
98
110
  else if (isCredential(v.path)) fix.withheld.add(grantPath(v.path, projectDir))
99
- else if (kind === 'read') fix.read.add(grantPath(v.path, projectDir))
100
- else {
111
+ else if (kind === 'read') {
112
+ const grant = grantPath(v.path, projectDir)
113
+ fix.read.add(grant)
114
+ if (keyed !== undefined && inProject(grant) && !keyed(grant)) fix.inputs.add(grant)
115
+ } else {
101
116
  const isDir =
102
117
  v.line.startsWith('mkdir') ||
103
118
  lstatSync(v.path, { throwIfNoEntry: false })?.isDirectory() === true
104
- fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs))
119
+ fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs ?? []))
105
120
  }
106
121
  }
107
122
  const out: SandboxFix = {}
108
- for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld'] as const)
123
+ for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld', 'inputs'] as const)
109
124
  if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
110
125
  return Object.keys(out).length > 0 ? out : undefined
111
126
  }
@@ -114,7 +129,11 @@ export function sandboxFix(
114
129
  * The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
115
130
  * or undefined when every denial was a withheld credential path.
116
131
  */
117
- export function sandboxFixLine({ withheld: _, ...fix }: SandboxFix): string | undefined {
132
+ export function sandboxFixLine({
133
+ withheld: _,
134
+ inputs: __,
135
+ ...fix
136
+ }: SandboxFix): string | undefined {
118
137
  if (Object.keys(fix).length === 0) return undefined
119
138
  const parts = Object.entries(fix).map(
120
139
  ([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,