@vzn/vx 0.0.0 → 0.0.12

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,283 @@
1
+ // Plugin API — in-process extensions on top of the run event bus.
2
+ //
3
+ // Collapses what `architecture-review-2026-06.md §4.1` calls the
4
+ // in-process subscriber + the WS subscriber into ONE Plugin contract.
5
+ // Users register plugins via defineWorkspace({ plugins }); each plugin
6
+ // receives a PluginContext with the bus + workspace metadata and can
7
+ // install lifecycle hooks. Plugins observe; they do not redirect.
8
+ //
9
+ // Crash isolation: a plugin that throws on setup() is wrapped in a
10
+ // UserError-style failure message naming the plugin (clean abort, no
11
+ // stack), matching the existing 'broken vx.config.ts' error style. A
12
+ // plugin that throws inside a hook is logged + the plugin is disabled
13
+ // for the remainder of the run (mirrors the deleted Observer's
14
+ // makeSafeObserver pattern; surfaces are isolated from execution).
15
+ //
16
+ // Hook return values: today every hook is fire-and-forget (the bus IS
17
+ // the message pipe; hooks just give users an ergonomic place to read
18
+ // it). One write-capable hook is reserved for a future iteration —
19
+ // onCacheLookup returning { skip: true } — but not in v1.
20
+
21
+ import type { Cache, CacheLayer, CachePolicy } from '../cache/index.js'
22
+ import type { TaskNode, TaskOutcome } from '../graph/index.js'
23
+ import { UserError } from '../util/index.js'
24
+ import type { EventBus, RunStartInfo, WireEvent } from './events.js'
25
+ import type { RunBackend, RunRequest } from './protocol.js'
26
+ import type { TelemetryContext, TelemetrySink } from './telemetry.js'
27
+
28
+ /**
29
+ * A vx plugin. Contributes any subset of three RUN-LEVEL infrastructure
30
+ * capabilities — where work routes (backend), which cache is used (cache),
31
+ * who observes the run (eventSink). It NEVER changes how a task executes
32
+ * (Architecture principle #3: shell is the API). Registered explicitly in
33
+ * vx.workspace.ts via defineWorkspace({ plugins: [...] }). No auto-discovery.
34
+ *
35
+ * The old observe-only `Plugin` (`{ name, setup(ctx) }`) is a subset of
36
+ * this shape: a plugin with only `setup` installs and runs exactly as
37
+ * before via `installPlugins`. The new capabilities are consulted by
38
+ * `plugin-host.ts`, each falling back to today's default.
39
+ */
40
+ export interface VxPlugin {
41
+ /** Stable identifier, convention `'org/name'`. Used in errors + precedence logs. */
42
+ readonly name: string
43
+
44
+ // --- BEHAVIOR capabilities (change WHAT/HOW work runs — opt-in) -----------
45
+
46
+ /**
47
+ * Contribute a run backend. Returns a RunBackend (run(request) → result),
48
+ * or undefined to decline (core then tries the next plugin, else the
49
+ * fallback). Consulted ONCE per run, before scheduling. At most one
50
+ * plugin's backend is used (first non-undefined, in declaration order).
51
+ */
52
+ backend?(ctx: BackendContext): RunBackend | undefined | Promise<RunBackend | undefined>
53
+
54
+ /**
55
+ * Contribute a cache layer. Returns a CacheLayer wrapping (or replacing)
56
+ * the local Cache, or undefined to decline. Consulted ONCE per prepareRun.
57
+ * Precedence: first non-undefined plugin cache wins; else core's env-var
58
+ * Turbo-wire LayeredCache; else the bare local Cache.
59
+ */
60
+ cache?(ctx: CacheContext): CacheLayer | undefined | Promise<CacheLayer | undefined>
61
+
62
+ // --- OBSERVE-ONLY capability (cannot change behavior — by construction) ---
63
+
64
+ /**
65
+ * Contribute one or more telemetry sinks — the canonical data-export path.
66
+ * A sink receives versioned `TelemetryRecord` / `RunSummaryRecord` values
67
+ * and holds NO run handle (no bus, no cache, no request), so it provably
68
+ * cannot change what or how tasks run. ALL plugins' sinks are active at
69
+ * once (additive); a throwing/slow sink is isolated and can never fail or
70
+ * stall a run. This is THE export contract OTel, the manual HTTP API, and
71
+ * any third-party exporter all speak. See docs/design/observability-architecture-2026-06.md.
72
+ */
73
+ telemetry?(
74
+ ctx: TelemetryContext,
75
+ ):
76
+ | TelemetrySink
77
+ | TelemetrySink[]
78
+ | undefined
79
+ | Promise<TelemetrySink | TelemetrySink[] | undefined>
80
+
81
+ /**
82
+ * @deprecated Prefer `telemetry`. Contribute an event sink — a consumer of
83
+ * the serializable WireEvent stream, subscribed for the whole run via
84
+ * wireForwarder. Fire-and-forget; a throwing sink is isolated and cannot
85
+ * break the run. Kept as a back-compat path; `telemetry` is the canonical,
86
+ * analytics-shaped export contract.
87
+ */
88
+ eventSink?(ctx: EventSinkContext): EventSink | undefined | Promise<EventSink | undefined>
89
+
90
+ /**
91
+ * Optional one-time setup before any capability is consulted (validate the
92
+ * workspace, open a connection, read a token). Throwing aborts the run with
93
+ * a clean UserError naming the plugin — same contract as the old setup().
94
+ */
95
+ setup?(ctx: PluginSetupContext): void | Promise<void>
96
+
97
+ /** Optional teardown at end-of-run (flush a sink, close a socket). Errors are logged, never thrown. */
98
+ teardown?(): void | Promise<void>
99
+ }
100
+
101
+ /** A WireEvent consumer. onEvent is fire-and-forget; flush is awaited at teardown. */
102
+ export interface EventSink {
103
+ onEvent(event: WireEvent): void
104
+ flush?(): Promise<void>
105
+ }
106
+
107
+ /** Shared, read-only context every capability factory receives. */
108
+ interface BaseContext {
109
+ readonly workspaceRoot: string
110
+ readonly cacheDir: string
111
+ /** Funnel warnings into the run:status channel (framed output). */
112
+ warn(message: string): void
113
+ }
114
+
115
+ export interface PluginSetupContext extends BaseContext {}
116
+ export interface EventSinkContext extends BaseContext {}
117
+
118
+ export interface BackendContext extends BaseContext {
119
+ /** The resolved RunRequest about to be executed — cwd, tasks, policy, flow. */
120
+ readonly request: RunRequest
121
+ }
122
+
123
+ export interface CacheContext extends BaseContext {
124
+ /** The local Cache handle the plugin may wrap (LayeredCache(local, remote)). */
125
+ readonly localCache: Cache
126
+ /** The run's cache policy (the 4 read/write axes). */
127
+ readonly policy: CachePolicy
128
+ }
129
+
130
+ export interface PluginContext {
131
+ /** Where the workspace lives on disk. */
132
+ readonly workspaceRoot: string
133
+ /** Where vx's cache lives — read-only as far as the plugin is concerned. */
134
+ readonly cacheDir: string
135
+ /** The run event bus. A plugin can subscribe directly if its needs exceed the hooks. */
136
+ readonly bus: EventBus
137
+ /**
138
+ * Convenience: register a typed handler keyed off `RunEvent.kind`.
139
+ * Multiple hooks can chain via repeated calls.
140
+ */
141
+ on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
142
+ }
143
+
144
+ export type PluginHookName =
145
+ | 'onRunStart'
146
+ | 'onTaskStart'
147
+ | 'onTaskStdout'
148
+ | 'onTaskStderr'
149
+ | 'onTaskComplete'
150
+ | 'onRunStatus'
151
+ | 'onRunEnd'
152
+
153
+ export interface PluginHookHandlers {
154
+ onRunStart: (info: RunStartInfo) => void | Promise<void>
155
+ onTaskStart: (node: TaskNode) => void | Promise<void>
156
+ onTaskStdout: (node: TaskNode, chunk: string) => void | Promise<void>
157
+ onTaskStderr: (node: TaskNode, chunk: string) => void | Promise<void>
158
+ onTaskComplete: (node: TaskNode, outcome: TaskOutcome) => void | Promise<void>
159
+ onRunStatus: (line: string) => void | Promise<void>
160
+ onRunEnd: () => void | Promise<void>
161
+ }
162
+
163
+ export interface Plugin {
164
+ /** Logged on errors; the convention is `'org/plugin-name'`. */
165
+ readonly name: string
166
+ /**
167
+ * Called once at the start of every run. Register hooks via
168
+ * `ctx.on(name, fn)` or subscribe to `ctx.bus` directly. Returning
169
+ * a promise lets a plugin do async setup; the bus subscription must
170
+ * be installed synchronously inside setup() so no events are missed.
171
+ *
172
+ * OPTIONAL: a capability-only plugin (one that contributes
173
+ * `backend`/`cache`/`eventSink` but no `setup`) is simply skipped by
174
+ * `installPlugins` — its capabilities are consulted by `plugin-host.ts`.
175
+ */
176
+ setup?(ctx: PluginContext): void | Promise<void>
177
+ }
178
+
179
+ export interface InstallPluginsArgs {
180
+ plugins: readonly Plugin[]
181
+ workspaceRoot: string
182
+ cacheDir: string
183
+ bus: EventBus
184
+ /**
185
+ * Where plugin-throw warnings go. Defaults to console.error; passing
186
+ * a callback lets the orchestrator funnel into the framed-output
187
+ * `run:status` channel.
188
+ */
189
+ warn?: (message: string) => void
190
+ }
191
+
192
+ /**
193
+ * Install every plugin's `setup` hook against a shared bus + context.
194
+ * Synchronous loop; setup() promises are awaited in order so a plugin's
195
+ * hooks are subscribed before the next plugin's setup runs. Throws if any
196
+ * plugin's setup() throws (the run cannot start with a broken plugin).
197
+ *
198
+ * A plugin without a `setup` is a capability-only plugin (backend / cache
199
+ * / eventSink) — skipped here; those capabilities are consulted by
200
+ * `plugin-host.ts`.
201
+ */
202
+ export async function installPlugins(args: InstallPluginsArgs): Promise<() => void> {
203
+ const { plugins, bus, workspaceRoot, cacheDir } = args
204
+ const warn = args.warn ?? ((m) => console.error(m))
205
+ const disposers: Array<() => void> = []
206
+ const disabled = new Set<string>()
207
+
208
+ for (const plugin of plugins) {
209
+ if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
210
+ throw new UserError('plugin missing `name` field')
211
+ }
212
+ // No setup → a capability-only plugin (backend / cache / eventSink),
213
+ // consulted by plugin-host.ts; skip the hook install. A setup that's
214
+ // present but not callable is a real authoring error — reject it.
215
+ if (plugin.setup === undefined) continue
216
+ if (typeof plugin.setup !== 'function') {
217
+ throw new UserError(`plugin '${plugin.name}' setup is not a function`)
218
+ }
219
+
220
+ const ctx: PluginContext = {
221
+ workspaceRoot,
222
+ cacheDir,
223
+ bus,
224
+ on(hook, handler) {
225
+ const dispose = bus.subscribe((event) => {
226
+ if (disabled.has(plugin.name)) return
227
+ // void each handler call: hooks may return Promise; we
228
+ // intentionally don't await (the bus is synchronous;
229
+ // long-running plugin work happens off the critical path).
230
+ try {
231
+ switch (hook) {
232
+ case 'onRunStart':
233
+ if (event.kind === 'run:start')
234
+ void (handler as PluginHookHandlers['onRunStart'])(event.info)
235
+ break
236
+ case 'onTaskStart':
237
+ if (event.kind === 'task:start')
238
+ void (handler as PluginHookHandlers['onTaskStart'])(event.node)
239
+ break
240
+ case 'onTaskStdout':
241
+ if (event.kind === 'task:stdout')
242
+ void (handler as PluginHookHandlers['onTaskStdout'])(event.node, event.chunk)
243
+ break
244
+ case 'onTaskStderr':
245
+ if (event.kind === 'task:stderr')
246
+ void (handler as PluginHookHandlers['onTaskStderr'])(event.node, event.chunk)
247
+ break
248
+ case 'onTaskComplete':
249
+ if (event.kind === 'task:complete')
250
+ void (handler as PluginHookHandlers['onTaskComplete'])(event.node, event.outcome)
251
+ break
252
+ case 'onRunStatus':
253
+ if (event.kind === 'run:status')
254
+ void (handler as PluginHookHandlers['onRunStatus'])(event.line)
255
+ break
256
+ case 'onRunEnd':
257
+ if (event.kind === 'run:end') void (handler as PluginHookHandlers['onRunEnd'])()
258
+ break
259
+ }
260
+ } catch (err) {
261
+ disabled.add(plugin.name)
262
+ warn(
263
+ `[vx] plugin '${plugin.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
264
+ )
265
+ }
266
+ })
267
+ disposers.push(dispose)
268
+ },
269
+ }
270
+
271
+ try {
272
+ await plugin.setup(ctx)
273
+ } catch (err) {
274
+ throw new UserError(
275
+ `plugin '${plugin.name}' failed to load: ${err instanceof Error ? err.message : String(err)}`,
276
+ )
277
+ }
278
+ }
279
+
280
+ return () => {
281
+ for (const d of disposers) d()
282
+ }
283
+ }
@@ -0,0 +1,106 @@
1
+ // Predictive scheduling — use HistoryTable to compute expected
2
+ // remaining critical-path duration per node. Replaces the scheduler's
3
+ // graph-counter heuristic priority for runs that opt in via
4
+ // defineWorkspace({ predictive: true }) (predictive-execution-2026-06.md
5
+ // Phase B + architecture-review-2026-06.md §8.4).
6
+ //
7
+ // Pure functions; no side effects; tested standalone. The scheduler
8
+ // reads the priorities at queue time, picks the max.
9
+
10
+ import type { TaskNode } from '../graph/index.js'
11
+ import type { HistoryTable } from './history.js'
12
+
13
+ /** Default duration when neither task history nor workspace median exists. */
14
+ const DEFAULT_DURATION_MS = 1000
15
+
16
+ /**
17
+ * For each node, the expected remaining critical-path duration (its own
18
+ * p50 + the max over its dependents). Cache hits are NOT modeled as
19
+ * zero-cost here because predicting cache state requires knowing the
20
+ * key + probing the layer — the run-time scheduler can flip the
21
+ * priority once a hit is observed; the upfront estimate is "what if
22
+ * everything ran."
23
+ */
24
+ export function computePredictedPriorities(
25
+ nodes: readonly TaskNode[],
26
+ history: HistoryTable,
27
+ ): ReadonlyMap<string, number> {
28
+ // Workspace-median fallback: across every history entry that has a
29
+ // p50, pick the median of those p50s. If history is sparse, fall
30
+ // back to DEFAULT_DURATION_MS.
31
+ const p50s: number[] = []
32
+ for (const h of history.values()) {
33
+ if (h.p50DurationMs !== undefined) p50s.push(h.p50DurationMs)
34
+ }
35
+ p50s.sort((a, b) => a - b)
36
+ const workspaceMedian =
37
+ p50s.length > 0
38
+ ? (p50s[Math.floor(p50s.length / 2)] ?? DEFAULT_DURATION_MS)
39
+ : DEFAULT_DURATION_MS
40
+
41
+ // Build a reverse-adjacency map so we can resolve dependents per
42
+ // node in O(1). The TaskNode graph carries dependsOn (upstream); we
43
+ // invert.
44
+ const dependentsOf = buildDependentsIndex(nodes)
45
+ const ownDuration = (n: TaskNode): number => {
46
+ const h = history.get(n.id)
47
+ return h?.p50DurationMs ?? workspaceMedian
48
+ }
49
+
50
+ // Memoized topo-DP. visit[id] = the expected critical path FROM this
51
+ // node down to a leaf. We compute bottom-up using a stack-based
52
+ // walker — iterative to avoid V8 stack-frame ceilings on deep graphs.
53
+ const memo = new Map<string, number>()
54
+ const nodeById = new Map<string, TaskNode>(nodes.map((n) => [n.id, n]))
55
+
56
+ const stack: TaskNode[] = nodes.slice()
57
+ // First pass: ensure deepest-first ordering via post-order traversal.
58
+ const order: TaskNode[] = []
59
+ const visited = new Set<string>()
60
+ while (stack.length > 0) {
61
+ const top = stack[stack.length - 1]!
62
+ if (visited.has(top.id)) {
63
+ stack.pop()
64
+ continue
65
+ }
66
+ const deps = dependentsOf.get(top.id) ?? []
67
+ let pushed = false
68
+ for (const d of deps) {
69
+ const n = nodeById.get(d)
70
+ if (n && !visited.has(n.id) && !stack.includes(n)) {
71
+ stack.push(n)
72
+ pushed = true
73
+ break
74
+ }
75
+ }
76
+ if (!pushed) {
77
+ visited.add(top.id)
78
+ order.push(top)
79
+ stack.pop()
80
+ }
81
+ }
82
+
83
+ for (const n of order) {
84
+ const own = ownDuration(n)
85
+ let downstream = 0
86
+ for (const dep of dependentsOf.get(n.id) ?? []) {
87
+ const d = memo.get(dep) ?? 0
88
+ if (d > downstream) downstream = d
89
+ }
90
+ memo.set(n.id, own + downstream)
91
+ }
92
+
93
+ return memo
94
+ }
95
+
96
+ function buildDependentsIndex(nodes: readonly TaskNode[]): Map<string, string[]> {
97
+ const out = new Map<string, string[]>()
98
+ for (const n of nodes) {
99
+ for (const upstreamId of n.deps) {
100
+ const list = out.get(upstreamId)
101
+ if (list) list.push(n.id)
102
+ else out.set(upstreamId, [n.id])
103
+ }
104
+ }
105
+ return out
106
+ }
@@ -0,0 +1,297 @@
1
+ // Shared setup for `run()` and `planRun()`. Both entry points perform
2
+ // the same workspace-discovery → config-load → graph-build → cache-
3
+ // open sequence; this module centralises it so the two callers stay
4
+ // thin.
5
+ //
6
+ // The caller owns the returned `cache.close()` lifetime: `run()`
7
+ // closes at the bottom of execution, `planRun()` does so via
8
+ // try/finally around its plan() call.
9
+
10
+ import path from 'node:path'
11
+ import type { ProjectConfig, WorkspaceConfig } from '../config.js'
12
+ import { UserError } from '../util/index.js'
13
+ import {
14
+ Cache,
15
+ type CacheLayer,
16
+ type CachePolicy,
17
+ FULL_CACHE_POLICY,
18
+ GitFilesCache,
19
+ populateGitFilesCache,
20
+ } from '../cache/index.js'
21
+ import {
22
+ buildPackageGraph,
23
+ computeNestedProjectDirs,
24
+ computeWorkspaceFingerprint,
25
+ findWorkspaceRoot,
26
+ frozenProjectConfig,
27
+ listProjects,
28
+ loadProjectConfig,
29
+ loadWorkspace,
30
+ loadWorkspaceConfig,
31
+ readLockfile,
32
+ resolveCacheDir,
33
+ type ProjectEntry,
34
+ } from '../workspace/index.js'
35
+ import { buildTaskGraph, expandRequested, type TaskNode } from '../graph/index.js'
36
+ import { wrapWithRemoteCache } from './remote-cache-setup.js'
37
+ import { resolveCache } from './plugin-host.js'
38
+ import type { VxPlugin } from './plugin.js'
39
+ import { createHashCache, type HashCache } from './task-hash.js'
40
+ import type { Logger } from './logger.js'
41
+ import type { RunOptions } from './options.js'
42
+ import { type HistoryTable, LocalHistoryProvider } from './history.js'
43
+ import { computePredictedPriorities } from './predict.js'
44
+
45
+ export interface PreparedRun {
46
+ workspaceRoot: string
47
+ workspaceConfig: WorkspaceConfig | null
48
+ cacheDir: string
49
+ cache: CacheLayer
50
+ /**
51
+ * The local Cache handle (unwrapped). `cache` may be a LayeredCache
52
+ * wrapping this; subsystems that need the raw SQLite (e.g.
53
+ * LocalHistoryProvider) read directly from here.
54
+ */
55
+ localCache: Cache
56
+ /**
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.
60
+ */
61
+ priorities: ReadonlyMap<string, number>
62
+ nodes: Map<string, TaskNode>
63
+ workspaceFingerprint: string
64
+ nestedDirsByProject: Map<string, string[]>
65
+ /**
66
+ * Per-run memo for `git ls-files` output, keyed by project dir.
67
+ * Without this, every task in a project re-spawns git just to
68
+ * enumerate its input file set (3× per project for build / test /
69
+ * lint, etc.) — observable in cache-hit run times.
70
+ */
71
+ gitFilesCache: GitFilesCache
72
+ /** Every project discovery found, in or out of scope — header scope bar. */
73
+ workspaceProjectCount: number
74
+ /**
75
+ * Per-run memo for derived hashes — project package.json bytes
76
+ * keyed by projectDir, task-config hash keyed by config object
77
+ * identity. Shared across every task's `computeTaskHash` call so
78
+ * the same project's package.json (and the same task config
79
+ * object) aren't re-hashed on every task in that project.
80
+ */
81
+ hashCache: HashCache
82
+ /**
83
+ * Reason `nodes` is empty. `null` when the prepared run is ready to
84
+ * execute. Either:
85
+ * - `'no-tasks-declared'` — `requested.length === 0` after the
86
+ * user's task names were resolved against `projects`. Typically
87
+ * a typo'd task name; `run()` treats this as a CI footgun and
88
+ * returns NOT-ok.
89
+ * - `'empty-graph'` — `requested` was non-empty but the
90
+ * graph builder still produced no nodes. Defensive; unreachable
91
+ * under current `buildTaskGraph` semantics.
92
+ */
93
+ empty: null | 'no-tasks-declared' | 'empty-graph'
94
+ }
95
+
96
+ /**
97
+ * Build the prepared-run context: workspace discovery, project-config
98
+ * load, package + task graph, cache handle (local, optionally wrapped
99
+ * in a remote layer). Caller owns `cache.close()`.
100
+ *
101
+ * Returns even when nothing can run — the `empty` field tells the
102
+ * caller why. We never throw on "no tasks"; behavior on that case is
103
+ * caller-specific (run logs + returns NOT-ok; planRun returns an
104
+ * empty plan).
105
+ */
106
+ export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
107
+ const workspaceRoot = await findWorkspaceRoot(options.cwd)
108
+ const workspace = await loadWorkspace(workspaceRoot)
109
+ const workspaceConfig = await loadWorkspaceConfig(workspaceRoot)
110
+ const projectMetas = await listProjects(workspace)
111
+
112
+ // SCOPED config loading: configs are programs, and evaluating 1090
113
+ // 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.
120
+ 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.
132
+ const anchored: string[] = []
133
+ let hasBare = false
134
+ for (const spec of options.tasks) {
135
+ const hashIdx = spec.indexOf('#')
136
+ if (hashIdx > 0) anchored.push(spec.slice(0, hashIdx))
137
+ else hasBare = true
138
+ }
139
+ const seeds = new Set<string>(
140
+ options.projects
141
+ ? options.projects.filter((p) => haveConfig.has(p))
142
+ : hasBare
143
+ ? haveConfig
144
+ : [],
145
+ )
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
+ }
153
+
154
+ const projects = new Map<string, ProjectEntry>()
155
+ const toLoad = projectsWithConfigs.filter((m) => needed.has(m.name))
156
+ // Frozen mode (--frozen, CI): configs load FROM vx-lock.json after a
157
+ // content-hash tripwire — no evaluation; env-dependent configs keep
158
+ // their locked values. Default (local) runs ALWAYS evaluate live:
159
+ // a byte hash can't see a config's import closure (shared presets),
160
+ // so consuming the lock by default would silently serve stale
161
+ // freezes. `vx lock --check` is the full re-evaluation audit.
162
+ // See docs/design/config-lock-2026-06.md.
163
+ const lock = options.frozen === true ? await readLockfile(workspaceRoot) : null
164
+ 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
+ )
168
+ }
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 })
178
+ }
179
+
180
+ // Boundary geometry considers every config-bearing project in the
181
+ // workspace, loaded or not — an out-of-scope nested project must
182
+ // still fence its files off from its parent's globs.
183
+ const nestedDirsByProject = computeNestedProjectDirs(
184
+ projectsWithConfigs.map((m) => ({ name: m.name, dir: m.dir })),
185
+ )
186
+
187
+ const candidateProjects = options.projects
188
+ ? options.projects.filter((p) => projects.has(p))
189
+ : [...projects.keys()]
190
+
191
+ const requested = expandRequested(options.tasks, candidateProjects, projects)
192
+
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
+ // Cache seam: a plugin's `cache` capability takes precedence; otherwise
201
+ // the env-var Turbo-wire fallback (today's unconditional behavior). With
202
+ // no cache plugin this is byte-identical to wrapWithRemoteCache.
203
+ const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
204
+ const cache = await resolveCache(
205
+ plugins,
206
+ localCache,
207
+ { workspaceRoot, cacheDir, warn: (m) => log.status(m), localCache, policy },
208
+ log,
209
+ () => wrapWithRemoteCache(localCache, log, policy),
210
+ )
211
+ const workspaceFingerprint = await computeWorkspaceFingerprint(workspaceRoot)
212
+
213
+ const gitFilesCache = new GitFilesCache()
214
+ // Bulk-populate via a single `git ls-files` at the workspace root —
215
+ // partitions the output by project. Avoids one fork+exec per project
216
+ // (~5-10ms each on Linux; the dominant cold-start cost on big
217
+ // monorepos). When any loaded task declares inputs.workspaceFiles,
218
+ // the enumeration must see every file from the root (no pathspec
219
+ // scoping) and additionally stores a workspace-wide partition.
220
+ const usesWorkspaceInputs = [...projects.values()].some((p) =>
221
+ Object.values(p.config.tasks ?? {}).some(
222
+ (t) => (t.cache?.inputs.workspaceFiles?.length ?? 0) > 0,
223
+ ),
224
+ )
225
+ await populateGitFilesCache(
226
+ workspaceRoot,
227
+ [...projects.values()].map((p) => p.dir),
228
+ gitFilesCache,
229
+ usesWorkspaceInputs,
230
+ )
231
+ const hashCache = createHashCache()
232
+
233
+ // Empty-cases bookkeeping. We still construct the cache + fingerprint
234
+ // so the caller's try/finally pattern can close it uniformly.
235
+ if (requested.length === 0) {
236
+ return {
237
+ workspaceRoot,
238
+ workspaceConfig,
239
+ cacheDir,
240
+ cache,
241
+ localCache,
242
+ priorities: new Map(),
243
+ nodes: new Map(),
244
+ workspaceFingerprint,
245
+ nestedDirsByProject,
246
+ gitFilesCache,
247
+ hashCache,
248
+ workspaceProjectCount: projectMetas.length,
249
+ empty: 'no-tasks-declared',
250
+ }
251
+ }
252
+
253
+ const nodes = buildTaskGraph({
254
+ projects,
255
+ packageGraph,
256
+ requested,
257
+ ...(options.excludeDependencies !== undefined
258
+ ? { excludeDependencies: options.excludeDependencies }
259
+ : {}),
260
+ })
261
+
262
+ // Predictive scheduling (architecture-review §8.4 / Phase 4): when
263
+ // the workspace opts in via `predictive: true`, load history for
264
+ // every node in the graph + compute expected-critical-path weights.
265
+ // The scheduler applies them on top of the static baseline. Failing
266
+ // open: any error in history loading degrades to baseline-only,
267
+ // never to a broken run.
268
+ let priorities: ReadonlyMap<string, number> = new Map()
269
+ if (workspaceConfig?.predictive === true) {
270
+ const history = new LocalHistoryProvider(localCache.dbHandle())
271
+ try {
272
+ const ids = [...nodes.keys()]
273
+ const table: HistoryTable = await history.loadFor(ids)
274
+ priorities = computePredictedPriorities([...nodes.values()], table)
275
+ } catch (err) {
276
+ log.status(
277
+ `[vx] predictive scheduling fell back to baseline: ${err instanceof Error ? err.message : String(err)}`,
278
+ )
279
+ }
280
+ }
281
+
282
+ return {
283
+ workspaceRoot,
284
+ workspaceConfig,
285
+ cacheDir,
286
+ cache,
287
+ localCache,
288
+ priorities,
289
+ nodes,
290
+ workspaceFingerprint,
291
+ nestedDirsByProject,
292
+ gitFilesCache,
293
+ hashCache,
294
+ workspaceProjectCount: projectMetas.length,
295
+ empty: nodes.size === 0 ? 'empty-graph' : null,
296
+ }
297
+ }