@vzn/vx 0.0.641 → 0.0.643

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 (77) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. package/src/cli/docs-text.d.ts +0 -5
@@ -7,9 +7,10 @@
7
7
  // closes at the bottom of execution, `planRun()` does so via
8
8
  // try/finally around its plan() call.
9
9
 
10
+ import { existsSync } from 'node:fs'
10
11
  import path from 'node:path'
11
12
  import type { WorkspaceConfig } from '../config.js'
12
- import { beginRun, mark, nearest, UserError } from '../util/index.js'
13
+ import { beginRun, mark, nearest, relPosix, type SecretBroker, UserError } from '../util/index.js'
13
14
  import {
14
15
  Cache,
15
16
  type CacheLayer,
@@ -25,7 +26,7 @@ import {
25
26
  writesRunStore,
26
27
  type PluginCache,
27
28
  type ResolvedRemote,
28
- ScopedCache,
29
+ BranchScope,
29
30
  applyGitEnumeration,
30
31
  gitPathspecs,
31
32
  MAX_SCOPED_PATHSPECS,
@@ -37,9 +38,11 @@ import {
37
38
  buildPackageGraph,
38
39
  computeNestedProjectDirs,
39
40
  findWorkspaceRoot,
40
- cacheScopeEnvError,
41
- isCacheScope,
41
+ type AffectedChanges,
42
+ branchChanges,
43
+ configuredBaseBranch,
42
44
  type LoadReads,
45
+ PROJECT_CONFIG_FILENAMES,
43
46
  readClaimedFiles,
44
47
  resolveCacheDir,
45
48
  resolveStoreRoot,
@@ -76,13 +79,13 @@ import {
76
79
  lockReader,
77
80
  type LoadedProjects,
78
81
  } from './projects.js'
79
- import { keptByAffected } from './affected-tasks.js'
82
+ import { keptByAffected, keyedByChange } from './affected-tasks.js'
80
83
  import { applyGraphStage } from './graph-stage.js'
81
84
  import { keyExcludedDependencies } from './excluded-keys.js'
82
85
  import { FingerprintWatch } from './fingerprint-watch.js'
83
86
  import type { VxPlugin } from './plugin.js'
84
87
  import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
85
- import { currentBranch } from './run-context.js'
88
+ import { currentBranch, resolveCacheScope } from './run-context.js'
86
89
  import { createHashCache, type HashCache } from './task-hash.js'
87
90
  import { type Logger, warnOf } from '../report/index.js'
88
91
  import type { RunOptions } from './options.js'
@@ -111,6 +114,10 @@ export interface PreparedRun {
111
114
  hasRemoteLayer: boolean
112
115
  /** `--cache-policy` with the workspace's `cacheScope` applied: what the layers were built under. */
113
116
  cachePolicy: CachePolicy
117
+ /** The scope this run's cache reads and writes, when one is set; named on the summary's `info` row. */
118
+ cacheScope: string | undefined
119
+ /** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
120
+ remotes: ResolvedRemote[]
114
121
  /**
115
122
  * Scheduling priorities from plugins' `schedule` stage (task id → weight,
116
123
  * merged over the structural baseline by the scheduler). Empty when no
@@ -245,8 +252,21 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
245
252
  (reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
246
253
  lazyGitEnumeration(workspaceRoot)
247
254
  if (wholeTree) void git.start()
248
- const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warnOf(log))
255
+ const {
256
+ workspaceConfig,
257
+ plugins,
258
+ branch: branchFacts,
259
+ } = await loadWorkspacePlugins(workspaceRoot, warnOf(log))
249
260
  mark('workspace config')
261
+ const baseBranch = configuredBaseBranch(workspaceConfig)
262
+ const cacheScope =
263
+ options.cacheScope ??
264
+ resolveCacheScope(
265
+ workspaceRoot,
266
+ branchFacts.scope,
267
+ plugins.some((p) => p.cache !== undefined),
268
+ baseBranch,
269
+ )
250
270
  let learnedBranch: string | null | undefined
251
271
  const branch = (): string | null =>
252
272
  learnedBranch === undefined
@@ -265,10 +285,38 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
265
285
  // Asked now, read when the cache opens: a repository with no remote is
266
286
  // named by its root commit, a git spawn the discovery below overlaps.
267
287
  const storeRoot = options.cacheDir ? null : resolveStoreRoot(workspaceRoot, workspaceConfig)
268
- const projectMetas =
288
+ const discovering =
269
289
  reused !== undefined
270
- ? reused.projects
271
- : await discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
290
+ ? Promise.resolve(reused.projects)
291
+ : discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
292
+ // What the branch changed decides which tasks read the scope (owner,
293
+ // 2026-10-17). Only a scoped run's first remote call asks git: a run the
294
+ // local cache answers makes no remote call, and diffing beside discovery
295
+ // cost it 20-60 ms at 1k-5k projects.
296
+ const scopeChanges: (() => Promise<AffectedChanges>) | undefined =
297
+ cacheScope === undefined
298
+ ? undefined
299
+ : options.scopeChanges !== undefined
300
+ ? async () => options.scopeChanges!
301
+ : () =>
302
+ branchChanges(
303
+ workspaceRoot,
304
+ options.scopeBase ?? branchFacts.base,
305
+ baseBranch,
306
+ discovering,
307
+ {
308
+ untracked: async () => (await git.start()).untracked,
309
+ // A scoped enumeration lists only its projects: null asks git.
310
+ links: async () => {
311
+ const g = await git.start()
312
+ if (!g.scopes.includes('.')) return null
313
+ const out: string[] = []
314
+ for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
315
+ return out
316
+ },
317
+ },
318
+ )
319
+ const projectMetas = await discovering
272
320
  mark('discover projects')
273
321
 
274
322
  // SCOPED config loading: configs are programs, and evaluating 1090
@@ -310,12 +358,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
310
358
  // so a reader profiling that stage was sent to the wrong code.
311
359
  mark('package graph')
312
360
 
313
- // `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
314
- // a CI job that knows its trust level says so without editing config.
315
- const envScope = process.env['VX_CACHE_SCOPE'] || undefined
316
- if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
317
361
  const workspacePolicy = workspaceCachePolicy(workspaceConfig)
318
- const cacheScope = options.cacheScope ?? envScope ?? workspaceConfig?.cache?.scope
319
362
  const asked =
320
363
  options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
321
364
  // A laptop reads the shared cache and does not write it unless a policy
@@ -325,16 +368,18 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
325
368
  const readOnly =
326
369
  options.remoteReadOnlyByDefault === true &&
327
370
  !(options.cache === undefined && namesRemote(workspaceConfig?.cache?.policy))
328
- const policy: CachePolicy = readOnly ? { ...asked, remoteWrite: false } : asked
371
+ const wanted: CachePolicy = readOnly ? { ...asked, remoteWrite: false } : asked
329
372
  const localCache = new Cache(
330
373
  cacheDir,
331
- { read: policy.localRead, write: policy.localWrite },
374
+ { read: wanted.localRead, write: wanted.localWrite },
332
375
  workspaceRoot,
333
376
  options.artifactCeiling,
334
377
  'open',
335
378
  await storeRoot,
336
379
  )
337
- localCache.assertWritable()
380
+ const unwritable = localCache.readOnlyNotice()
381
+ if (unwritable !== null) warnOf(log)(`[vx] ${unwritable}`)
382
+ const policy: CachePolicy = unwritable === null ? wanted : { ...wanted, localWrite: false }
338
383
  // The root files the plugins claim (`VxPlugin.fingerprint`), whose bytes
339
384
  // their claimants fold: read now so a task that rewrites one is caught.
340
385
  const fingerprintsAt = Date.now()
@@ -405,6 +450,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
405
450
  // A dependant's `^build` still reaches it, and `lib#build` names it.
406
451
  const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
407
452
  let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
453
+ refuseUnlockedConfig(tasks, projects, projectMetas, workspaceRoot)
408
454
  if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
409
455
  // A name a loaded project declares is no typo, whether or not the load
410
456
  // was whole: an unaffected dependency loaded for the closure declared
@@ -463,6 +509,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
463
509
  // cache plugin.
464
510
  let cache: CacheLayer
465
511
  const remotes: ResolvedRemote[] = []
512
+ // A scope only picks remote keys (owner, 2026-10-17): the local store
513
+ // keeps every branch's entries under the task keys.
514
+ const branchScope = cacheScope === undefined ? undefined : new BranchScope(cacheScope)
466
515
  if (options.remoteCache) {
467
516
  remotes.push({
468
517
  remote: options.remoteCache,
@@ -473,7 +522,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
473
522
  }
474
523
  try {
475
524
  cache = options.remoteCache
476
- ? new LayeredCache(localCache, remotes)
525
+ ? new LayeredCache(localCache, remotes, branchScope)
477
526
  : await resolveCache(plugins, {
478
527
  workspaceRoot,
479
528
  cacheDir,
@@ -482,6 +531,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
482
531
  localCache,
483
532
  policy,
484
533
  remotes,
534
+ ...(branchScope !== undefined ? { branch: branchScope } : {}),
485
535
  })
486
536
  } catch (err) {
487
537
  // A cache plugin that throws or returns something off-contract is
@@ -492,8 +542,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
492
542
  localCache.close()
493
543
  throw err
494
544
  }
495
- // One scoping rule above every layer, local and remote alike.
496
- if (cacheScope !== undefined) cache = new ScopedCache(cache, cacheScope)
497
545
  // Ask the LAYER, don't infer. Identity against `localCache` answers a
498
546
  // DIFFERENT question — "did the plugin hand back something other than the
499
547
  // handle I passed in?" — which an ordinary pass-through decorator (a
@@ -508,7 +556,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
508
556
  // cycle under `vx watch` (item 1029). run() owns both only once this
509
557
  // returns.
510
558
  try {
511
- const hashCache = createHashCache()
559
+ const hashCache = createHashCache(localCache.foldMemo())
512
560
  const fingerprintWatch = new FingerprintWatch(
513
561
  workspaceRoot,
514
562
  claimed,
@@ -527,6 +575,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
527
575
  localCache,
528
576
  hasRemoteLayer,
529
577
  cachePolicy: policy,
578
+ cacheScope,
579
+ remotes,
530
580
  keepRunEntry: undefined,
531
581
  priorities: () => Promise.resolve(new Map()),
532
582
  branch,
@@ -713,6 +763,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
713
763
  if (hasHook(plugins, 'key')) {
714
764
  await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
715
765
  }
766
+ // The final inputs decide which tasks the branch changed: they read
767
+ // the scope, every other task the default key.
768
+ if (branchScope !== undefined && scopeChanges !== undefined) {
769
+ branchScope.routeTasks(async () =>
770
+ keyedByChange(nodes, await scopeChanges(), projects, packageGraph),
771
+ )
772
+ }
716
773
  refuseFiltersThroughGroups(nodes)
717
774
  const exclude = options.excludeDependencies
718
775
  let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
@@ -775,6 +832,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
775
832
  localCache,
776
833
  hasRemoteLayer,
777
834
  cachePolicy: policy,
835
+ cacheScope,
836
+ remotes,
778
837
  priorities,
779
838
  branch,
780
839
  cacheFor,
@@ -906,3 +965,63 @@ function workspaceCachePolicy(config: WorkspaceConfig | null | undefined): Cache
906
965
  )
907
966
  }
908
967
  }
968
+
969
+ /**
970
+ * A `project#build` naming the default `build` of a project the lock holds
971
+ * no config for, while its directory holds one, asks for that config's task, not
972
+ * one that runs nothing: ran, `c#build` was 0 tasks, exit 0, where a
973
+ * renamed task gets "run `vx lock`" (Growth, lock drift).
974
+ */
975
+ function refuseUnlockedConfig(
976
+ tasks: readonly string[],
977
+ projects: ReadonlyMap<string, ProjectEntry>,
978
+ metas: ReadonlyArray<{ name: string; configPath?: string | null }>,
979
+ workspaceRoot: string,
980
+ ): void {
981
+ for (const spec of tasks) {
982
+ const at = spec.indexOf('#')
983
+ if (at < 0) continue
984
+ const name = spec.slice(0, at)
985
+ const entry = projects.get(name)
986
+ if (entry === undefined || !isDefaultBuild(entry.config.tasks?.[spec.slice(at + 1)])) continue
987
+ const locked = metas.find((m) => m.name === name)?.configPath
988
+ if (typeof locked === 'string' && locked !== '') continue
989
+ const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(entry.dir, n)))
990
+ if (config === undefined) continue
991
+ throw new UserError(
992
+ `${spec}: ${relPosix(workspaceRoot, path.join(entry.dir, config))} is not in the lock yet — run \`vx lock\``,
993
+ )
994
+ }
995
+ }
996
+
997
+ /**
998
+ * Each remote's `open`, in declared order, for the ones the policy lets read
999
+ * or write. One at a time: two that ask on the terminal must not interleave.
1000
+ * A throw warns through the remote's `onError` (naming its plugin) and
1001
+ * turns that remote off; the run goes on, as after any remote failure.
1002
+ * True when that left the run no remote at all.
1003
+ */
1004
+ export async function openRemotes(
1005
+ remotes: readonly ResolvedRemote[],
1006
+ ci: boolean,
1007
+ secrets: SecretBroker,
1008
+ ): Promise<boolean> {
1009
+ let closed = false
1010
+ for (const entry of remotes) {
1011
+ if (entry.remote.open === undefined || !(entry.read || entry.write)) continue
1012
+ try {
1013
+ await entry.remote.open({
1014
+ ci,
1015
+ warn: (message) => entry.onError?.(new Error(message)),
1016
+ ...secrets.for(entry.plugin ?? 'vx'),
1017
+ })
1018
+ } catch (err) {
1019
+ entry.read = false
1020
+ entry.write = false
1021
+ const e = err instanceof Error ? err : new Error(String(err))
1022
+ entry.onError?.(e)
1023
+ closed = true
1024
+ }
1025
+ }
1026
+ return closed && !remotes.some((r) => r.read || r.write)
1027
+ }
@@ -25,6 +25,8 @@ import type { WorkspaceConfig } from '../config.js'
25
25
  import { lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
26
26
  import { relPosix, UserError } from '../util/index.js'
27
27
  import {
28
+ cacheScopeError,
29
+ isCacheScope,
28
30
  loadWorkspaceConfig,
29
31
  discoveredProject,
30
32
  linkDiscoveredEdges,
@@ -33,7 +35,7 @@ import {
33
35
  } from '../workspace/index.js'
34
36
  import { parseDependencySpec } from '../graph/index.js'
35
37
  import { applyConfigHooks, applyDiscoverHooks, applyProjectHooks, hasHook } from './plugin-host.js'
36
- import type { VxPlugin } from './plugin.js'
38
+ import type { BranchFacts, VxPlugin } from './plugin.js'
37
39
 
38
40
  /**
39
41
  * The workspace config with the plugin `config` stage applied — the first
@@ -43,10 +45,16 @@ import type { VxPlugin } from './plugin.js'
43
45
  export async function loadWorkspacePlugins(
44
46
  workspaceRoot: string,
45
47
  warn: (message: string) => void,
46
- ): Promise<{ workspaceConfig: WorkspaceConfig | null; plugins: readonly VxPlugin[] }> {
48
+ ): Promise<{
49
+ workspaceConfig: WorkspaceConfig | null
50
+ plugins: readonly VxPlugin[]
51
+ branch: BranchFacts
52
+ }> {
47
53
  const loaded = await loadWorkspaceConfig(workspaceRoot)
48
54
  const plugins = (loaded?.plugins ?? []) as readonly VxPlugin[]
49
- if (loaded === null || !hasHook(plugins, 'config')) return { workspaceConfig: loaded, plugins }
55
+ const branch: BranchFacts = {}
56
+ if (loaded === null || !hasHook(plugins, 'config'))
57
+ return { workspaceConfig: loaded, plugins, branch }
50
58
  // Bun keeps one module per specifier, so the file's export is one object
51
59
  // per process: the CLI's selection pass, the run and each `vx watch`
52
60
  // cycle handed the hooks what the last load's hooks had edited
@@ -60,10 +68,16 @@ export async function loadWorkspacePlugins(
60
68
  // Re-validated after each plugin, as the `project` stage is: an edit
61
69
  // the loader would refuse from a user (`concurrency: -3`) hung the run,
62
70
  // and `cacheDir: 42` reached `path.resolve` as a TypeError.
63
- await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
71
+ const setBranch = (facts: BranchFacts): void => {
72
+ if (facts.scope !== undefined && !isCacheScope(facts.scope))
73
+ throw new UserError(`setBranch: ${cacheScopeError('scope')}`)
74
+ if (facts.scope !== undefined) branch.scope = facts.scope
75
+ if (facts.base !== undefined) branch.base = facts.base
76
+ }
77
+ await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn, setBranch }, (plugin) =>
64
78
  validateWorkspace(workspaceConfig, `.vx/workspace (after plugin '${plugin.name}')`),
65
79
  )
66
- return { workspaceConfig, plugins }
80
+ return { workspaceConfig, plugins, branch }
67
81
  }
68
82
 
69
83
  /**
@@ -127,11 +141,16 @@ export async function discoverProjects(
127
141
  (e) => e.changed,
128
142
  () => null,
129
143
  )
144
+ const byDir = new Map(projects.map((p) => [p.dir, p]))
130
145
  const edges = await applyDiscoverHooks(
131
146
  plugins,
132
147
  projects,
133
148
  { workspaceRoot, cacheDir, warn, worktreeChanges },
134
- (entry, plugin) => discoveredProject(workspaceRoot, projects, entry, plugin.name),
149
+ async (entry, plugin) => {
150
+ const found = await discoveredProject(workspaceRoot, byDir, entry, plugin.name)
151
+ if (found.added) byDir.set(found.meta.dir, found.meta)
152
+ return found
153
+ },
135
154
  )
136
155
  checkProjectNames(workspaceRoot, projects)
137
156
  linkDiscoveredEdges(workspaceRoot, projects, edges)
@@ -15,6 +15,7 @@ import os from 'node:os'
15
15
  import path from 'node:path'
16
16
  import { executablePath, gitEnv, relPosix, ulid, xxh3hex } from '../util/index.js'
17
17
  import { resolveSetting } from '../settings.js'
18
+ import { branchCacheScope, cacheScopeEnvError, isCacheScope } from '../workspace/index.js'
18
19
  import { VERSION } from '../version.js'
19
20
  import type { RunContextRecord } from './telemetry.js'
20
21
 
@@ -525,6 +526,85 @@ export function currentBranch(
525
526
  return set ?? captureGitContext(workspaceRoot, null, env).branch
526
527
  }
527
528
 
529
+ /**
530
+ * The cache scope a checkout on a branch reads, from the `.git` files
531
+ * alone (owner, 2026-10-17): `<owner>/<repo>:<branch>`, the branch it
532
+ * tracks (`branch.<name>.merge` on `branch.<name>.remote`), else its own
533
+ * name on `origin`, the repository named by that remote's URL. None on
534
+ * the default branch, on a detached HEAD, or with no remote URL. CI writes
535
+ * that scope for the same branch (`github()`), so a laptop reads what its
536
+ * branch's CI saved.
537
+ */
538
+ export function localBranchScope(
539
+ workspaceRoot: string,
540
+ baseBranch: string | undefined,
541
+ env: NodeJS.ProcessEnv = process.env,
542
+ ): string | undefined {
543
+ const head = readHeadDirect(workspaceRoot)
544
+ if (head === null || head.branch === null) return undefined
545
+ const config = gitConfigSections(workspaceRoot)
546
+ const own = config.get(`branch "${head.branch}"`)
547
+ const name = own?.get('merge')?.replace(/^refs\/heads\//, '') ?? head.branch
548
+ const url = config.get(`remote "${own?.get('remote') ?? 'origin'}"`)?.get('url')
549
+ const repo = url === undefined ? undefined : repoOfUrl(url)
550
+ if (repo === undefined) return undefined
551
+ const trunk = baseBranch ?? captureDefaultBranch(env, workspaceRoot)
552
+ if (trunk === null ? name === 'main' || name === 'master' : name === trunk) return undefined
553
+ return branchCacheScope(repo, name)
554
+ }
555
+
556
+ /** `owner/repo` from a remote URL (its last two path segments); undefined for a local path. */
557
+ function repoOfUrl(url: string): string | undefined {
558
+ const parts = normalizeRemoteUrl(url).split('/')
559
+ return parts.length < 3 ? undefined : parts.slice(-2).join('/')
560
+ }
561
+
562
+ /** `.git/config`'s sections, each a key → value map (last wins); empty when it cannot be read. */
563
+ function gitConfigSections(workspaceRoot: string): Map<string, Map<string, string>> {
564
+ const sections = new Map<string, Map<string, string>>()
565
+ try {
566
+ const dirs = refDirsOf(workspaceRoot)
567
+ if (dirs === null) return sections
568
+ let at: Map<string, string> | undefined
569
+ for (const raw of fs.readFileSync(path.join(dirs.commonDir, 'config'), 'utf8').split('\n')) {
570
+ const line = raw.trim()
571
+ const section = /^\[\s*(\w+)(?:\s+"(.*)")?\s*\]$/.exec(line)
572
+ if (section !== null) {
573
+ const key = section[2] === undefined ? section[1]! : `${section[1]} "${section[2]}"`
574
+ at = sections.get(key) ?? new Map()
575
+ sections.set(key, at)
576
+ continue
577
+ }
578
+ const kv = /^(\w+)\s*=\s*(.*)$/.exec(line)
579
+ if (kv !== null) at?.set(kv[1]!.toLowerCase(), kv[2]!.trim())
580
+ }
581
+ } catch {
582
+ // no config to read: no remote, so no scope.
583
+ }
584
+ return sections
585
+ }
586
+
587
+ /**
588
+ * The run's cache scope (owner, 2026-10-17): `VX_CACHE_SCOPE` (empty: none),
589
+ * else a plugin's (`setBranch`, `github()` on Actions), else, with a cache
590
+ * plugin to share it, the branch's from git. Undefined: the default keys.
591
+ */
592
+ export function resolveCacheScope(
593
+ workspaceRoot: string,
594
+ pluginScope: string | undefined,
595
+ hasRemote: boolean,
596
+ baseBranch: string | undefined,
597
+ env: NodeJS.ProcessEnv = process.env,
598
+ ): string | undefined {
599
+ const named = env['VX_CACHE_SCOPE']
600
+ if (named !== undefined) {
601
+ if (named === '') return undefined
602
+ if (!isCacheScope(named)) throw cacheScopeEnvError()
603
+ return named
604
+ }
605
+ return pluginScope ?? (hasRemote ? localBranchScope(workspaceRoot, baseBranch, env) : undefined)
606
+ }
607
+
528
608
  export interface TelemetryRunContextArgs {
529
609
  runId: string
530
610
  /** The invocation, already masked. */
@@ -49,6 +49,7 @@ import {
49
49
  isUserError,
50
50
  detectColors,
51
51
  type ColorSupport,
52
+ SecretBroker,
52
53
  } from '../util/index.js'
53
54
  import { keyedProjects } from './keyed-projects.js'
54
55
  import { isDefaultBuild } from './projects.js'
@@ -87,7 +88,7 @@ import { isPassStatus } from '../graph/index.js'
87
88
  import type { RunContextRecord } from './telemetry.js'
88
89
  import { plan, type RunPlan } from './plan.js'
89
90
  import type { AffectedReason } from './affected-tasks.js'
90
- import { prepareRun, type PreparedRun } from './prepare.js'
91
+ import { openRemotes, prepareRun, type PreparedRun } from './prepare.js'
91
92
  import { acquireRunLock } from './run-lock.js'
92
93
  import { terminateChildren } from './signals.js'
93
94
  import { runStop } from './run-stop.js'
@@ -466,6 +467,15 @@ async function runOnBus(
466
467
  failures: keepRun.failures,
467
468
  traffic: prepared.localCache.traffic,
468
469
  })
470
+ // Before setup and the status line: a remote's `open` may ask on the terminal.
471
+ const secrets = new SecretBroker(
472
+ ciContext.ci,
473
+ warnOf(log),
474
+ undefined,
475
+ undefined,
476
+ terminal === null ? undefined : () => terminal.holdStatus(),
477
+ )
478
+ const remotesClosed = await openRemotes(prepared.remotes, ciContext.ci, secrets)
469
479
  try {
470
480
  disposePlugins = await installPlugins({
471
481
  plugins: prepared.plugins,
@@ -476,6 +486,7 @@ async function runOnBus(
476
486
  cache: prepared.cacheFor,
477
487
  ci: ciContext.ci,
478
488
  branch: prepared.branch(),
489
+ secrets,
479
490
  step,
480
491
  })
481
492
  } catch (err) {
@@ -592,6 +603,7 @@ async function runOnBus(
592
603
  handsTerminal: holders.size > 0,
593
604
  teardowns: prepared.plugins.filter((p) => p.teardown).length,
594
605
  onStop: () => {
606
+ log.runStop?.()
595
607
  stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
596
608
  cache.stopUploads?.()
597
609
  keeper?.stop()
@@ -634,13 +646,15 @@ async function runOnBus(
634
646
  try {
635
647
  const runStartHrTimeNs = process.hrtime.bigint()
636
648
  const endedAtMsAtStart = Date.now()
637
- const remoteCacheEnabled = prepared.hasRemoteLayer
649
+ // Every remote turned off by its `open` is no remote: a task must not clean
650
+ // its outputs for a save that goes nowhere.
651
+ const remoteCacheEnabled = prepared.hasRemoteLayer && !remotesClosed
638
652
  // Normalised ONCE so every consumer — execute-task, the dedup
639
653
  // predicate, the recorded invocation row — reads the policy that
640
654
  // actually governed the run. Reading the raw request here made tasks
641
655
  // clean their outputs before every exec for a save that never
642
656
  // happened.
643
- const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
657
+ const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, remoteCacheEnabled)
644
658
  if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
645
659
  warnOf(log)(
646
660
  'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
@@ -785,6 +799,7 @@ async function runOnBus(
785
799
  version: VERSION,
786
800
  packageCount: packagesInScope.size,
787
801
  remoteCacheEnabled,
802
+ cacheScope: prepared.cacheScope,
788
803
  cache: {
789
804
  local: policy.localRead || policy.localWrite,
790
805
  localWrite: policy.localWrite,
@@ -794,6 +809,7 @@ async function runOnBus(
794
809
  },
795
810
  concurrency,
796
811
  workspaceProjectCount,
812
+ runId: undefined as string | undefined,
797
813
  }
798
814
 
799
815
  // Asked only when a plugin declares `output`: a plain run pays nothing.
@@ -810,6 +826,8 @@ async function runOnBus(
810
826
 
811
827
  // Lifecycle hooks drive the default logger's dynamic status line
812
828
  // (TTY-only); custom loggers may ignore them.
829
+ // The status line starts here: a prompt now would be drawn over.
830
+ secrets.closePrompts()
813
831
  log.runStart?.({
814
832
  total: taskCount,
815
833
  concurrency,
@@ -817,18 +835,22 @@ async function runOnBus(
817
835
  context: runContext,
818
836
  startedAtMs: endedAtMsAtStart,
819
837
  })
820
- // The live region's end-of-run forecast. The logger asks only if its
821
- // region is still up a second in, so a warm run never reads history.
822
- terminal?.forecast(async () => {
823
- const ids: string[] = []
824
- for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
825
- const p50s = await new LocalHistoryProvider(
826
- prepared.localCache.dbHandle(),
827
- undefined,
828
- prepared.branch(),
829
- ).p50sFor(ids)
830
- return createForecast(nodes, p50s, concurrency)
831
- })
838
+ // The live region's forecast: the graph's from the first free row, and
839
+ // history's only if the region is still up a second in, so a warm run
840
+ // never reads history.
841
+ terminal?.forecast(
842
+ () => createForecast(nodes, new Map(), concurrency),
843
+ async () => {
844
+ const ids: string[] = []
845
+ for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
846
+ const p50s = await new LocalHistoryProvider(
847
+ prepared.localCache.dbHandle(),
848
+ undefined,
849
+ prepared.branch(),
850
+ ).p50sFor(ids)
851
+ return createForecast(nodes, p50s, concurrency)
852
+ },
853
+ )
832
854
 
833
855
  // Remote-only: kick off background prefetches so remote-GET latency
834
856
  // overlaps execution. Fire-and-forget — execution starts on the next
@@ -1269,6 +1291,7 @@ async function runOnBus(
1269
1291
  })
1270
1292
  }
1271
1293
  mark('record history')
1294
+ runContext.runId = runId
1272
1295
  // Drain any still-in-flight background prefetches before closing the
1273
1296
  // cache handle — a prefetch ingesting into a closed SQLite DB would
1274
1297
  // throw. Tasks that resolved as local hits never awaited their
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'
7
7
  import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
8
8
  import { homedir } from 'node:os'
9
9
  import path from 'node:path'
10
- import type { ExecConfig, SandboxConfig } from '../config.js'
10
+ import type { ExecConfig } from '../config.js'
11
11
  import {
12
12
  bindableWrites,
13
13
  initSandbox,
@@ -289,53 +289,6 @@ export async function sandboxRequestFor(
289
289
  return { sandbox: request, placeholders, withheld: links.withheld }
290
290
  }
291
291
 
292
- /**
293
- * Whether a sandboxed task could read `real` (a canonical path inside its
294
- * project): under one of its read or write grants or its `node_modules`,
295
- * and not behind a wall. vx packs a symlinked output as its target's
296
- * bytes, outside the sandbox, so a link the task planted to a file it was
297
- * denied (`dist/x -> ../.env` under `read: ['src/']`) packed that file
298
- * into the cache wherever creating the link went unseen: seatbelt, or
299
- * Linux untraced. The grants are matched as written, not expanded: a
300
- * glob's hit made by the task itself counts.
301
- */
302
- export function sandboxReadable(
303
- node: TaskNode,
304
- sandbox: SandboxConfig,
305
- workspaceRoot: string,
306
- nested: readonly string[] = [],
307
- ): (real: string) => boolean {
308
- const projectReal = toRealPath(node.projectDir)
309
- const at = (p: string): string =>
310
- p.startsWith('~')
311
- ? path.join(homedir(), p.slice(1))
312
- : path.isAbsolute(p)
313
- ? p
314
- : path.join(projectReal, p)
315
- const grants = [...(sandbox.allow?.read ?? []), ...(sandbox.allow?.write ?? [])].map(at)
316
- const literals = [
317
- ...grants.filter(isMountableLiteral).map((g) => toRealPath(g.replace(/\/+$/, '') || '/')),
318
- toRealPath(path.join(node.projectDir, 'node_modules')),
319
- toRealPath(path.join(workspaceRoot, 'node_modules')),
320
- ]
321
- const globs = grants.filter((g) => !isMountableLiteral(g)).map((g) => new Bun.Glob(g))
322
- const walls = [
323
- ...nested,
324
- path.join(workspaceRoot, '.git'),
325
- path.join(workspaceRoot, '.vx', 'cache'),
326
- ].map(toRealPath)
327
- return (real) => {
328
- if (walls.some((w) => atOrUnder(real, w))) return false
329
- if (literals.some((l) => atOrUnder(real, l))) return true
330
- // A directory a glob matches is granted whole.
331
- for (let p = real; atOrUnder(p, projectReal); p = path.dirname(p)) {
332
- if (globs.some((g) => g.match(p))) return true
333
- if (p === projectReal) break
334
- }
335
- return false
336
- }
337
- }
338
-
339
292
  /**
340
293
  * The sandbox half of the request, plus the empty files vx created so a
341
294
  * literal write grant had something to bind (`placeholders`). They are