@vzn/vx 0.0.636 → 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 +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  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 +5 -3
  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 -63
  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 +103 -149
  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 +169 -70
  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 +24 -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 +158 -63
  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 +42 -29
  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 +113 -72
  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 +121 -95
  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 +209 -268
  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 +20 -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,12 +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 {
7
- inputStabilityEnv,
8
- loadWorkspace,
9
- unreachedHint,
10
- unreachedPackages,
11
- } from '../workspace/index.js'
6
+ import { inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
12
7
  import { realpathSync } from 'node:fs'
13
8
  import path from 'node:path'
14
9
  import {
@@ -42,8 +37,6 @@ import {
42
37
  stageTimes,
43
38
  killGraceMs,
44
39
  MAX_TIMEOUT_MS,
45
- parseDuration,
46
- parseSize,
47
40
  printTimings,
48
41
  ulid,
49
42
  isLiteralPattern,
@@ -54,10 +47,11 @@ import {
54
47
  teardownTimeoutMs,
55
48
  secretMask,
56
49
  isFsRefusal,
50
+ isUserError,
57
51
  } from '../util/index.js'
58
52
  import { keyedProjects } from './keyed-projects.js'
59
53
  import { isDefaultBuild } from './projects.js'
60
- import { prepareSandbox } from './sandbox-request.js'
54
+ import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
61
55
  import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
62
56
  import { admitTasks, taintTracker } from './admission.js'
63
57
  import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
@@ -67,6 +61,7 @@ import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './
67
61
  import { installPlugins, PluginSetupError } from './plugin.js'
68
62
  import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from './plugin-host.js'
69
63
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
64
+ import { claimOutput, guardedOutput } from './output-host.js'
70
65
  import { assembleRunSummary, isPassStatus } from './telemetry.js'
71
66
  import type { RunContextRecord } from './telemetry.js'
72
67
  import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
@@ -126,10 +121,13 @@ import {
126
121
  type RunSummaryJson,
127
122
  } from './run-artifacts.js'
128
123
  import { createSaveLane } from './save-lane.js'
124
+ import { commandWindows } from './command-windows.js'
129
125
  import { formatOutcomeTable, formatRunSummary } from './summary.js'
130
126
  import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
131
127
  import { createMissExplainer } from './miss-reason.js'
132
128
  import type { HeldServer, RunOptions, RunSummary } from './options.js'
129
+ import { withRunSettings } from './run-settings.js'
130
+ import { resolveSetting } from '../settings.js'
133
131
 
134
132
  // Per run, never shared: a `vx watch` process runs many, and a shared map
135
133
  // is one `preProbed.set` away from leaking a hit across cycles.
@@ -140,28 +138,6 @@ const emptyShortCircuit = (): ShortCircuit => ({
140
138
  groupKeys: new Map(),
141
139
  })
142
140
 
143
- /**
144
- * Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
145
- * timeout default. A missing/empty/non-positive-integer value yields
146
- * `undefined` (ignored), so a typo never silently disables a task's own
147
- * `exec.timeout`. A value past `MAX_TIMEOUT_MS` is clamped to it — see below.
148
- *
149
- * Exported for `tests/options-resolve.test.ts`, which pins every accepted
150
- * and ignored form of this rung; it has no other caller outside this file.
151
- */
152
- export function readTaskTimeoutEnv(): number | undefined {
153
- const raw = process.env['VX_TASK_TIMEOUT']
154
- if (raw === undefined || raw === '') return undefined
155
- const n = Number(raw)
156
- if (!Number.isInteger(n) || n <= 0) return undefined
157
- // CLAMPED, not refused — unlike `exec.timeout` and `--timeout`, this rung's
158
- // contract is already "unusable value → fall back", so it never throws. But
159
- // falling back is wrong here: someone who typed a huge number wants a long
160
- // timeout, and the largest expressible one IS long (~24.8 days). Handing it
161
- // to `setTimeout` unbounded would mean 1 ms — killing every task instantly.
162
- return Math.min(n, MAX_TIMEOUT_MS)
163
- }
164
-
165
141
  /**
166
142
  * Compact the 4-axis cache policy into the `invocations.cache_policy`
167
143
  * column string. Each enabled axis contributes its flag; a fully-on
@@ -182,7 +158,7 @@ function compactCachePolicy(p: CachePolicy): string {
182
158
  * local cache writes NOWHERE, yet the write axis reads as on. Normalising
183
159
  * here — the ONE place both `run()` and `planRun()` derive it from — is
184
160
  * what keeps `--dry` describing the run you are about to get: reading the
185
- * 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 —
186
162
  * would exec" (a result that will be stored) for a run in which caching is
187
163
  * entirely off.
188
164
  */
@@ -201,7 +177,7 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
201
177
  * ingests hits into local for execute-task's lazy probe. The gate asks
202
178
  * the layer (`hasRemote`), not `instanceof LayeredCache` — a
203
179
  * third-party remote layer stalls on the classify just as hard.
204
- * - the policy reads locally (a `--no-cache`/`--force`/`--cache=local:`
180
+ * - the policy reads locally (a `--no-cache`/`--force`/`--cache-policy=local:`
205
181
  * run reads nothing locally → nothing to restore → skip);
206
182
  * - there is at least one task.
207
183
  * A flat graph has no ordering to bypass, but the classify pass is worth
@@ -294,6 +270,12 @@ function refuseRunOptions(options: RunOptions): void {
294
270
  oneOf('outputLogs', options.outputLogs, ['full', 'errors-only', 'none', 'hash-only'])
295
271
  oneOf('download', options.download, ['all', 'toplevel', 'none'])
296
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
+ ])
297
279
  const exclude = options.excludeDependencies
298
280
  if (exclude !== undefined && exclude !== 'all' && !names(exclude))
299
281
  refuse('excludeDependencies', exclude, "'all' or an array of task names")
@@ -304,11 +286,15 @@ function refuseRunOptions(options: RunOptions): void {
304
286
  refuse('concurrency', concurrency, 'a positive integer')
305
287
  if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
306
288
  refuse('retries', retries, 'a non-negative integer')
307
- if (
308
- timeout !== undefined &&
309
- !(Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS)
310
- )
311
- 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
+ }
312
298
  }
313
299
 
314
300
  export async function run(options: RunOptions): Promise<RunSummary> {
@@ -337,6 +323,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
337
323
  coalesce: true,
338
324
  ...(options.tty === true ? { tty: true } : {}),
339
325
  ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
326
+ ...(options.footer === false ? { footer: false } : {}),
340
327
  },
341
328
  )
342
329
  : null
@@ -349,16 +336,26 @@ export async function run(options: RunOptions): Promise<RunSummary> {
349
336
  // Tasks run with colour forced (exec/env.ts); where the terminal prints
350
337
  // plain, their escapes are stripped. A custom logger gets the task's
351
338
  // bytes as written, as the cache holds them: it formats its own output.
352
- const unsubscribeTerminal = bus.subscribe(
353
- terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink)),
354
- )
339
+ const render = terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink))
340
+ let unsubscribeTerminal = bus.subscribe(render)
355
341
  const detach = (): void => {
356
342
  unsubscribeTerminal()
357
343
  terminal?.settle()
358
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
+ }
359
356
  let held = false
360
357
  try {
361
- const summary = await runOnBus(options, bus, colors, terminal)
358
+ const summary = await runOnBus(options, bus, colors, terminal, takeOutput)
362
359
  if (summary.persistent === undefined) return summary
363
360
  // Servers handed back still running still write: `vx watch dev` printed
364
361
  // none of its server's log while it idled, the renderer gone with this
@@ -382,6 +379,7 @@ async function runOnBus(
382
379
  bus: EventBus,
383
380
  colors: ColorSupport,
384
381
  terminal: DefaultLogger | null,
382
+ takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
385
383
  ): Promise<RunSummary> {
386
384
  const log = busLogger(bus)
387
385
  // A server's output goes through a cell of its own: a later watch cycle
@@ -398,6 +396,7 @@ async function runOnBus(
398
396
  }
399
397
 
400
398
  const prepared = await prepareRun(options, log)
399
+ options = withRunSettings(options, prepared.workspaceConfig)
401
400
  mark('plugin stages')
402
401
  // Every exit from here tears the plugins down: their capability
403
402
  // factories ran in prepareRun, and teardown is what releases what those
@@ -499,14 +498,13 @@ async function runOnBus(
499
498
  cacheDir,
500
499
  cache,
501
500
  nodes,
502
- workspaceFingerprint,
503
501
  fingerprintWatch,
504
502
  nestedDirsByProject,
505
503
  gitFilesCache,
506
504
  hashCache,
507
505
  workspaceProjectCount,
508
506
  } = prepared
509
- const concurrency = options.concurrency ?? workspaceConfig?.concurrency ?? machineParallelism()
507
+ const concurrency = options.concurrency ?? machineParallelism()
510
508
  // An earlier cycle's servers: the ones this graph starts again unchanged
511
509
  // stay up; the others go now, before anything binds their ports. A stop
512
510
  // that lands meanwhile ends the run here, having run nothing: an aborted
@@ -598,7 +596,7 @@ async function runOnBus(
598
596
  }
599
597
  }
600
598
 
601
- // `--download` (default `all`) decides ONCE per task, here, whether a
599
+ // `cache.download` (default `all`) decides ONCE per task, here, whether a
602
600
  // remote execution's outputs come home.
603
601
  const downloadPolicy = options.download ?? 'all'
604
602
  const localPlaced = locallyPlaced(placements, nodes.keys())
@@ -609,20 +607,20 @@ async function runOnBus(
609
607
  remoteOnly: placements.remoteOnly,
610
608
  })
611
609
 
612
- // Run-level default task timeout (ms), applied to any task WITHOUT its own
613
- // `exec.timeout`. Precedence, highest first: `--timeout`/RunOptions.timeout
614
- // → `VX_TASK_TIMEOUT` env → workspace `timeout`. A malformed env value is
615
- // ignored (a bad timeout must not silently disable a task's own limit).
616
- const taskTimeoutDefault =
617
- options.timeout ?? readTaskTimeoutEnv() ?? workspaceConfig?.timeout ?? undefined
610
+ // `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
611
+ const taskTimeoutDefault = options.timeout
618
612
  // Read once, before any task: a bad value is refused here, not mid-run.
619
613
  const inputStability = {
620
614
  run: inputStabilityEnv(),
621
- ...(workspaceConfig?.inputStability !== undefined
622
- ? { workspace: workspaceConfig.inputStability }
615
+ ...(workspaceConfig?.cache?.inputStability !== undefined
616
+ ? { workspace: workspaceConfig.cache.inputStability }
623
617
  : {}),
624
618
  }
625
619
 
620
+ const cleanOutputs = resolveSetting('cache.outputs.clean', workspaceConfig).value as
621
+ | boolean
622
+ | undefined
623
+
626
624
  // Run-scoped registries of live subprocesses:
627
625
  // - `liveChildren`: in-flight children. The runner adds/removes
628
626
  // each child around its spawn (persistent children stay until
@@ -710,7 +708,7 @@ async function runOnBus(
710
708
  // bumps are flushed, so a throw between opening the cache and the
711
709
  // normal close leaked the SQLite handle (it matters in a long-lived
712
710
  // host) AND lost this run's touch record, after which an LRU
713
- // `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
714
712
  // because the normal path closes before the persistent-task wait —
715
713
  // holding the handle open for a dev server's whole lifetime would be
716
714
  // worse — and because close() re-runs its retention DELETEs.
@@ -751,7 +749,7 @@ async function runOnBus(
751
749
  const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
752
750
  if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
753
751
  log.status(
754
- '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',
755
753
  )
756
754
  }
757
755
  const deferredOutputs = new DeferredOutputs({
@@ -768,7 +766,11 @@ async function runOnBus(
768
766
  // fails a run). `dirty` reuses the `git status --porcelain` the
769
767
  // GitFilesCache populate already ran for input enumeration — no
770
768
  // second status spawn.
771
- 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
+ }
772
774
  const ciContext = detectCi(process.env)
773
775
  const hostContext = captureHostContext()
774
776
 
@@ -784,7 +786,7 @@ async function runOnBus(
784
786
  let runContextRecord: RunContextRecord | undefined
785
787
  // Gate on a plugin that can actually CONTRIBUTE a sink, not on declaring
786
788
  // any plugin at all: a cache-only plugin has no telemetry hook to
787
- // 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`
788
790
  // write on a remote-less repo) buys nothing. A plugin that has the hook
789
791
  // but declines still pays — its answer is only knowable by asking.
790
792
  const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
@@ -869,6 +871,13 @@ async function runOnBus(
869
871
  log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
870
872
  )
871
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
+ }
872
881
 
873
882
  // Focused flow: a requested GROUP has no output of its own, so
874
883
  // surface the same-project, non-group tasks it chains (one level)
@@ -907,6 +916,20 @@ async function runOnBus(
907
916
  workspaceProjectCount,
908
917
  }
909
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
+
910
933
  // Lifecycle hooks drive the default logger's dynamic status line
911
934
  // (TTY-only); custom loggers may ignore them.
912
935
  log.runStart?.({
@@ -921,7 +944,11 @@ async function runOnBus(
921
944
  terminal?.forecast(async () => {
922
945
  const ids: string[] = []
923
946
  for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
924
- 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)
925
952
  return createForecast(nodes, p50s, concurrency)
926
953
  })
927
954
 
@@ -938,7 +965,7 @@ async function runOnBus(
938
965
  nodes,
939
966
  cache,
940
967
  workspaceRoot,
941
- workspaceFingerprint,
968
+ claimedFiles: fingerprintWatch.names,
942
969
  forwardArgs: options.forwardArgs,
943
970
  nestedDirsByProject,
944
971
  gitFilesCache,
@@ -970,7 +997,7 @@ async function runOnBus(
970
997
  nodes,
971
998
  cache,
972
999
  workspaceRoot,
973
- workspaceFingerprint,
1000
+ claimedFiles: fingerprintWatch.names,
974
1001
  forwardArgs: options.forwardArgs,
975
1002
  nestedDirsByProject,
976
1003
  gitFilesCache,
@@ -1043,7 +1070,6 @@ async function runOnBus(
1043
1070
  node,
1044
1071
  upstream,
1045
1072
  workspaceRoot,
1046
- workspaceFingerprint,
1047
1073
  cache,
1048
1074
  cachePolicy: policy,
1049
1075
  forwardArgs: options.forwardArgs,
@@ -1063,12 +1089,15 @@ async function runOnBus(
1063
1089
  outputDirSnapshots,
1064
1090
  deferSave: saveLane.defer,
1065
1091
  deferredSaves,
1092
+ commandWindows: windows,
1066
1093
  stopSignal: stopRun.signal,
1067
1094
  failFast: failFast.signal,
1068
1095
  }
1069
1096
  if (options.retries !== undefined) a.retries = options.retries
1070
1097
  if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
1098
+ if (options.readyNotice !== undefined) a.readyNotice = options.readyNotice
1071
1099
  a.inputStability = inputStability
1100
+ if (cleanOutputs !== undefined) a.cleanOutputs = cleanOutputs
1072
1101
  if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
1073
1102
  if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
1074
1103
  if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
@@ -1090,7 +1119,6 @@ async function runOnBus(
1090
1119
  shortCircuit,
1091
1120
  hashArgs: {
1092
1121
  workspaceRoot,
1093
- workspaceFingerprint,
1094
1122
  cache,
1095
1123
  forwardArgs: options.forwardArgs,
1096
1124
  nestedDirsByProject,
@@ -1148,6 +1176,7 @@ async function runOnBus(
1148
1176
  wallclockEndNs: at,
1149
1177
  }
1150
1178
  }
1179
+ let lockBroken: boolean | undefined
1151
1180
  const outcomes = await runGraph({
1152
1181
  nodes,
1153
1182
  concurrency,
@@ -1169,7 +1198,15 @@ async function runOnBus(
1169
1198
  log.taskComplete(o.node, o)
1170
1199
  narrowDemand(o.node.id)
1171
1200
  },
1172
- 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
+ ),
1173
1210
  execute: (node, upstream) => {
1174
1211
  const kept = keepServer(node)
1175
1212
  return kept !== undefined
@@ -1195,9 +1232,7 @@ async function runOnBus(
1195
1232
  // baseline as the tie-break. Empty map → baseline only.
1196
1233
  ...(priorities.size > 0 ? { priorities } : {}),
1197
1234
  // The baseline under them; `most-work` when the workspace names none.
1198
- ...(prepared.workspaceConfig?.schedule !== undefined
1199
- ? { strategy: prepared.workspaceConfig.schedule }
1200
- : {}),
1235
+ ...(options.schedule !== undefined ? { strategy: options.schedule } : {}),
1201
1236
  // Local short-circuit: confirmed stable local hits the scheduler
1202
1237
  // runs ahead of their deps as low-priority worker-slot backfill.
1203
1238
  // Empty when the short-circuit didn't fire → byte-identical.
@@ -1289,7 +1324,7 @@ async function runOnBus(
1289
1324
  const stillDeferred = deferredOutputs.pending()
1290
1325
  if (stillDeferred.length > 0) {
1291
1326
  log.status(
1292
- ` 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(', ')}`,
1293
1328
  )
1294
1329
  }
1295
1330
 
@@ -1519,7 +1554,8 @@ async function runOnBus(
1519
1554
  if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
1520
1555
  const above = options.beforeFooter?.(list, ok)
1521
1556
  if (above) log.status(above.replace(/\n$/, ''))
1522
- 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)
1523
1559
 
1524
1560
  // Edge case the summary already reported: the user requested a
1525
1561
  // persistent task (dev server / watcher). The run is "done" in every
@@ -1665,18 +1701,16 @@ async function runOnBus(
1665
1701
  }
1666
1702
 
1667
1703
  /**
1668
- * The workspace's `cacheRetention`, after every save and upload of this run
1704
+ * `cache.retention`, after every save and upload of this run
1669
1705
  * has landed (so nothing this run wrote is mid-flight) and before the cache
1670
1706
  * closes. Housekeeping, not the run's work: silent, and a failure is never
1671
1707
  * a failed run. Declared nowhere → one property read.
1672
1708
  */
1673
1709
  async function applyCacheRetention(prepared: PreparedRun): Promise<void> {
1674
- const retention = prepared.workspaceConfig?.cacheRetention
1675
- if (retention === undefined) return
1676
- // Validated at load (`validateRetention`), so both parse.
1677
- const maxAgeMs =
1678
- retention.olderThan === undefined ? undefined : parseDuration(retention.olderThan)!
1679
- 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
1680
1714
  try {
1681
1715
  await prepared.localCache.evictIfDue({
1682
1716
  ...(maxAgeMs !== undefined ? { maxAgeMs } : {}),
@@ -1721,6 +1755,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1721
1755
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
1722
1756
  const reasons = new Map<string, AffectedReason>()
1723
1757
  const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
1758
+ options = withRunSettings(options, prepared.workspaceConfig)
1724
1759
  try {
1725
1760
  if (prepared.unresolvedTasks.length > 0) {
1726
1761
  return {
@@ -1748,7 +1783,6 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1748
1783
  const planned = await plan({
1749
1784
  nodes: prepared.nodes,
1750
1785
  workspaceRoot: prepared.workspaceRoot,
1751
- workspaceFingerprint: prepared.workspaceFingerprint,
1752
1786
  cache: prepared.cache,
1753
1787
  cachePolicy: policy,
1754
1788
  forwardArgs: options.forwardArgs,
@@ -1760,7 +1794,11 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1760
1794
  // per-task p50s + a predicted wall-clock. `--dry` is an explicit
1761
1795
  // inspection command, so the history read's cost is fine here even
1762
1796
  // though it stays OFF the default run path.
1763
- history: new LocalHistoryProvider(prepared.localCache.dbHandle()),
1797
+ history: new LocalHistoryProvider(
1798
+ prepared.localCache.dbHandle(),
1799
+ undefined,
1800
+ prepared.branch(),
1801
+ ),
1764
1802
  // Placement, by the same rules the run applies. Only worth showing
1765
1803
  // when there is a choice to show — with one declared executor every
1766
1804
  // line would carry the same label. Resolving the executors here is
@@ -1810,7 +1848,7 @@ async function planRestorable(
1810
1848
  nodes,
1811
1849
  cache: prepared.cache,
1812
1850
  workspaceRoot: prepared.workspaceRoot,
1813
- workspaceFingerprint: prepared.workspaceFingerprint,
1851
+ claimedFiles: prepared.fingerprintWatch.names,
1814
1852
  forwardArgs,
1815
1853
  nestedDirsByProject: prepared.nestedDirsByProject,
1816
1854
  gitFilesCache: prepared.gitFilesCache,
@@ -1828,25 +1866,13 @@ function elsewhereHint(names: readonly string[]): string {
1828
1866
  }
1829
1867
 
1830
1868
  /**
1831
- * 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
1832
1870
  * has a `vx.config.*` at all, so no name could have resolved. Reached only
1833
- * on the error path. A workspace whose package globs match NOTHING is
1834
- * told that instead — `vx init` would find no package to write for.
1871
+ * on the error path.
1835
1872
  */
1836
- async function initHint(prepared: {
1837
- anyProjectConfig: boolean
1838
- workspaceProjectCount: number
1839
- workspaceRoot: string
1840
- }): Promise<string> {
1873
+ async function initHint(prepared: { anyProjectConfig: boolean }): Promise<string> {
1841
1874
  if (prepared.anyProjectConfig) return ''
1842
- if (prepared.workspaceProjectCount === 0) {
1843
- return " No package matched the workspace's package globs — check `workspaces` in package.json (or pnpm-workspace.yaml)."
1844
- }
1845
- // Single-project mode with packages the root's missing `workspaces`
1846
- // never reaches: `vx init` would find no scripts either (item 248).
1847
- const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
1848
- if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
1849
- 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`.'
1850
1876
  }
1851
1877
 
1852
1878
  /**
@@ -1892,7 +1918,7 @@ function didYouMean(
1892
1918
  // `//` is two edits from any two-letter name: it hinted `ui#build` (X-13).
1893
1919
  if (proj === '//') {
1894
1920
  lists.add(
1895
- "`//` 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`.',
1896
1922
  )
1897
1923
  continue
1898
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(', ')}]`,