@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -8,43 +8,58 @@
8
8
  // try/finally around its plan() call.
9
9
 
10
10
  import path from 'node:path'
11
- import type { ProjectConfig, WorkspaceConfig } from '../config.js'
12
- import { UserError } from '../util/index.js'
11
+ import type { WorkspaceConfig } from '../config.js'
12
+ import { mark, UserError } from '../util/index.js'
13
13
  import {
14
14
  Cache,
15
+ noteSchemaReset,
15
16
  type CacheLayer,
16
17
  type CachePolicy,
17
18
  FULL_CACHE_POLICY,
18
19
  GitFilesCache,
19
20
  LayeredCache,
20
- populateGitFilesCache,
21
+ applyGitEnumeration,
22
+ gitPathspecs,
23
+ startGitEnumeration,
24
+ type GitEnumeration,
21
25
  } from '../cache/index.js'
22
26
  import {
23
27
  buildPackageGraph,
24
28
  computeNestedProjectDirs,
25
- computeWorkspaceFingerprint,
29
+ computeWorkspaceFingerprints,
26
30
  findWorkspaceRoot,
27
- frozenProjectConfig,
28
31
  listProjects,
29
- loadProjectConfig,
30
32
  loadWorkspace,
31
- loadWorkspaceConfig,
33
+ FROZEN_WITHOUT_LOCK,
32
34
  readLockfile,
33
35
  resolveCacheDir,
34
36
  type ProjectEntry,
35
37
  } from '../workspace/index.js'
36
- import { buildTaskGraph, expandRequested, type TaskNode } from '../graph/index.js'
37
- import { resolveCache } from './plugin-host.js'
38
+ import {
39
+ buildTaskGraph,
40
+ expandRequested,
41
+ type TaskNode,
42
+ unresolvedRequests,
43
+ } from '../graph/index.js'
44
+ import {
45
+ applyGraphHooks,
46
+ applyKeyHooks,
47
+ applyScheduleHooks,
48
+ fingerprintClaims,
49
+ hasHook,
50
+ resolveCache,
51
+ } from './plugin-host.js'
52
+ import { loadProjects, loadWorkspacePlugins, type LoadedProjects } from './projects.js'
38
53
  import type { VxPlugin } from './plugin.js'
39
54
  import { createHashCache, type HashCache } from './task-hash.js'
40
55
  import type { Logger } from './logger.js'
41
56
  import type { RunOptions } from './options.js'
42
- import { type HistoryTable, LocalHistoryProvider } from './history.js'
43
- import { computePredictedPriorities } from './predict.js'
44
57
 
45
58
  export interface PreparedRun {
46
59
  workspaceRoot: string
47
60
  workspaceConfig: WorkspaceConfig | null
61
+ /** The workspace's declared plugins, in declaration order. Nothing is added. */
62
+ plugins: readonly VxPlugin[]
48
63
  cacheDir: string
49
64
  cache: CacheLayer
50
65
  /**
@@ -54,12 +69,35 @@ export interface PreparedRun {
54
69
  */
55
70
  localCache: Cache
56
71
  /**
57
- * Predicted priorities (history-aware critical-path). Populated only
58
- * when the workspace opts in via `defineWorkspace({ predictive: true })`.
59
- * Empty map otherwise — scheduler falls back to its baseline.
72
+ * True when `cache` is something other than the bare local handle — an
73
+ * injected remote layer, or one a plugin's `cache` capability built. The
74
+ * remote axes of the policy only mean anything then: without a remote
75
+ * layer a `remote:w` policy writes NOWHERE, so a task that believed it
76
+ * would be saved still cleans its outputs before executing.
77
+ */
78
+ hasRemoteLayer: boolean
79
+ /**
80
+ * Scheduling priorities from plugins' `schedule` stage (task id → weight,
81
+ * merged over the structural baseline by the scheduler). Empty when no
82
+ * plugin declares the stage.
60
83
  */
61
84
  priorities: ReadonlyMap<string, number>
62
85
  nodes: Map<string, TaskNode>
86
+ /**
87
+ * Requested task specs that matched NO project — a typo, or a stray
88
+ * positional (the value of an `=`-only flag written with a space).
89
+ * Nothing they asked for is in `nodes`, so callers must fail rather
90
+ * than run the remainder silently.
91
+ */
92
+ unresolvedTasks: readonly string[]
93
+ /** Every discovered project — the declared task names a typo is measured against. */
94
+ projects: ReadonlyMap<string, ProjectEntry>
95
+ /**
96
+ * True iff at least one package in the workspace has a `vx.config.*` at
97
+ * all — regardless of scope, so a `--filter` that matched nothing is not
98
+ * mistaken for a workspace vx was never set up in.
99
+ */
100
+ anyProjectConfig: boolean
63
101
  workspaceFingerprint: string
64
102
  nestedDirsByProject: Map<string, string[]>
65
103
  /**
@@ -104,31 +142,46 @@ export interface PreparedRun {
104
142
  * empty plan).
105
143
  */
106
144
  export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
145
+ mark('startup')
107
146
  const workspaceRoot = await findWorkspaceRoot(options.cwd)
147
+ // An UNSCOPED run (no explicit scope, at least one bare task name)
148
+ // enumerates the whole tree whatever the configs say, so git starts
149
+ // HERE — the walk needs only the root — and overlaps the workspace
150
+ // config, discovery, the cache open and the config evaluation. Its
151
+ // ~60 ms is the warm run's wall floor on a 1000-project tree; it used to
152
+ // start after discovery and the cache open, ~25 ms later. A scoped run
153
+ // waits: its pathspecs depend on which projects the configs pull in.
154
+ const unscoped =
155
+ options.projects === undefined && options.tasks.some((spec) => spec.indexOf('#') <= 0)
156
+ const earlyGit: Promise<GitEnumeration> | undefined = unscoped
157
+ ? startGitEnumeration(workspaceRoot, ['.'])
158
+ : undefined
159
+ // A broken config below throws before this is awaited; the detached
160
+ // handler keeps that from surfacing as an unhandled rejection (the real
161
+ // await further down still sees the error).
162
+ earlyGit?.catch(() => {})
108
163
  const workspace = await loadWorkspace(workspaceRoot)
109
- const workspaceConfig = await loadWorkspaceConfig(workspaceRoot)
164
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
165
+ log.status(m),
166
+ )
167
+ mark('workspace config')
110
168
  const projectMetas = await listProjects(workspace)
169
+ mark('discover projects')
111
170
 
112
171
  // SCOPED config loading: configs are programs, and evaluating 1090
113
172
  // of them costs ~200 ms — the dominant fixed cost of small runs.
114
- // Only the in-scope projects and their transitive dependency
115
- // closure can contribute graph nodes (frontier '^task' expansion
116
- // never escapes the closure), so only those configs are evaluated.
117
- // Side effect, deliberate and Turbo-like: a broken config in an
118
- // unrelated package no longer fails a scoped run — it surfaces
119
- // when that package enters a run's scope.
173
+ // Only the in-scope projects, their transitive dependency closure
174
+ // (which bounds '^task' frontier expansion) and any project named by
175
+ // a `pkg#task` dependsOn entry can contribute graph nodes, so only
176
+ // those configs are evaluated. Side effect, deliberate and
177
+ // Turbo-like: a broken config in an unrelated package no longer
178
+ // fails a scoped run — it surfaces when that package enters scope.
120
179
  const packageGraph = buildPackageGraph(projectMetas)
121
- const projectsWithConfigs = projectMetas.filter(
122
- (m): m is typeof m & { configPath: string } =>
123
- typeof m.configPath === 'string' && m.configPath.length > 0,
124
- )
125
- const haveConfig = new Set(projectsWithConfigs.map((m) => m.name))
126
-
127
- // Seeds: explicit scope, plus anchored pkg#task targets (which
128
- // bypass scope by design). With no explicit scope, bare task names
129
- // fan out across the whole workspace — but when EVERY spec is
130
- // anchored, the anchors alone are the scope and nothing else needs
131
- // its config evaluated.
180
+ // Seeds: explicit scope, plus anchored pkg#task targets (which bypass
181
+ // scope by design). With no explicit scope, bare task names fan out
182
+ // across the whole workspace — but when EVERY spec is anchored, the
183
+ // anchors alone are the scope and nothing else needs its config
184
+ // evaluated.
132
185
  const anchored: string[] = []
133
186
  let hasBare = false
134
187
  for (const spec of options.tasks) {
@@ -136,23 +189,29 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
136
189
  if (hashIdx > 0) anchored.push(spec.slice(0, hashIdx))
137
190
  else hasBare = true
138
191
  }
139
- const seeds = new Set<string>(
140
- options.projects
141
- ? options.projects.filter((p) => haveConfig.has(p))
142
- : hasBare
143
- ? haveConfig
144
- : [],
192
+ const seeds: 'all' | string[] =
193
+ options.projects !== undefined ? [...options.projects, ...anchored] : hasBare ? 'all' : anchored
194
+
195
+ // The local cache opens BEFORE the configs load: it is also where their
196
+ // cached evaluations live. `--cache-dir <path>` (RunOptions.cacheDir)
197
+ // overrides the workspace `cacheDir` field + the `.vx/cache` default;
198
+ // resolved relative to cwd.
199
+ const policy: CachePolicy = options.cache ?? FULL_CACHE_POLICY
200
+ const cacheDir = options.cacheDir
201
+ ? path.resolve(options.cwd, options.cacheDir)
202
+ : resolveCacheDir(workspaceRoot, workspaceConfig)
203
+ const localCache = new Cache(cacheDir, { read: policy.localRead, write: policy.localWrite })
204
+ noteSchemaReset(localCache, (m) => log.status(m))
205
+ // Two digests from one read: the config-evaluation cache keys on every
206
+ // file (a config may import a dependency), the task keys on the files no
207
+ // plugin claims (`VxPlugin.fingerprint`).
208
+ const fingerprints = await computeWorkspaceFingerprints(
209
+ workspaceRoot,
210
+ new Set(fingerprintClaims(plugins).keys()),
145
211
  )
146
- for (const project of anchored) {
147
- if (haveConfig.has(project)) seeds.add(project)
148
- }
149
- const needed = new Set<string>(seeds)
150
- for (const seed of seeds) {
151
- for (const dep of packageGraph.transitiveDeps(seed)) needed.add(dep)
152
- }
212
+ const workspaceFingerprint = fingerprints.unclaimed
213
+ mark('open cache')
153
214
 
154
- const projects = new Map<string, ProjectEntry>()
155
- const toLoad = projectsWithConfigs.filter((m) => needed.has(m.name))
156
215
  // Frozen mode (--frozen, CI): configs load FROM vx-lock.json after a
157
216
  // content-hash tripwire — no evaluation; env-dependent configs keep
158
217
  // their locked values. Default (local) runs ALWAYS evaluate live:
@@ -162,20 +221,32 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
162
221
  // See docs/design/config-lock-2026-06.md.
163
222
  const lock = options.frozen === true ? await readLockfile(workspaceRoot) : null
164
223
  if (options.frozen === true && lock === null) {
165
- throw new UserError(
166
- `--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it`,
167
- )
224
+ throw new UserError(FROZEN_WITHOUT_LOCK)
168
225
  }
169
- const configs = await Promise.all(
170
- toLoad.map((m) =>
171
- lock ? frozenProjectConfig(lock, m, workspaceRoot) : loadProjectConfig(m.configPath),
172
- ),
173
- )
174
- for (let i = 0; i < toLoad.length; i++) {
175
- const meta = toLoad[i]!
176
- const config = configs[i] as ProjectConfig
177
- projects.set(meta.name, { name: meta.name, dir: meta.dir, config })
226
+
227
+ let loaded: LoadedProjects
228
+ try {
229
+ loaded = await loadProjects({
230
+ workspaceRoot,
231
+ cacheDir,
232
+ plugins,
233
+ projectMetas,
234
+ packageGraph,
235
+ seeds,
236
+ closure: true,
237
+ lock,
238
+ evalCache: { store: localCache, workspaceFingerprint: fingerprints.all },
239
+ warn: (m) => log.status(m),
240
+ ...(options.staged !== undefined ? { staged: options.staged } : {}),
241
+ })
242
+ } catch (err) {
243
+ // The cache opened before the configs loaded (it holds their cached
244
+ // evaluations); a config error must not leak the handle.
245
+ localCache.close()
246
+ throw err
178
247
  }
248
+ const { projects, configured: projectsWithConfigs } = loaded
249
+ mark('load configs')
179
250
 
180
251
  // Boundary geometry considers every config-bearing project in the
181
252
  // workspace, loaded or not — an out-of-scope nested project must
@@ -189,35 +260,43 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
189
260
  : [...projects.keys()]
190
261
 
191
262
  const requested = expandRequested(options.tasks, candidateProjects, projects)
263
+ const unresolvedTasks = unresolvedRequests(options.tasks, candidateProjects, projects)
192
264
 
193
- const policy: CachePolicy = options.cache ?? FULL_CACHE_POLICY
194
- // `--cache-dir <path>` (RunOptions.cacheDir) overrides the workspace
195
- // `cacheDir` field + the `.vx/cache` default; resolved relative to cwd.
196
- const cacheDir = options.cacheDir
197
- ? path.resolve(options.cwd, options.cacheDir)
198
- : resolveCacheDir(workspaceRoot, workspaceConfig)
199
- const localCache = new Cache(cacheDir, { read: policy.localRead, write: policy.localWrite })
200
265
  // Cache seam precedence: an EXPLICITLY injected remote layer
201
- // (RunOptions.remoteCache — a distribution agent or serve that already
266
+ // (RunOptions.remoteCache — a distribution agent or daemon that already
202
267
  // holds a wire client) wins outright; else a plugin's `cache` capability;
203
268
  // else the local cache alone. Core ships no wire client — the remote
204
- // cache is a plugin concern (native-cache-wire-2026-07). Injection
269
+ // cache is a plugin concern (docs/patterns.md § Remote cache wire). Injection
205
270
  // winning prevents double-wrapping when the workspace also declares a
206
271
  // cache plugin.
207
- const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
208
- const cache = options.remoteCache
209
- ? new LayeredCache(localCache, options.remoteCache, {
210
- policy,
211
- onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
212
- })
213
- : await resolveCache(
214
- plugins,
215
- localCache,
216
- { workspaceRoot, cacheDir, warn: (m) => log.status(m), localCache, policy },
217
- log,
218
- () => localCache,
219
- )
220
- const workspaceFingerprint = await computeWorkspaceFingerprint(workspaceRoot)
272
+ let cache: CacheLayer
273
+ try {
274
+ cache = options.remoteCache
275
+ ? new LayeredCache(localCache, options.remoteCache, {
276
+ policy,
277
+ onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
278
+ })
279
+ : await resolveCache(plugins, {
280
+ workspaceRoot,
281
+ cacheDir,
282
+ warn: (m) => log.status(m),
283
+ localCache,
284
+ policy,
285
+ })
286
+ } catch (err) {
287
+ // A cache plugin that throws or returns something off-contract is
288
+ // refused by name; the local handle opened above must not leak with it.
289
+ localCache.close()
290
+ throw err
291
+ }
292
+ // Ask the LAYER, don't infer. Identity against `localCache` answers a
293
+ // DIFFERENT question — "did the plugin hand back something other than the
294
+ // handle I passed in?" — which an ordinary pass-through decorator (a
295
+ // metrics wrapper, a cache-dir redirect) with no remote at all answers
296
+ // yes to, skipping the remote-axis clamp below. `hasRemote` is the
297
+ // layer's own truthful answer; `LayeredCache` sets it, a bare `Cache`
298
+ // doesn't, and a third-party layer opts in when it really has a remote.
299
+ const hasRemoteLayer = cache.hasRemote === true
221
300
 
222
301
  const gitFilesCache = new GitFilesCache()
223
302
  // Bulk-populate via a single `git ls-files` at the workspace root —
@@ -231,12 +310,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
231
310
  (t) => (t.cache?.inputs.workspaceFiles?.length ?? 0) > 0,
232
311
  ),
233
312
  )
234
- await populateGitFilesCache(
235
- workspaceRoot,
236
- [...projects.values()].map((p) => p.dir),
237
- gitFilesCache,
238
- usesWorkspaceInputs,
239
- )
313
+ const projectDirs = [...projects.values()].map((p) => p.dir)
314
+ const enumeration = await (earlyGit ??
315
+ startGitEnumeration(
316
+ workspaceRoot,
317
+ gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
318
+ ))
319
+ applyGitEnumeration(enumeration, workspaceRoot, projectDirs, gitFilesCache, usesWorkspaceInputs)
320
+ mark('git enumeration')
240
321
  const hashCache = createHashCache()
241
322
 
242
323
  // Empty-cases bookkeeping. We still construct the cache + fingerprint
@@ -245,11 +326,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
245
326
  return {
246
327
  workspaceRoot,
247
328
  workspaceConfig,
329
+ plugins,
248
330
  cacheDir,
249
331
  cache,
250
332
  localCache,
333
+ hasRemoteLayer,
251
334
  priorities: new Map(),
252
335
  nodes: new Map(),
336
+ unresolvedTasks,
337
+ projects,
338
+ anyProjectConfig: projectsWithConfigs.length > 0,
253
339
  workspaceFingerprint,
254
340
  nestedDirsByProject,
255
341
  gitFilesCache,
@@ -267,35 +353,45 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
267
353
  ? { excludeDependencies: options.excludeDependencies }
268
354
  : {}),
269
355
  })
270
-
271
- // Predictive scheduling (architecture-review §8.4 / Phase 4): when
272
- // the workspace opts in via `predictive: true`, load history for
273
- // every node in the graph + compute expected-critical-path weights.
274
- // The scheduler applies them on top of the static baseline. Failing
275
- // open: any error in history loading degrades to baseline-only,
276
- // never to a broken run.
356
+ // The graph is built; what follows is the plugins' (graph, key,
357
+ // schedule). Two rows, so a plugin's key stage reads as its own cost
358
+ // and not as graph building — a lockfile plugin's 1000 stats per run
359
+ // hid inside one `prepare (graph)` row until 2026-09-10.
360
+ mark('build graph')
361
+ if (hasHook(plugins, 'graph')) {
362
+ await applyGraphHooks(plugins, nodes, {
363
+ workspaceRoot,
364
+ cacheDir,
365
+ warn: (m) => log.status(m),
366
+ requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
367
+ })
368
+ }
369
+ if (hasHook(plugins, 'key')) {
370
+ await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
371
+ }
277
372
  let priorities: ReadonlyMap<string, number> = new Map()
278
- if (workspaceConfig?.predictive === true) {
279
- const history = new LocalHistoryProvider(localCache.dbHandle())
280
- try {
281
- const ids = [...nodes.keys()]
282
- const table: HistoryTable = await history.loadFor(ids)
283
- priorities = computePredictedPriorities([...nodes.values()], table)
284
- } catch (err) {
285
- log.status(
286
- `[vx] predictive scheduling fell back to baseline: ${err instanceof Error ? err.message : String(err)}`,
287
- )
288
- }
373
+ if (hasHook(plugins, 'schedule')) {
374
+ priorities = await applyScheduleHooks(plugins, nodes, {
375
+ workspaceRoot,
376
+ cacheDir,
377
+ warn: (m) => log.status(m),
378
+ localCache,
379
+ })
289
380
  }
290
381
 
291
382
  return {
292
383
  workspaceRoot,
293
384
  workspaceConfig,
385
+ plugins,
294
386
  cacheDir,
295
387
  cache,
296
388
  localCache,
389
+ hasRemoteLayer,
297
390
  priorities,
298
391
  nodes,
392
+ unresolvedTasks,
393
+ projects,
394
+ anyProjectConfig: projectsWithConfigs.length > 0,
299
395
  workspaceFingerprint,
300
396
  nestedDirsByProject,
301
397
  gitFilesCache,