@vzn/vx 0.0.0 → 0.0.14
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.
- package/LICENSE +21 -0
- package/README.md +349 -1
- package/launcher.mjs +92 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +28 -0
- package/src/cache/inputs.ts +839 -0
- package/src/cache/layered-cache.ts +355 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +96 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +129 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +80 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +683 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +460 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +85 -0
- package/src/graph/index.ts +27 -0
- package/src/graph/scheduler.ts +677 -0
- package/src/graph/task-graph.ts +377 -0
- package/src/index.ts +222 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +939 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +174 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1927 -0
- package/src/orchestrator/options.ts +197 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +185 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +306 -0
- package/src/orchestrator/protocol.ts +156 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +887 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +315 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +253 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +358 -0
- package/src/orchestrator/upstream.ts +121 -0
- package/src/orchestrator/verify.ts +164 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +8 -0
- package/src/util/paths.ts +16 -0
- package/src/util/size.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +512 -0
- 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,306 @@
|
|
|
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
|
+
LayeredCache,
|
|
20
|
+
populateGitFilesCache,
|
|
21
|
+
} from '../cache/index.js'
|
|
22
|
+
import {
|
|
23
|
+
buildPackageGraph,
|
|
24
|
+
computeNestedProjectDirs,
|
|
25
|
+
computeWorkspaceFingerprint,
|
|
26
|
+
findWorkspaceRoot,
|
|
27
|
+
frozenProjectConfig,
|
|
28
|
+
listProjects,
|
|
29
|
+
loadProjectConfig,
|
|
30
|
+
loadWorkspace,
|
|
31
|
+
loadWorkspaceConfig,
|
|
32
|
+
readLockfile,
|
|
33
|
+
resolveCacheDir,
|
|
34
|
+
type ProjectEntry,
|
|
35
|
+
} from '../workspace/index.js'
|
|
36
|
+
import { buildTaskGraph, expandRequested, type TaskNode } from '../graph/index.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 precedence: an EXPLICITLY injected remote layer
|
|
201
|
+
// (RunOptions.remoteCache — a distribution agent or serve that already
|
|
202
|
+
// holds a wire client) wins outright; else a plugin's `cache` capability;
|
|
203
|
+
// else the local cache alone. Core ships no wire client — the remote
|
|
204
|
+
// cache is a plugin concern (native-cache-wire-2026-07). Injection
|
|
205
|
+
// winning prevents double-wrapping when the workspace also declares a
|
|
206
|
+
// 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)
|
|
221
|
+
|
|
222
|
+
const gitFilesCache = new GitFilesCache()
|
|
223
|
+
// Bulk-populate via a single `git ls-files` at the workspace root —
|
|
224
|
+
// partitions the output by project. Avoids one fork+exec per project
|
|
225
|
+
// (~5-10ms each on Linux; the dominant cold-start cost on big
|
|
226
|
+
// monorepos). When any loaded task declares inputs.workspaceFiles,
|
|
227
|
+
// the enumeration must see every file from the root (no pathspec
|
|
228
|
+
// scoping) and additionally stores a workspace-wide partition.
|
|
229
|
+
const usesWorkspaceInputs = [...projects.values()].some((p) =>
|
|
230
|
+
Object.values(p.config.tasks ?? {}).some(
|
|
231
|
+
(t) => (t.cache?.inputs.workspaceFiles?.length ?? 0) > 0,
|
|
232
|
+
),
|
|
233
|
+
)
|
|
234
|
+
await populateGitFilesCache(
|
|
235
|
+
workspaceRoot,
|
|
236
|
+
[...projects.values()].map((p) => p.dir),
|
|
237
|
+
gitFilesCache,
|
|
238
|
+
usesWorkspaceInputs,
|
|
239
|
+
)
|
|
240
|
+
const hashCache = createHashCache()
|
|
241
|
+
|
|
242
|
+
// Empty-cases bookkeeping. We still construct the cache + fingerprint
|
|
243
|
+
// so the caller's try/finally pattern can close it uniformly.
|
|
244
|
+
if (requested.length === 0) {
|
|
245
|
+
return {
|
|
246
|
+
workspaceRoot,
|
|
247
|
+
workspaceConfig,
|
|
248
|
+
cacheDir,
|
|
249
|
+
cache,
|
|
250
|
+
localCache,
|
|
251
|
+
priorities: new Map(),
|
|
252
|
+
nodes: new Map(),
|
|
253
|
+
workspaceFingerprint,
|
|
254
|
+
nestedDirsByProject,
|
|
255
|
+
gitFilesCache,
|
|
256
|
+
hashCache,
|
|
257
|
+
workspaceProjectCount: projectMetas.length,
|
|
258
|
+
empty: 'no-tasks-declared',
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const nodes = buildTaskGraph({
|
|
263
|
+
projects,
|
|
264
|
+
packageGraph,
|
|
265
|
+
requested,
|
|
266
|
+
...(options.excludeDependencies !== undefined
|
|
267
|
+
? { excludeDependencies: options.excludeDependencies }
|
|
268
|
+
: {}),
|
|
269
|
+
})
|
|
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.
|
|
277
|
+
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
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
workspaceRoot,
|
|
293
|
+
workspaceConfig,
|
|
294
|
+
cacheDir,
|
|
295
|
+
cache,
|
|
296
|
+
localCache,
|
|
297
|
+
priorities,
|
|
298
|
+
nodes,
|
|
299
|
+
workspaceFingerprint,
|
|
300
|
+
nestedDirsByProject,
|
|
301
|
+
gitFilesCache,
|
|
302
|
+
hashCache,
|
|
303
|
+
workspaceProjectCount: projectMetas.length,
|
|
304
|
+
empty: nodes.size === 0 ? 'empty-graph' : null,
|
|
305
|
+
}
|
|
306
|
+
}
|