@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  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 +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -4,30 +4,30 @@
4
4
  // plugin `project` stage, which can give tasks to a package that never
5
5
  // wrote a `vx.config.ts`.
6
6
 
7
+ import { stat } from 'node:fs/promises'
7
8
  import path from 'node:path'
8
9
  import type { ProjectConfig, TaskConfig } from '../config.js'
9
10
  import {
11
+ checkProjectNames,
10
12
  frozenProjectConfigs,
11
- loadProjectConfigs,
12
13
  LOCKFILE_NAME,
14
+ lockedProjects,
15
+ NO_LOCK,
16
+ pathName,
17
+ readLockfile,
13
18
  validateProjectConfig,
14
- type LoadProjectConfigOptions,
15
- type LoadReads,
16
19
  type Lockfile,
17
20
  type PackageGraph,
18
21
  type ProjectEntry,
19
22
  type ProjectMeta,
20
- type Workspace,
21
23
  } from '../workspace/index.js'
22
24
  import type { WorkspaceConfig } from '../config.js'
23
- import { Cache, lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
24
- import { UserError } from '../util/index.js'
25
+ import { lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
26
+ import { relPosix, UserError } from '../util/index.js'
25
27
  import {
26
- computeWorkspaceFingerprint,
27
- listProjects,
28
- loadWorkspace,
29
28
  loadWorkspaceConfig,
30
- namedProject,
29
+ discoveredProject,
30
+ linkDiscoveredEdges,
31
31
  resolveCacheDir,
32
32
  validateWorkspace,
33
33
  } from '../workspace/index.js'
@@ -61,7 +61,7 @@ export async function loadWorkspacePlugins(
61
61
  // the loader would refuse from a user (`concurrency: -3`) hung the run,
62
62
  // and `cacheDir: 42` reached `path.resolve` as a TypeError.
63
63
  await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
64
- validateWorkspace(workspaceConfig, `vx.workspace (after plugin '${plugin.name}')`),
64
+ validateWorkspace(workspaceConfig, `.vx/workspace (after plugin '${plugin.name}')`),
65
65
  )
66
66
  return { workspaceConfig, plugins }
67
67
  }
@@ -77,6 +77,15 @@ export function gitOfDiscovery(projects: readonly ProjectMeta[]): LazyGitEnumera
77
77
  return discoveredGit.get(projects)
78
78
  }
79
79
 
80
+ /** The lock a discovery read, by the array it returned: a later config load reads no second copy. */
81
+ const discoveredLock = new WeakMap<readonly ProjectMeta[], () => Promise<Lockfile>>()
82
+
83
+ export function lockOfDiscovery(
84
+ projects: readonly ProjectMeta[],
85
+ ): (() => Promise<Lockfile>) | undefined {
86
+ return discoveredLock.get(projects)
87
+ }
88
+
80
89
  /**
81
90
  * The package graph a selection pass built over a discovery, by that
82
91
  * discovery's array, when it folded no task edge in: the graph a run that
@@ -93,34 +102,40 @@ export function keepDiscoveryGraph(projects: readonly ProjectMeta[], graph: Pack
93
102
  }
94
103
 
95
104
  /**
96
- * The workspace's projects: core's discovery, then the plugin `discover`
97
- * stage. With no plugin declaring it, `listProjects` alone.
105
+ * The workspace's projects: the directories the lock records (a run never
106
+ * walks for configs), then the plugin `discover` stage, which may add
107
+ * directories, name path-named ones and give edges. Sorted by name; two
108
+ * projects of one name are refused.
98
109
  */
99
110
  export async function discoverProjects(
100
- workspace: Workspace,
111
+ workspaceRoot: string,
112
+ lock: () => Promise<Lockfile>,
101
113
  plugins: readonly VxPlugin[],
102
114
  cacheDir: string,
103
115
  warn: (message: string) => void,
104
116
  /** The run's enumeration, which `DiscoverContext.worktreeChanges` reads (and starts). */
105
- git: LazyGitEnumeration = lazyGitEnumeration(workspace.root),
117
+ git: LazyGitEnumeration = lazyGitEnumeration(workspaceRoot),
106
118
  ): Promise<ProjectMeta[]> {
107
- const projects = await listProjects(workspace)
119
+ const projects = lockedProjects(workspaceRoot, (await lock()).projects)
108
120
  // Registered whatever the hooks: an `--affected` selection reads its
109
121
  // untracked files from this walk, and the run reuses it (I-26).
110
122
  discoveredGit.set(projects, git)
111
- if (!hasHook(plugins, 'discover')) return projects
123
+ discoveredLock.set(projects, lock)
124
+ if (!hasHook(plugins, 'discover')) return checkProjectNames(workspaceRoot, projects)
112
125
  const worktreeChanges = (): Promise<readonly string[] | null> =>
113
126
  git.start().then(
114
127
  (e) => e.changed,
115
128
  () => null,
116
129
  )
117
- await applyDiscoverHooks(
130
+ const edges = await applyDiscoverHooks(
118
131
  plugins,
119
132
  projects,
120
- { workspaceRoot: workspace.root, cacheDir, warn, worktreeChanges },
121
- (named, plugin) => namedProject(workspace, projects, named, plugin.name),
133
+ { workspaceRoot, cacheDir, warn, worktreeChanges },
134
+ (entry, plugin) => discoveredProject(workspaceRoot, projects, entry, plugin.name),
122
135
  )
123
- return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
136
+ checkProjectNames(workspaceRoot, projects)
137
+ linkDiscoveredEdges(workspaceRoot, projects, edges)
138
+ return projects
124
139
  }
125
140
 
126
141
  export type LoadProjectsArgs = LoadProjectsBase &
@@ -142,13 +157,14 @@ interface LoadProjectsBase {
142
157
  */
143
158
  seeds: 'all' | Iterable<string>
144
159
  /**
145
- * Read configs from the lock instead of evaluating them (`--frozen`),
146
- * asked only when a config is to be read: a run whose every config the
147
- * CLI's selection pass staged never parses it again.
160
+ * The lock configs come from, asked only when a config is to be read (a
161
+ * run whose every config the CLI's selection pass staged never parses
162
+ * it). `vx lock` hands the one it is about to write.
148
163
  */
149
- lock: (() => Promise<Lockfile>) | null
150
- evalCache: LoadProjectConfigOptions['evalCache']
164
+ configs: () => Promise<Lockfile>
151
165
  warn: (message: string) => void
166
+ /** `vx lock`'s audit: the `project` stage's `ctx.lock`. */
167
+ lock?: boolean
152
168
  /**
153
169
  * Entries a load in this same process already produced with the same
154
170
  * cache dir and lock (the CLI's selection pass, which stages every
@@ -180,7 +196,8 @@ export interface LoadedProjects {
180
196
  * enters scope.
181
197
  */
182
198
  export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjects> {
183
- const { plugins, lock, workspaceRoot } = args
199
+ const { plugins, workspaceRoot } = args
200
+ const lock = args.configs
184
201
  const graph = args.closure ? args.packageGraph : null
185
202
  // A package with no config file declares no tasks — unless a plugin
186
203
  // fills the `project` stage, in which case it is a project the stage may
@@ -238,27 +255,13 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
238
255
  // deps by design), so its config has to be pulled in — and that config
239
256
  // may declare cross edges of its own. The common case (no cross deps)
240
257
  // is a single round, identical to loading the closure in one batch.
241
- // Turbo spells the root package `//` (`dependsOn: ['//#lint']`); vx names
242
- // the root project by its package.json name, so the spelling is rewritten
243
- // to it once, here, for the closure, the graph and `cache.inputs.tasks`
244
- // alike. With no root project it stays, and the graph's refusal says so.
258
+ // Turbo spells the root package `//` (`dependsOn: ['//#lint']`), as vx
259
+ // names the root project unless something names it; a named root's
260
+ // spelling is rewritten to its name once, here, for the closure, the
261
+ // graph and `cache.inputs.tasks` alike. With no root project it stays,
262
+ // and the graph's refusal says so.
245
263
  const root = path.resolve(workspaceRoot)
246
264
  const rootName = args.projectMetas.find((m) => path.resolve(m.dir) === root)?.name
247
- // A project the lock holds whose config file is gone ran nothing under
248
- // --frozen, green, though the lock promises what runs is what was
249
- // locked (X-144); a run never even asks such a project for a task.
250
- // Refused as a moved config is (D-88). With no lock at all, nothing was
251
- // locked: a repo of config-less projects needs none.
252
- if (lock) {
253
- const bare = args.projectMetas.filter((m) => m.configPath === null)
254
- const held = bare.length === 0 ? null : await lock().catch(() => null)
255
- const gone = held === null ? undefined : bare.find((m) => Object.hasOwn(held.projects, m.name))
256
- if (gone !== undefined && held !== null)
257
- throw new UserError(
258
- `${LOCKFILE_NAME} locks "${gone.name}" at ${held.projects[gone.name]!.configPath}, but it has no config now — ` +
259
- `run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
260
- )
261
- }
262
265
  const projects = new Map<string, ProjectEntry>()
263
266
  while (pending.length > 0) {
264
267
  const round = pending.splice(0, pending.length)
@@ -269,14 +272,30 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
269
272
  (m): m is ProjectMeta & { configPath: string } =>
270
273
  typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
271
274
  )
272
- const loaded = lock
273
- ? withFile.length === 0
274
- ? []
275
- : await frozenProjectConfigs(await lock(), withFile, workspaceRoot, args.evalCache?.store)
276
- : await loadProjectConfigs(
277
- withFile.map((m) => m.configPath),
278
- args.evalCache !== undefined ? { evalCache: args.evalCache } : {},
279
- )
275
+ // A locked project whose config file is gone ran as locked, green,
276
+ // though nothing declares it any more (X-144): asked only of the
277
+ // configs this run reads.
278
+ const present = await Promise.all(withFile.map((m) => Bun.file(m.configPath).exists()))
279
+ const gone = withFile.find((_, i) => !present[i])
280
+ if (gone !== undefined) {
281
+ const rel = relPosix(workspaceRoot, gone.dir) || '.'
282
+ const named = gone.name === pathName(workspaceRoot, gone.dir) ? '' : ` ("${gone.name}")`
283
+ const file = relPosix(gone.dir, gone.configPath)
284
+ // exists() says false for a file behind a directory this user may not
285
+ // search too; asked only on this failure path, so a run pays no stat.
286
+ const denied = await stat(gone.configPath).then(
287
+ () => null,
288
+ (err: NodeJS.ErrnoException) =>
289
+ err.code === 'EACCES' || err.code === 'EPERM' ? err.code : null,
290
+ )
291
+ throw new UserError(
292
+ denied !== null
293
+ ? `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is not readable by this user (${denied})`
294
+ : `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is gone — run \`vx lock\``,
295
+ )
296
+ }
297
+ const loaded =
298
+ withFile.length === 0 ? [] : frozenProjectConfigs(await lock(), withFile, workspaceRoot)
280
299
  let next = 0
281
300
  for (const meta of round) {
282
301
  const pre = args.staged?.get(meta.name)
@@ -305,10 +324,15 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
305
324
  warn: args.warn,
306
325
  name: meta.name,
307
326
  dir: meta.dir,
308
- packageJson: meta.packageJson as unknown as Readonly<Record<string, unknown>>,
309
327
  projects: args.projectMetas,
328
+ lock: args.lock === true,
310
329
  },
311
- (plugin) => validateProjectConfig(config, `${where} (after plugin '${plugin.name}')`),
330
+ (plugin) =>
331
+ validateProjectConfig(
332
+ config,
333
+ `${where} (after plugin '${plugin.name}')`,
334
+ meta.configPath,
335
+ ),
312
336
  )
313
337
  }
314
338
  const built = withDefaultBuild(config)
@@ -396,57 +420,39 @@ function crossDepProjects(config: ProjectConfig): string[] {
396
420
  }
397
421
 
398
422
  /**
399
- * The run path's view of a workspace's projects for a reader — `vx show`,
400
- * the MCP server, an embedder: discovery, the plugin `config` and
401
- * `project` stages, and the local cache opened only to serve cached
402
- * evaluations, so a pure config costs a stat, not an evaluation. `scope`
403
- * is every project or a list of names; no closure, no lock (a reader
404
- * reads live, as a default run does). Plugin warnings go to `warn`.
423
+ * The run path's view of a workspace's projects for a reader — a plugin's
424
+ * verb, an embedder: the lock, the plugin `config`, `discover` and
425
+ * `project` stages. `scope` is every project or a list of names; no
426
+ * closure. No lock is `NO_LOCK`. Plugin warnings go to `warn`.
405
427
  */
406
428
  export async function loadResolvedProjects(
407
429
  workspaceRoot: string,
408
430
  opts: { scope?: 'all' | readonly string[]; warn?: (message: string) => void } = {},
409
431
  ): Promise<Map<string, ProjectEntry>> {
410
432
  const warn = opts.warn ?? ((): void => {})
411
- const reads: LoadReads = new Map()
412
433
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warn)
413
434
  const cacheDir = resolveCacheDir(workspaceRoot, workspaceConfig)
414
- const metas = await discoverProjects(
415
- await loadWorkspace(workspaceRoot, reads),
416
- plugins,
435
+ const lock = lockReader(workspaceRoot)
436
+ const metas = await discoverProjects(workspaceRoot, lock, plugins, cacheDir, warn)
437
+ const loaded = await loadProjects({
438
+ workspaceRoot,
417
439
  cacheDir,
440
+ plugins,
441
+ projectMetas: metas,
442
+ seeds: opts.scope ?? 'all',
443
+ closure: false,
444
+ configs: lock,
418
445
  warn,
419
- )
420
- // Opened as `vx last` opens it: a reader makes no index where there is
421
- // none and never resets an earlier schema's (C-5). One it cannot read
422
- // serves nothing; the configs evaluate live.
423
- let cache: Cache | undefined
424
- try {
425
- cache = Cache.inspect(cacheDir)
426
- } catch (err) {
427
- if (!(err instanceof UserError)) throw err
428
- }
429
- try {
430
- const loaded = await loadProjects({
431
- workspaceRoot,
432
- cacheDir,
433
- plugins,
434
- projectMetas: metas,
435
- seeds: opts.scope ?? 'all',
436
- closure: false,
437
- lock: null,
438
- evalCache:
439
- cache === undefined
440
- ? undefined
441
- : {
442
- store: cache,
443
- workspaceRoot,
444
- workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot, reads),
445
- },
446
- warn,
447
- })
448
- return loaded.projects
449
- } finally {
450
- cache?.close()
451
- }
446
+ })
447
+ return loaded.projects
448
+ }
449
+
450
+ /** The lock, read once on first ask; no lock is `NO_LOCK`. */
451
+ export function lockReader(workspaceRoot: string): () => Promise<Lockfile> {
452
+ let read: Promise<Lockfile> | undefined
453
+ return () =>
454
+ (read ??= readLockfile(workspaceRoot).then((lock) => {
455
+ if (lock === null) throw new UserError(NO_LOCK)
456
+ return lock
457
+ }))
452
458
  }
@@ -12,8 +12,9 @@
12
12
  // GET per key.
13
13
  //
14
14
  // HARD SCOPE: this runs ONLY when a remote layer is configured. It
15
- // NEVER touches the local cache (no local get / no isOutputsCurrent /
16
- // no stat pass) — it derives keys and probes REMOTE only. Local-only
15
+ // asks the local index only which keys it holds (no local get / no
16
+ // isOutputsCurrent), and probes the remote for the rest: a run the local
17
+ // cache answers makes no remote call (M, 2026-10-15). Local-only
17
18
  // runs are byte-identical to before; the caller gates on the layer's
18
19
  // own `hasRemote`, so a third-party remote layer gets the overlap too.
19
20
  // The two batch-probe hooks are OPTIONAL on the contract: a remote layer
@@ -30,7 +31,8 @@ export interface PrefetchArgs {
30
31
  /** A layer whose `hasRemote` is true — the caller's gate. */
31
32
  cache: CacheLayer
32
33
  workspaceRoot: string
33
- workspaceFingerprint: string
34
+ /** The root files the plugins claim (`DeriveStableKeysArgs.claimedFiles`). */
35
+ claimedFiles: readonly string[]
34
36
  forwardArgs?: readonly string[] | undefined
35
37
  nestedDirsByProject: Map<string, string[]>
36
38
  gitFilesCache: GitFilesCache
@@ -74,8 +76,11 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
74
76
  // would 404. When the remote can't batch (`null` — an older remote, reads
75
77
  // disabled, or a layer that doesn't implement the hook at all), fall back to
76
78
  // prefetching every stable key, exactly as before.
77
- let toPrefetch = stableKeys
78
- const uniqueHashes = [...new Set(stableKeys.map((k) => k.hash))]
79
+ const held =
80
+ (await args.cache.localHasMany?.([...new Set(stableKeys.map((k) => k.hash))])) ?? null
81
+ let toPrefetch = held === null ? stableKeys : stableKeys.filter((k) => !held.has(k.hash))
82
+ if (toPrefetch.length === 0) return
83
+ const uniqueHashes = [...new Set(toPrefetch.map((k) => k.hash))]
79
84
  const present = (await args.cache.remoteHasMany?.(uniqueHashes)) ?? null
80
85
  if (present !== null) {
81
86
  args.cache.markRemoteAbsent?.(uniqueHashes.filter((h) => !present.has(h)))
@@ -84,7 +89,7 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
84
89
  // economy: deleting it alone GETs nothing extra, deleting both GETs
85
90
  // every absent key. What it decides is which hashes the pump asks
86
91
  // the layer for, and that is what the row pins (item 643, 645).
87
- toPrefetch = stableKeys.filter((k) => present.has(k.hash))
92
+ toPrefetch = toPrefetch.filter((k) => present.has(k.hash))
88
93
  if (toPrefetch.length === 0) return
89
94
  }
90
95
 
@@ -13,7 +13,8 @@
13
13
  import fs from 'node:fs'
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
- import { executablePath, relPosix, ulid, xxh3hex } from '../util/index.js'
16
+ import { executablePath, gitEnv, relPosix, ulid, xxh3hex } from '../util/index.js'
17
+ import { resolveSetting } from '../settings.js'
17
18
 
18
19
  export interface GitContext {
19
20
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
@@ -86,6 +87,7 @@ export function captureGitContext(
86
87
  '--abbrev-ref',
87
88
  'HEAD',
88
89
  ],
90
+ env: gitEnv(),
89
91
  stdout: 'pipe',
90
92
  stderr: 'ignore',
91
93
  })
@@ -280,6 +282,7 @@ export function captureDefaultBranch(
280
282
  '--short',
281
283
  'refs/remotes/origin/HEAD',
282
284
  ],
285
+ env: gitEnv(),
283
286
  stdout: 'pipe',
284
287
  stderr: 'ignore',
285
288
  })
@@ -445,10 +448,10 @@ export function normalizeRemoteUrl(raw: string): string {
445
448
  * (telemetry schema v2). Derivation ladder:
446
449
  * 1. git remote origin URL, normalized → xxh3 (same id from any
447
450
  * machine's checkout of the same repo);
448
- * 2. no remote → a salt persisted at `<root>/.vx/workspace-id` (the
449
- * checkout keeps a stable identity across runs; `.vx/` is already
450
- * gitignored infrastructure);
451
- * 3. unwritable `.vx/` → the root path itself (stable per machine).
451
+ * 2. no remote → a salt persisted at `<root>/.vx/cache/workspace-id`
452
+ * (the checkout keeps a stable identity across runs; `.vx/cache` holds
453
+ * the machine-local state, never committed);
454
+ * 3. unwritable `.vx/cache` → the root path itself (stable per machine).
452
455
  * The URL comes from the run's own `git var -l` when it has one, else one
453
456
  * `git` spawn behind try/catch; call sites gate on telemetry being active
454
457
  * so a plain run never pays it. Never throws.
@@ -473,6 +476,7 @@ export function captureWorkspaceIdentity(
473
476
  // different proxies would derive different workspace ids. The raw
474
477
  // configured URL is the identity.
475
478
  cmd: [executablePath('git'), '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
479
+ env: gitEnv(),
476
480
  stdout: 'pipe',
477
481
  stderr: 'ignore',
478
482
  })
@@ -488,7 +492,7 @@ export function captureWorkspaceIdentity(
488
492
  // git unavailable — fall through to the salt.
489
493
  }
490
494
  try {
491
- const saltPath = path.join(workspaceRoot, '.vx', 'workspace-id')
495
+ const saltPath = path.join(workspaceRoot, '.vx', 'cache', 'workspace-id')
492
496
  let salt: string
493
497
  try {
494
498
  salt = fs.readFileSync(saltPath, 'utf8').trim()
@@ -503,3 +507,18 @@ export function captureWorkspaceIdentity(
503
507
  return { id: xxh3hex(workspaceRoot), name: base, ...where }
504
508
  }
505
509
  }
510
+
511
+ /**
512
+ * The run's branch: `run.branch` (the flag given, else `VX_RUN_BRANCH`,
513
+ * else the workspace's), else HEAD's, else the CI provider's for a
514
+ * detached checkout. What run history records and a forecast reads first.
515
+ */
516
+ export function currentBranch(
517
+ workspaceRoot: string,
518
+ workspace?: unknown,
519
+ flag?: string,
520
+ env: NodeJS.ProcessEnv = process.env,
521
+ ): string | null {
522
+ const set = resolveSetting('run.branch', workspace, flag, env).value as string | undefined
523
+ return set ?? captureGitContext(workspaceRoot, null, env).branch
524
+ }
@@ -1,6 +1,5 @@
1
1
  // A failed run's task output and the files it names, so an agent reads why
2
- // a task failed without the terminal (`vx last --format json`, vx-mcp's
3
- // getFailures). One file per failed run, `<cacheDir>/failures/<runId>.json`,
2
+ // a task failed without the terminal (`vx last --format json`). One file per failed run, `<cacheDir>/failures/<runId>.json`,
4
3
  // the newest 50 kept: files, not cache.db, so the index layout (and every
5
4
  // shared store under it) stays as it was.
6
5
 
@@ -21,6 +20,8 @@ export interface TaskFailure {
21
20
  taskId: string
22
21
  exitCode: number
23
22
  timedOut?: true
23
+ /** The input that changed while the command ran (see `TaskOutcome`). */
24
+ inputMoved?: string
24
25
  /** Plain text, bounded, secrets masked; '' when the run kept none. */
25
26
  output: string
26
27
  locations: OutputLocation[]
@@ -47,18 +48,25 @@ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
47
48
  const isFile = fileMemo()
48
49
  const out: StoredFailure[] = []
49
50
  for (const o of outcomes) {
50
- if (o.failedOutput === undefined || isGroupTask(o.node)) continue
51
- const output = Bun.stripANSI(
52
- decodeOutputLog(o.failedOutput)
53
- .map((c) => c.text)
54
- .join(''),
55
- )
51
+ // A task failed by a moved input may have printed nothing: the input
52
+ // is still why, and an agent reads it here.
53
+ if ((o.failedOutput === undefined && o.inputMoved === undefined) || isGroupTask(o.node))
54
+ continue
55
+ const output =
56
+ o.failedOutput === undefined
57
+ ? ''
58
+ : Bun.stripANSI(
59
+ decodeOutputLog(o.failedOutput)
60
+ .map((c) => c.text)
61
+ .join(''),
62
+ )
56
63
  out.push({
57
64
  taskId: o.node.id,
58
65
  project: o.node.projectName,
59
66
  task: o.node.taskName,
60
67
  exitCode: o.exitCode,
61
68
  ...(o.timedOut === true ? { timedOut: true as const } : {}),
69
+ ...(o.inputMoved !== undefined ? { inputMoved: o.inputMoved } : {}),
62
70
  output,
63
71
  locations: fileLocations(output, o.node.projectDir, isFile),
64
72
  ...(o.sandboxFix !== undefined ? { sandboxFix: o.sandboxFix } : {}),
@@ -138,6 +146,7 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
138
146
  taskId,
139
147
  exitCode: r.exitCode,
140
148
  ...(r.timedOut === 1 ? { timedOut: true as const } : {}),
149
+ ...(k?.inputMoved !== undefined ? { inputMoved: k.inputMoved } : {}),
141
150
  output: k?.output ?? '',
142
151
  locations: k?.locations ?? [],
143
152
  ...(k?.sandboxFix !== undefined ? { sandboxFix: k.sandboxFix } : {}),
@@ -330,7 +330,7 @@ export async function acquireRunLock(
330
330
  opts: RunLockOptions,
331
331
  ): Promise<() => Promise<void>> {
332
332
  const lockDir = runLockPath(workspaceRoot, opts.dir)
333
- // A task's own process (`vx cache prune` in a task) would wait for the
333
+ // A task's own process (`vx cache clean` in a task) would wait for the
334
334
  // run that started it, which holds the lock until that task ends.
335
335
  const outer = process.env[VX_RUN_WORKSPACE_ENV]
336
336
  if (outer !== undefined && runLockPath(outer, opts.dir) === lockDir) {
@@ -27,7 +27,7 @@ export interface RunRecordsInput {
27
27
  ok: boolean
28
28
  command: string
29
29
  requestedTasks: readonly string[]
30
- /** Already compacted (`compactCachePolicy`), the form the header row stores. */
30
+ /** Already formatted (`formatCachePolicy`), the form the header row stores. */
31
31
  cachePolicy: string
32
32
  concurrency: number
33
33
  flow: InvocationRecord['flow']
@@ -82,7 +82,7 @@ function statusWord(o: OutcomeView): string {
82
82
  case 'cache-hit-remote':
83
83
  return 'success'
84
84
  case 'failed':
85
- return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady)
85
+ return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady, o.inputMoved)
86
86
  case 'skipped':
87
87
  return skippedLabel(o.blockedBy)
88
88
  case 'aborted':
@@ -144,8 +144,13 @@ const cell = escapeMarkdownCell
144
144
  export function formatRunReportMarkdown(result: RunResult): string {
145
145
  const t = tally(result.outcomes)
146
146
  const cached = t.cachedLocal + t.cachedRemote
147
+ // The terminal summary's words and counts: every task in the run counts
148
+ // (M, 2026-10-14), and a task the stop reached before it ran was never
149
+ // aborted.
150
+ const aborted = t.aborted - t.notRun
151
+ const count = t.total + aborted + t.notRun
147
152
  const parts = [
148
- `**${t.total} task${t.total === 1 ? '' : 's'}**`,
153
+ `**${count} task${count === 1 ? '' : 's'}**`,
149
154
  `${t.successful} success`,
150
155
  `${t.failed} failed`,
151
156
  // What the hits did to the disk, as the run's summary splits them.
@@ -154,15 +159,10 @@ export function formatRunReportMarkdown(result: RunResult): string {
154
159
  : '0 cached',
155
160
  ]
156
161
  if (t.skipped > 0) parts.push(`${t.skipped} skipped`)
162
+ if (aborted > 0) parts.push(`${aborted} aborted`)
163
+ if (t.notRun > 0) parts.push(`${t.notRun} not run`)
157
164
  parts.push(`${fmtDuration(t.executedMs)} total`)
158
165
  if (t.savedMs > 0) parts.push(`${fmtDuration(t.savedMs)} saved`)
159
- // The terminal summary's words: neither is in `total`, and a task the stop
160
- // reached before it ran was never aborted.
161
- const aborted = t.aborted - t.notRun
162
- const uncounted: string[] = []
163
- if (aborted > 0) uncounted.push(`${aborted} aborted`)
164
- if (t.notRun > 0) uncounted.push(`${t.notRun} not run`)
165
- if (uncounted.length > 0) parts.push(`not counted: ${uncounted.join(', ')}`)
166
166
 
167
167
  // Only a stop (a signal, `RunOptions.signal`) makes an `aborted` outcome,
168
168
  // so one is how an interrupted run tells itself from a failed one.
@@ -0,0 +1,48 @@
1
+ // A run's run-level settings, resolved once before any task: the flag
2
+ // (a RunOptions field) beats its variable, which beats `.vx/workspace`
3
+ // (src/settings.ts). A task's own `exec.timeout` / `exec.retries` is
4
+ // applied where the task runs, above all three.
5
+
6
+ import type { WorkspaceConfig } from '../config.js'
7
+ import { resolveSetting } from '../settings.js'
8
+ import { setSettleBounds, type SettleBound } from '../util/index.js'
9
+ import type { RunOptions } from './options.js'
10
+
11
+ const FROM_OPTIONS = {
12
+ concurrency: 'run.concurrency',
13
+ schedule: 'run.schedule',
14
+ timeout: 'run.timeout',
15
+ retries: 'run.retries',
16
+ continueMode: 'run.continue',
17
+ readyNotice: 'run.readyNotice',
18
+ download: 'cache.download',
19
+ } as const satisfies Partial<Record<keyof RunOptions, string>>
20
+
21
+ /**
22
+ * `options` with each run-level setting it leaves unset filled from the
23
+ * environment, then the workspace. Arms the settle bounds (`util/settle`,
24
+ * below settings) with the flag's or workspace's `run.killGrace` and
25
+ * `run.teardownTimeout`: the variable sits between them, read per call.
26
+ */
27
+ export function withRunSettings(
28
+ options: RunOptions,
29
+ workspace: WorkspaceConfig | null | undefined,
30
+ ): RunOptions {
31
+ const out: Record<string, unknown> = { ...options }
32
+ for (const [field, path] of Object.entries(FROM_OPTIONS)) {
33
+ const { value } = resolveSetting(path, workspace, options[field as keyof RunOptions])
34
+ if (value !== undefined) out[field] = value
35
+ }
36
+ const bound = (flag: number | undefined, path: string): SettleBound | undefined => {
37
+ if (flag !== undefined) return { ms: flag, from: 'flag' }
38
+ const { value } = resolveSetting(path, workspace, undefined, {})
39
+ return value === undefined ? undefined : { ms: value as number, from: 'workspace' }
40
+ }
41
+ const killGrace = bound(options.killGrace, 'run.killGrace')
42
+ const teardown = bound(options.teardownTimeout, 'run.teardownTimeout')
43
+ setSettleBounds({
44
+ ...(killGrace !== undefined ? { killGrace } : {}),
45
+ ...(teardown !== undefined ? { teardown } : {}),
46
+ })
47
+ return out as unknown as RunOptions
48
+ }