@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,887 @@
|
|
|
1
|
+
// End-to-end wiring: discover workspace -> load configs -> build graph ->
|
|
2
|
+
// run with caching. Each step delegates to a single-purpose sibling file
|
|
3
|
+
// so the layers can be swapped without touching the others.
|
|
4
|
+
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import {
|
|
7
|
+
type CacheLayer,
|
|
8
|
+
type CachePolicy,
|
|
9
|
+
FULL_CACHE_POLICY,
|
|
10
|
+
type InvocationRecord,
|
|
11
|
+
LayeredCache,
|
|
12
|
+
type RunRecord,
|
|
13
|
+
} from '../cache/index.js'
|
|
14
|
+
import { VERSION } from '../version.js'
|
|
15
|
+
import { initSandbox, probeSandbox, resetSandbox, signalExitCode } from '../exec/index.js'
|
|
16
|
+
import {
|
|
17
|
+
isGroupTask,
|
|
18
|
+
markSurfacedDeps,
|
|
19
|
+
runGraph,
|
|
20
|
+
type TaskNode,
|
|
21
|
+
type TaskOutcome,
|
|
22
|
+
} from '../graph/index.js'
|
|
23
|
+
import { ulid, UserError } from '../util/index.js'
|
|
24
|
+
import { executeTask } from './execute-task.js'
|
|
25
|
+
import { resolveResourceCosts } from './resources.js'
|
|
26
|
+
import { computeTaskHash } from './task-hash.js'
|
|
27
|
+
import { busLogger, createEventBus, terminalSubscriber } from './events.js'
|
|
28
|
+
import { installPlugins } from './plugin.js'
|
|
29
|
+
import type { VxPlugin } from './plugin.js'
|
|
30
|
+
import { subscribeEventSinks, teardownPlugins } from './plugin-host.js'
|
|
31
|
+
import type { SubscribedEventSinks } from './plugin-host.js'
|
|
32
|
+
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
33
|
+
import { deriveCacheSource, TELEMETRY_SCHEMA_VERSION } from './telemetry.js'
|
|
34
|
+
import type { RunContextRecord, RunSummaryRecord, TaskTelemetry } from './telemetry.js'
|
|
35
|
+
import { defaultLogger, resolveOutputView } from './logger.js'
|
|
36
|
+
import { detectColors } from './colors.js'
|
|
37
|
+
import { formatPersistentList } from './framed-output.js'
|
|
38
|
+
import { plan, type RunPlan } from './plan.js'
|
|
39
|
+
import { prepareRun } from './prepare.js'
|
|
40
|
+
import {
|
|
41
|
+
captureGitContext,
|
|
42
|
+
captureHostContext,
|
|
43
|
+
captureWorkspaceIdentity,
|
|
44
|
+
detectCi,
|
|
45
|
+
} from './run-context.js'
|
|
46
|
+
import { startRemotePrefetch } from './remote-prefetch.js'
|
|
47
|
+
import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.js'
|
|
48
|
+
import { formatVerifySection } from './verify.js'
|
|
49
|
+
|
|
50
|
+
const EMPTY_SHORT_CIRCUIT: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
|
|
51
|
+
import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
|
|
52
|
+
import { formatRunSummary } from './summary.js'
|
|
53
|
+
import type { RunOptions, RunSummary } from './options.js'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
|
|
57
|
+
* timeout default. A missing/empty/non-positive-integer value yields
|
|
58
|
+
* `undefined` (ignored), so a typo never silently disables a task's own
|
|
59
|
+
* `exec.timeout`.
|
|
60
|
+
*/
|
|
61
|
+
function readTaskTimeoutEnv(): number | undefined {
|
|
62
|
+
const raw = process.env['VX_TASK_TIMEOUT']
|
|
63
|
+
if (raw === undefined || raw === '') return undefined
|
|
64
|
+
const n = Number(raw)
|
|
65
|
+
return Number.isInteger(n) && n > 0 ? n : undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compact the 4-axis cache policy into the `invocations.cache_policy`
|
|
70
|
+
* column string. Each enabled axis contributes its flag; a fully-on
|
|
71
|
+
* policy reads `'lR,lW,rR,rW'`. Pure presentation — never affects the key.
|
|
72
|
+
*/
|
|
73
|
+
function compactCachePolicy(p: CachePolicy): string {
|
|
74
|
+
const parts: string[] = []
|
|
75
|
+
if (p.localRead) parts.push('lR')
|
|
76
|
+
if (p.localWrite) parts.push('lW')
|
|
77
|
+
if (p.remoteRead) parts.push('rR')
|
|
78
|
+
if (p.remoteWrite) parts.push('rW')
|
|
79
|
+
return parts.join(',')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The perf firewall for the local short-circuit. Always-on; the only
|
|
84
|
+
* gates are correctness/no-op gates:
|
|
85
|
+
* - LOCAL-ONLY cache. Under a LayeredCache, `cache.get` is a remote
|
|
86
|
+
* READ-THROUGH — the up-front classify is awaited before scheduling,
|
|
87
|
+
* so N remote GETs would land on the critical path before any task
|
|
88
|
+
* starts. Remote runs are owned by `startRemotePrefetch`, which
|
|
89
|
+
* overlaps the GETs with execution instead (fire-and-forget) and
|
|
90
|
+
* ingests hits into local for execute-task's lazy probe.
|
|
91
|
+
* - the policy reads locally (a `--no-cache`/`--force`/`--cache=local:`
|
|
92
|
+
* run reads nothing locally → nothing to restore → skip);
|
|
93
|
+
* - the graph has at least one dependency edge (a flat graph has no
|
|
94
|
+
* ordering to bypass — restoring is what execute() already does, so
|
|
95
|
+
* there's no upside and we avoid the upfront probe pass).
|
|
96
|
+
* The per-task correctness gates (stable key, no workspace-outputs in
|
|
97
|
+
* graph) live in `startLocalShortCircuit`.
|
|
98
|
+
*/
|
|
99
|
+
export function shouldShortCircuit(
|
|
100
|
+
nodes: Map<string, TaskNode>,
|
|
101
|
+
policy: CachePolicy,
|
|
102
|
+
cache: CacheLayer,
|
|
103
|
+
): boolean {
|
|
104
|
+
if (cache instanceof LayeredCache) return false
|
|
105
|
+
if (!policy.localRead) return false
|
|
106
|
+
for (const node of nodes.values()) if (node.deps.length > 0) return true
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function run(options: RunOptions): Promise<RunSummary> {
|
|
111
|
+
// Color decision: a custom logger (tests, embedders) handles its
|
|
112
|
+
// own formatting and asserts on plain strings, so we suppress
|
|
113
|
+
// ANSI escapes for them. Only the defaultLogger (real terminal
|
|
114
|
+
// output) gets colors, gated by NO_COLOR / FORCE_COLOR / isTTY.
|
|
115
|
+
const colors = options.log ? { enabled: false } : detectColors()
|
|
116
|
+
// The concrete renderer (default terminal logger, or a custom embedder
|
|
117
|
+
// logger) no longer receives orchestrator calls directly — it SUBSCRIBES
|
|
118
|
+
// to the run event bus as the always-on, in-process terminal surface.
|
|
119
|
+
// Every existing `log.X(...)` call site emits a RunEvent through
|
|
120
|
+
// `busLogger`, so the same output flows through the event stream and
|
|
121
|
+
// future off-thread surfaces (web devtool, TUI, MCP) attach as
|
|
122
|
+
// additional subscribers. The fan-out is synchronous and order-
|
|
123
|
+
// preserving, so terminal output is byte-identical to a direct call.
|
|
124
|
+
// See docs/design/event-stream-2026-06.md.
|
|
125
|
+
const sink = options.log ?? defaultLogger(colors, resolveOutputView(options))
|
|
126
|
+
// An injected bus (e.g. from `--ui`) already has surfaces subscribed;
|
|
127
|
+
// we just add the terminal renderer. Otherwise a fresh internal bus.
|
|
128
|
+
const bus = options.bus ?? createEventBus()
|
|
129
|
+
bus.subscribe(terminalSubscriber(sink))
|
|
130
|
+
const log = busLogger(bus)
|
|
131
|
+
|
|
132
|
+
const prepared = await prepareRun(options, log)
|
|
133
|
+
if (prepared.empty !== null) {
|
|
134
|
+
// `no-tasks-declared` is almost always a typo in CI; we surface
|
|
135
|
+
// a clear message and return NOT-ok so the script exits 1.
|
|
136
|
+
// `empty-graph` is defensive — unreachable under current
|
|
137
|
+
// buildTaskGraph semantics but logged just in case.
|
|
138
|
+
const msg =
|
|
139
|
+
prepared.empty === 'no-tasks-declared'
|
|
140
|
+
? `No projects declare task(s): ${options.tasks.join(', ')}.`
|
|
141
|
+
: 'No tasks to run.'
|
|
142
|
+
log.status(msg)
|
|
143
|
+
prepared.cache.close()
|
|
144
|
+
return { ok: false, outcomes: [] }
|
|
145
|
+
}
|
|
146
|
+
// Install user plugins as additional bus subscribers BEFORE the run
|
|
147
|
+
// starts emitting events. `installPlugins` runs each plugin's optional
|
|
148
|
+
// `setup` hook (the old observe-only path); `subscribeEventSinks` wires
|
|
149
|
+
// each plugin's `eventSink` capability onto the bus via wireForwarder.
|
|
150
|
+
// Both fail-fast on a setup() throw with a clean UserError naming the
|
|
151
|
+
// plugin; eventSink init failures are isolated (observability never
|
|
152
|
+
// breaks a run). With no plugin, both are no-ops.
|
|
153
|
+
let disposePlugins: (() => void) | undefined
|
|
154
|
+
let eventSinks: SubscribedEventSinks | undefined
|
|
155
|
+
let telemetry: TelemetryHandle | undefined
|
|
156
|
+
if (prepared.workspaceConfig?.plugins && prepared.workspaceConfig.plugins.length > 0) {
|
|
157
|
+
const plugins = prepared.workspaceConfig.plugins as readonly VxPlugin[]
|
|
158
|
+
const pluginCtx = {
|
|
159
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
160
|
+
cacheDir: prepared.cacheDir,
|
|
161
|
+
warn: (m: string) => log.status(m),
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
disposePlugins = await installPlugins({
|
|
165
|
+
plugins: prepared.workspaceConfig.plugins as never,
|
|
166
|
+
bus,
|
|
167
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
168
|
+
cacheDir: prepared.cacheDir,
|
|
169
|
+
warn: (m) => log.status(m),
|
|
170
|
+
})
|
|
171
|
+
eventSinks = await subscribeEventSinks(plugins, bus, pluginCtx)
|
|
172
|
+
} catch (err) {
|
|
173
|
+
disposePlugins?.()
|
|
174
|
+
prepared.cache.close()
|
|
175
|
+
throw err
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const {
|
|
179
|
+
workspaceRoot,
|
|
180
|
+
workspaceConfig,
|
|
181
|
+
cacheDir,
|
|
182
|
+
cache,
|
|
183
|
+
nodes,
|
|
184
|
+
workspaceFingerprint,
|
|
185
|
+
nestedDirsByProject,
|
|
186
|
+
gitFilesCache,
|
|
187
|
+
hashCache,
|
|
188
|
+
workspaceProjectCount,
|
|
189
|
+
} = prepared
|
|
190
|
+
const concurrency =
|
|
191
|
+
options.concurrency ??
|
|
192
|
+
workspaceConfig?.concurrency ??
|
|
193
|
+
Math.max(1, navigator.hardwareConcurrency)
|
|
194
|
+
|
|
195
|
+
// Run-level default task timeout (ms), applied to any task WITHOUT its own
|
|
196
|
+
// `exec.timeout`. Precedence, highest first: `--timeout`/RunOptions.timeout
|
|
197
|
+
// → `VX_TASK_TIMEOUT` env → workspace `timeout`. A malformed env value is
|
|
198
|
+
// ignored (a bad timeout must not silently disable a task's own limit).
|
|
199
|
+
const taskTimeoutDefault =
|
|
200
|
+
options.timeout ?? readTaskTimeoutEnv() ?? workspaceConfig?.timeout ?? undefined
|
|
201
|
+
|
|
202
|
+
// Run-scoped registries of live subprocesses:
|
|
203
|
+
// - `liveChildren`: in-flight children. The runner adds/removes
|
|
204
|
+
// each child around its spawn (persistent children stay until
|
|
205
|
+
// they exit).
|
|
206
|
+
// - `persistentRegistry`: ready persistent tasks (dev servers,
|
|
207
|
+
// watchers). executeTask spawns them but does NOT await their
|
|
208
|
+
// exit; ownership moves here so the orchestrator can SIGTERM
|
|
209
|
+
// them once the rest of the graph finishes.
|
|
210
|
+
//
|
|
211
|
+
// A SIGINT/SIGTERM mid-run forwards SIGTERM to everything live,
|
|
212
|
+
// closes the cache handle, and exits 128+signo (130/143). Without
|
|
213
|
+
// this, a programmatic signal to the vx process alone (CI
|
|
214
|
+
// cancellation, `kill <pid>`) orphans every running child —
|
|
215
|
+
// terminal Ctrl-C only worked via process-group propagation. The
|
|
216
|
+
// handlers are removed in the finally below so repeated run()
|
|
217
|
+
// calls (test suites) never stack listeners.
|
|
218
|
+
const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
|
|
219
|
+
const persistentRegistry = new Map<string, ReturnType<typeof Bun.spawn>>()
|
|
220
|
+
const onSignal = (signal: 'SIGINT' | 'SIGTERM'): void => {
|
|
221
|
+
// Clear the live worker/status region BEFORE exiting so a TTY isn't
|
|
222
|
+
// left with a frozen region frozen in the scrollback (the documented
|
|
223
|
+
// KNOWN-OPEN). runEnd is idempotent and a no-op for non-TTY loggers.
|
|
224
|
+
try {
|
|
225
|
+
log.runEnd?.()
|
|
226
|
+
} catch {
|
|
227
|
+
// teardown must not throw on the way out
|
|
228
|
+
}
|
|
229
|
+
for (const child of liveChildren) child.kill('SIGTERM')
|
|
230
|
+
for (const child of persistentRegistry.values()) child.kill('SIGTERM')
|
|
231
|
+
try {
|
|
232
|
+
cache.close()
|
|
233
|
+
} catch {
|
|
234
|
+
// double-close race with the normal path; we're exiting anyway
|
|
235
|
+
}
|
|
236
|
+
process.exit(signalExitCode(signal))
|
|
237
|
+
}
|
|
238
|
+
const onSigint = (): void => onSignal('SIGINT')
|
|
239
|
+
const onSigterm = (): void => onSignal('SIGTERM')
|
|
240
|
+
if (options.handleSignals ?? true) {
|
|
241
|
+
process.on('SIGINT', onSigint)
|
|
242
|
+
process.on('SIGTERM', onSigterm)
|
|
243
|
+
}
|
|
244
|
+
try {
|
|
245
|
+
// One run-id per `vx run` invocation. Every task in the resulting
|
|
246
|
+
// graph carries it so analytics queries can group by invocation.
|
|
247
|
+
const runId = ulid()
|
|
248
|
+
const runStartHrTimeNs = process.hrtime.bigint()
|
|
249
|
+
const endedAtMsAtStart = Date.now()
|
|
250
|
+
const remoteCacheEnabled = cache instanceof LayeredCache
|
|
251
|
+
const policy: CachePolicy = options.cache ?? FULL_CACHE_POLICY
|
|
252
|
+
|
|
253
|
+
// `--verify` observes the miss-then-save path, so a policy with NO write
|
|
254
|
+
// axis (`--no-cache`, `--cache=local:,remote:`) verifies nothing — every
|
|
255
|
+
// task would come back green with zero verdicts. The user asked for a
|
|
256
|
+
// proof; silently skipping it is the one failure mode verification must
|
|
257
|
+
// never have (same platform-honesty rule as the sandbox-unavailable
|
|
258
|
+
// error). `--force --verify` is the supported re-verify-warm recipe.
|
|
259
|
+
if (options.verify !== undefined && !policy.localWrite && !policy.remoteWrite) {
|
|
260
|
+
prepared.cache.close()
|
|
261
|
+
throw new UserError(
|
|
262
|
+
'--verify needs cache writes to prove anything (it verifies the save path); ' +
|
|
263
|
+
'drop --no-cache, or use --force --verify to re-execute and verify a warm graph',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Per-run context for the Tier-3 `invocations` header row. Captured
|
|
268
|
+
// ONCE (git is ONE spawn for commit+branch, behind try/catch; never
|
|
269
|
+
// fails a run). `dirty` reuses the `git status --porcelain` the
|
|
270
|
+
// GitFilesCache populate already ran for input enumeration — no
|
|
271
|
+
// second status spawn.
|
|
272
|
+
const gitContext = captureGitContext(workspaceRoot, gitFilesCache.worktreeDirty)
|
|
273
|
+
const ciContext = detectCi(process.env)
|
|
274
|
+
const hostContext = captureHostContext()
|
|
275
|
+
|
|
276
|
+
// The canonical run-context record — the same git/CI/host data the
|
|
277
|
+
// invocation header uses, shaped as the telemetry export contract.
|
|
278
|
+
// Built + consulted ONLY when plugins are declared (an explicit opt-in),
|
|
279
|
+
// and BEFORE run:start is emitted so a sink catches the whole stream.
|
|
280
|
+
// subscribeTelemetry returns undefined when no sink is contributed (no
|
|
281
|
+
// telemetry plugin, or all declined — e.g. otel() with no OTLP endpoint),
|
|
282
|
+
// so a plain run does ZERO extra work: no record allocation, no bus
|
|
283
|
+
// subscriber, no summary building. The hot path stays off-limits.
|
|
284
|
+
let runContextRecord: RunContextRecord | undefined
|
|
285
|
+
const hasPlugins =
|
|
286
|
+
prepared.workspaceConfig?.plugins !== undefined && prepared.workspaceConfig.plugins.length > 0
|
|
287
|
+
if (hasPlugins || options.telemetrySinks !== undefined) {
|
|
288
|
+
// Workspace identity (telemetry v2): one git spawn, paid only when a
|
|
289
|
+
// telemetry consumer can exist — a plain run never reaches here.
|
|
290
|
+
const wsIdentity = captureWorkspaceIdentity(workspaceRoot)
|
|
291
|
+
runContextRecord = {
|
|
292
|
+
runId,
|
|
293
|
+
vxVersion: VERSION,
|
|
294
|
+
command: options.command ?? process.argv.slice(1).join(' '),
|
|
295
|
+
requestedTasks: [...options.tasks],
|
|
296
|
+
cachePolicy: compactCachePolicy(policy),
|
|
297
|
+
concurrency,
|
|
298
|
+
flow: options.flow ?? null,
|
|
299
|
+
commitSha: gitContext.commitSha,
|
|
300
|
+
branch: gitContext.branch,
|
|
301
|
+
dirty: gitContext.dirty,
|
|
302
|
+
ci: ciContext.ci,
|
|
303
|
+
ciProvider: ciContext.provider,
|
|
304
|
+
host: hostContext.host,
|
|
305
|
+
os: hostContext.os,
|
|
306
|
+
arch: hostContext.arch,
|
|
307
|
+
workspaceId: wsIdentity.id,
|
|
308
|
+
workspaceName: wsIdentity.name,
|
|
309
|
+
tags: options.tags ?? {},
|
|
310
|
+
}
|
|
311
|
+
telemetry = await subscribeTelemetry(
|
|
312
|
+
(prepared.workspaceConfig?.plugins ?? []) as readonly VxPlugin[],
|
|
313
|
+
bus,
|
|
314
|
+
{ workspaceRoot, cacheDir, warn: (m: string) => log.status(m) },
|
|
315
|
+
runContextRecord,
|
|
316
|
+
options.telemetrySinks,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Lazy SRT init: fire it up if at least one task opts into sandboxing
|
|
321
|
+
// via its `sandbox: {...}` block, OR `--verify=inputs`/`=all` is on (it
|
|
322
|
+
// forces the declared-input baseline sandbox onto every cacheable task to
|
|
323
|
+
// prove input-completeness). Tasks that need sandboxing on an unsupported
|
|
324
|
+
// platform get a hard error so they don't silently run unsandboxed —
|
|
325
|
+
// `--verify=inputs` in particular must fail loud, never falsely "pass".
|
|
326
|
+
const verifyInputs = options.verify?.inputs === true
|
|
327
|
+
const anySandboxed =
|
|
328
|
+
verifyInputs || [...nodes.values()].some((n) => n.config.sandbox !== undefined)
|
|
329
|
+
if (anySandboxed) {
|
|
330
|
+
const avail = await probeSandbox()
|
|
331
|
+
if (!avail.available) {
|
|
332
|
+
prepared.cache.close()
|
|
333
|
+
throw new UserError(
|
|
334
|
+
verifyInputs
|
|
335
|
+
? `--verify=inputs needs the sandbox, which is not available: ${avail.reason}`
|
|
336
|
+
: `sandbox not available: ${avail.reason}`,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
await initSandbox()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Focused flow: a requested GROUP has no output of its own, so
|
|
343
|
+
// surface the same-project, non-group tasks it chains (one level)
|
|
344
|
+
// for display. Marks `node.surfaced`; never touches `requested`.
|
|
345
|
+
markSurfacedDeps(nodes)
|
|
346
|
+
|
|
347
|
+
// Header counts: unique projects covered by the graph (including
|
|
348
|
+
// dependsOn-pulled deps, not just the user-requested set), and the
|
|
349
|
+
// total number of real (non-group) task executions. Mirrors the
|
|
350
|
+
// count the end-of-run summary reports under "total". The
|
|
351
|
+
// requested count drives the focused logger's live-vs-buffered
|
|
352
|
+
// decision, so surfaced nodes count toward it too — they display
|
|
353
|
+
// like requested tasks.
|
|
354
|
+
const packagesInScope = new Set<string>()
|
|
355
|
+
let taskCount = 0
|
|
356
|
+
let requestedCount = 0
|
|
357
|
+
for (const node of nodes.values()) {
|
|
358
|
+
packagesInScope.add(node.projectName)
|
|
359
|
+
if (!isGroupTask(node)) {
|
|
360
|
+
taskCount++
|
|
361
|
+
if (node.requested || node.surfaced === true) requestedCount++
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Resource-aware admission: resolve every task's `exec.resources`
|
|
365
|
+
// into absolute costs ONCE, up front (percent forms against the
|
|
366
|
+
// budgets), so the scheduler's inner loop is a plain Map.get. The
|
|
367
|
+
// CPU budget is the run's concurrency; the memory budget is
|
|
368
|
+
// os.totalmem() unless `--memory` overrides it (pass `--memory` in
|
|
369
|
+
// cgroup-limited containers — totalmem() reports the HOST's RAM).
|
|
370
|
+
// Nothing declared → empty map → fields omitted from the scheduler
|
|
371
|
+
// AND the footer → byte-identical legacy path.
|
|
372
|
+
const memBudget = options.memory ?? os.totalmem()
|
|
373
|
+
const resourceCosts = resolveResourceCosts(nodes, concurrency, memBudget)
|
|
374
|
+
|
|
375
|
+
// Run context for the footer. The top-of-run header is gone — the
|
|
376
|
+
// banner now lives in the summary, where the eye lands at the end.
|
|
377
|
+
const runContext = {
|
|
378
|
+
version: VERSION,
|
|
379
|
+
packageCount: packagesInScope.size,
|
|
380
|
+
remoteCacheEnabled,
|
|
381
|
+
concurrency,
|
|
382
|
+
workspaceProjectCount,
|
|
383
|
+
...(resourceCosts.size > 0 ? { cpuBudget: concurrency, memBudget } : {}),
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Lifecycle hooks drive the default logger's dynamic status line
|
|
387
|
+
// (TTY-only); custom loggers may ignore them.
|
|
388
|
+
log.runStart?.({ total: taskCount, concurrency, requestedCount, context: runContext })
|
|
389
|
+
|
|
390
|
+
// Remote-only: kick off background prefetches so remote-GET latency
|
|
391
|
+
// overlaps execution. Fire-and-forget — execution starts on the next
|
|
392
|
+
// line; LayeredCache ingests hits into local and de-dups so
|
|
393
|
+
// execute-task's cache.get awaits the in-flight promise (one remote
|
|
394
|
+
// GET per key). Gated entirely on a remote layer being configured;
|
|
395
|
+
// local-only runs never reach here, so their behavior + perf is
|
|
396
|
+
// unchanged (no upfront key pass, no local probing).
|
|
397
|
+
let prefetchDone: Promise<void> = Promise.resolve()
|
|
398
|
+
if (cache instanceof LayeredCache) {
|
|
399
|
+
prefetchDone = startRemotePrefetch({
|
|
400
|
+
nodes,
|
|
401
|
+
cache,
|
|
402
|
+
workspaceRoot,
|
|
403
|
+
workspaceFingerprint,
|
|
404
|
+
forwardArgs: options.forwardArgs,
|
|
405
|
+
nestedDirsByProject,
|
|
406
|
+
gitFilesCache,
|
|
407
|
+
hashCache,
|
|
408
|
+
concurrency,
|
|
409
|
+
remoteRead: policy.remoteRead,
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Local cache short-circuit (default-on). Up-front classify: derive
|
|
414
|
+
// every stable+cacheable+local-read task's key and probe local ONCE.
|
|
415
|
+
// The result drives two-tier scheduling — confirmed hits become a
|
|
416
|
+
// restore-tier the scheduler runs ahead of their deps but only as
|
|
417
|
+
// worker-slot backfill (misses own the pool) — and execute reuses
|
|
418
|
+
// each probe, so there is no second cache.get. Gated by
|
|
419
|
+
// shouldShortCircuit (localRead + has-deps); when off, both maps are
|
|
420
|
+
// empty and the run is byte-identical.
|
|
421
|
+
let shortCircuit: ShortCircuit = EMPTY_SHORT_CIRCUIT
|
|
422
|
+
if (shouldShortCircuit(nodes, policy, cache)) {
|
|
423
|
+
shortCircuit = await startLocalShortCircuit({
|
|
424
|
+
nodes,
|
|
425
|
+
cache,
|
|
426
|
+
workspaceRoot,
|
|
427
|
+
workspaceFingerprint,
|
|
428
|
+
forwardArgs: options.forwardArgs,
|
|
429
|
+
nestedDirsByProject,
|
|
430
|
+
gitFilesCache,
|
|
431
|
+
hashCache,
|
|
432
|
+
concurrency,
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
|
|
437
|
+
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
438
|
+
return {
|
|
439
|
+
node,
|
|
440
|
+
upstream,
|
|
441
|
+
workspaceRoot,
|
|
442
|
+
workspaceFingerprint,
|
|
443
|
+
cache,
|
|
444
|
+
cachePolicy: policy,
|
|
445
|
+
forwardArgs: options.forwardArgs,
|
|
446
|
+
...(options.retries !== undefined ? { retries: options.retries } : {}),
|
|
447
|
+
...(taskTimeoutDefault !== undefined ? { timeout: taskTimeoutDefault } : {}),
|
|
448
|
+
...(options.verify !== undefined ? { verify: options.verify } : {}),
|
|
449
|
+
log,
|
|
450
|
+
nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
|
|
451
|
+
runStartHrTimeNs,
|
|
452
|
+
persistentRegistry,
|
|
453
|
+
liveChildren,
|
|
454
|
+
gitFilesCache,
|
|
455
|
+
hashCache,
|
|
456
|
+
...(probe !== undefined ? { preProbed: probe } : {}),
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// In-flight dedup. Only when a service supplies a shared `inflight`
|
|
461
|
+
// registry (concurrent runs in one `vx serve`); a stateless `vx run`
|
|
462
|
+
// passes none and takes the untouched path. Gated to cacheable tasks —
|
|
463
|
+
// the join works by waiting for the sibling to populate the cache, then
|
|
464
|
+
// letting executeTask cache-hit on it. executeTask stays unchanged.
|
|
465
|
+
const inflight = options.inflight
|
|
466
|
+
const executeWithDedup = async (
|
|
467
|
+
node: TaskNode,
|
|
468
|
+
upstream: TaskOutcome[],
|
|
469
|
+
): Promise<TaskOutcome> => {
|
|
470
|
+
// Dedup only helps when the sibling will WRITE the artifact and
|
|
471
|
+
// this task can READ it back — i.e. both axes effectively on.
|
|
472
|
+
const canRead = policy.localRead || policy.remoteRead
|
|
473
|
+
const canWrite = policy.localWrite || policy.remoteWrite
|
|
474
|
+
const cacheable =
|
|
475
|
+
!isGroupTask(node) &&
|
|
476
|
+
node.config.exec?.persistent === undefined &&
|
|
477
|
+
node.config.cache !== undefined &&
|
|
478
|
+
canRead &&
|
|
479
|
+
canWrite
|
|
480
|
+
// A restore-tier task (confirmed local hit, may run before its
|
|
481
|
+
// deps) needs no dedup — it's a restore, not an executor, and its
|
|
482
|
+
// live `upstream` is incomplete, so the dedup hash recompute would
|
|
483
|
+
// be wrong. Route it straight to executeTask, which reuses the
|
|
484
|
+
// up-front probe.
|
|
485
|
+
const restorable = shortCircuit.restoreTier.has(node.id)
|
|
486
|
+
if (inflight === undefined || !cacheable || restorable) {
|
|
487
|
+
return executeTask(buildExecuteArgs(node, upstream))
|
|
488
|
+
}
|
|
489
|
+
const hash = await computeTaskHash({
|
|
490
|
+
node,
|
|
491
|
+
upstream,
|
|
492
|
+
workspaceRoot,
|
|
493
|
+
workspaceFingerprint,
|
|
494
|
+
cache,
|
|
495
|
+
forwardArgs: options.forwardArgs,
|
|
496
|
+
nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
|
|
497
|
+
gitFilesCache,
|
|
498
|
+
hashCache,
|
|
499
|
+
})
|
|
500
|
+
const existing = inflight.get(hash)
|
|
501
|
+
if (existing !== undefined) {
|
|
502
|
+
// Join a sibling already computing this exact task: wait, then
|
|
503
|
+
// executeTask cache-hits on the artifact it just saved. The
|
|
504
|
+
// up-front probe (preProbed) predates the sibling's save — its
|
|
505
|
+
// "confirmed stable miss" would skip the lazy cache.get and
|
|
506
|
+
// re-execute, defeating the dedup — so the join path drops it
|
|
507
|
+
// and lets executeTask probe fresh.
|
|
508
|
+
await existing.catch(() => {})
|
|
509
|
+
return executeTask(buildExecuteArgs(node, upstream, false))
|
|
510
|
+
}
|
|
511
|
+
// Become the executor: register a barrier siblings await. get→set has
|
|
512
|
+
// no await between, so registration is atomic — at most one executor
|
|
513
|
+
// per hash. Released on every exit (success / failure / throw).
|
|
514
|
+
let release!: () => void
|
|
515
|
+
inflight.set(
|
|
516
|
+
hash,
|
|
517
|
+
new Promise<void>((resolve) => {
|
|
518
|
+
release = resolve
|
|
519
|
+
}),
|
|
520
|
+
)
|
|
521
|
+
try {
|
|
522
|
+
return await executeTask(buildExecuteArgs(node, upstream))
|
|
523
|
+
} finally {
|
|
524
|
+
inflight.delete(hash)
|
|
525
|
+
release()
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const outcomes = await runGraph({
|
|
530
|
+
nodes,
|
|
531
|
+
concurrency,
|
|
532
|
+
...(resourceCosts.size > 0 ? { resourceCosts, cpuBudget: concurrency, memBudget } : {}),
|
|
533
|
+
...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
|
|
534
|
+
onStart: (node) => {
|
|
535
|
+
log.taskStart?.(node)
|
|
536
|
+
},
|
|
537
|
+
onFinish: (o) => {
|
|
538
|
+
log.taskComplete(o.node, o)
|
|
539
|
+
},
|
|
540
|
+
execute: executeWithDedup,
|
|
541
|
+
// Predictive scheduling: empty map when not opted in, in which
|
|
542
|
+
// case the scheduler keeps the static baseline behavior.
|
|
543
|
+
priorities: prepared.priorities,
|
|
544
|
+
// Local short-circuit: confirmed stable local hits the scheduler
|
|
545
|
+
// runs ahead of their deps as low-priority worker-slot backfill.
|
|
546
|
+
// Empty when the short-circuit didn't fire → byte-identical.
|
|
547
|
+
restoreTier: shortCircuit.restoreTier,
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
// A persistent task the user REQUESTED (a dev server / watcher) is
|
|
551
|
+
// the run's whole purpose — don't tear it down the instant it's
|
|
552
|
+
// ready. We leave those running and block on them at the very end
|
|
553
|
+
// (after the normal summary prints); everything else (persistent
|
|
554
|
+
// tasks pulled in only as dependencies of now-finished work) is
|
|
555
|
+
// SIGTERMed here as before. Scoped to the real CLI foreground:
|
|
556
|
+
// `options.log === undefined` means the default logger (a `vx run`
|
|
557
|
+
// invocation), and `handleSignals` excludes watch mode (own signal
|
|
558
|
+
// loop) and embedders that manage lifecycle themselves — both expect
|
|
559
|
+
// run() to return, not block on a server.
|
|
560
|
+
const foreground = options.log === undefined && (options.handleSignals ?? true)
|
|
561
|
+
const keepAliveNodes: TaskNode[] = []
|
|
562
|
+
const keepAlive: ReturnType<typeof Bun.spawn>[] = []
|
|
563
|
+
if (foreground) {
|
|
564
|
+
for (const [id, child] of persistentRegistry) {
|
|
565
|
+
const n = nodes.get(id)
|
|
566
|
+
if (n !== undefined && (n.requested || n.surfaced === true)) {
|
|
567
|
+
keepAliveNodes.push(n)
|
|
568
|
+
keepAlive.push(child)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const keepAliveSet = new Set(keepAlive)
|
|
573
|
+
|
|
574
|
+
// Shut down the dependency-only persistent tasks before reporting the
|
|
575
|
+
// final summary. SIGTERM gives well-behaved servers (vite, next,
|
|
576
|
+
// esbuild --watch) a moment to clean up; we don't escalate to SIGKILL
|
|
577
|
+
// — process-group propagation on Ctrl-C handles the unhappy case.
|
|
578
|
+
// Bun's Subprocess.kill is idempotent on an already-exited child.
|
|
579
|
+
for (const child of persistentRegistry.values()) {
|
|
580
|
+
if (!keepAliveSet.has(child)) child.kill('SIGTERM')
|
|
581
|
+
}
|
|
582
|
+
await Promise.allSettled(
|
|
583
|
+
[...persistentRegistry.values()].filter((c) => !keepAliveSet.has(c)).map((c) => c.exited),
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
// Clear the status line for good before the summary prints.
|
|
587
|
+
log.runEnd?.()
|
|
588
|
+
|
|
589
|
+
const list = [...outcomes.values()]
|
|
590
|
+
const ok =
|
|
591
|
+
list.every(
|
|
592
|
+
(o) =>
|
|
593
|
+
o.status === 'success' || o.status === 'cache-hit' || o.status === 'cache-hit-remote',
|
|
594
|
+
) &&
|
|
595
|
+
// `--verify`: a provably-unsafe cache entry (non-deterministic outputs, a
|
|
596
|
+
// re-run that failed, or a read of undeclared inputs) turns the run red so
|
|
597
|
+
// CI catches it.
|
|
598
|
+
!list.some(
|
|
599
|
+
(o) =>
|
|
600
|
+
o.verify?.kind === 'nondeterministic' ||
|
|
601
|
+
o.verify?.kind === 'rerun-failed' ||
|
|
602
|
+
o.verify?.kind === 'undeclared-inputs',
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
// The summary + artifact writers + recordRun pass all exclude group
|
|
606
|
+
// tasks via the shared tallyOutcomes helper. We pass the full
|
|
607
|
+
// outcome list and let each consumer apply the same filter.
|
|
608
|
+
const endedAtMs = Date.now()
|
|
609
|
+
const totalMs = Number(process.hrtime.bigint() - runStartHrTimeNs) / 1_000_000
|
|
610
|
+
// Foreground dev mode: between the task frame and the footer, list
|
|
611
|
+
// the persistent tasks still running (see the keep-alive block below).
|
|
612
|
+
if (keepAliveNodes.length > 0) {
|
|
613
|
+
for (const line of formatPersistentList(keepAliveNodes, colors)) log.status(line)
|
|
614
|
+
}
|
|
615
|
+
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
616
|
+
if (options.verify !== undefined) {
|
|
617
|
+
for (const line of formatVerifySection(list)) log.status(line)
|
|
618
|
+
// A fingerprint-only run attaches no verdicts, so the verdict-driven
|
|
619
|
+
// section above prints nothing — report what actually happened.
|
|
620
|
+
if (options.verify.fingerprint && !options.verify.determinism && !options.verify.inputs) {
|
|
621
|
+
const n = list.filter((o) => o.outputFp !== undefined).length
|
|
622
|
+
log.status('')
|
|
623
|
+
log.status(
|
|
624
|
+
` Verify: fingerprinted ${n} task output trees (cross-machine diff via a connected serve)`,
|
|
625
|
+
)
|
|
626
|
+
// Only EXECUTED tasks fingerprint — a warm all-hit run reports 0.
|
|
627
|
+
// A per-platform matrix wired without `--force` produces nothing
|
|
628
|
+
// forever, so name the cause instead of a bare 0.
|
|
629
|
+
if (n === 0) {
|
|
630
|
+
log.status(' (0 executed — cache hits do not fingerprint; pair with --force)')
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Optional artifacts. Errors are surfaced to the user but don't
|
|
636
|
+
// change the run's exit code — the run already happened.
|
|
637
|
+
if (options.summarize !== undefined) {
|
|
638
|
+
try {
|
|
639
|
+
const wrote = await writeRunSummary({
|
|
640
|
+
target: options.summarize,
|
|
641
|
+
cacheDir,
|
|
642
|
+
cwd: options.cwd,
|
|
643
|
+
runId,
|
|
644
|
+
startedAtMs: endedAtMsAtStart,
|
|
645
|
+
endedAtMs,
|
|
646
|
+
totalMs,
|
|
647
|
+
outcomes: list,
|
|
648
|
+
})
|
|
649
|
+
log.status(`vx: summary written to ${wrote}`)
|
|
650
|
+
} catch (err) {
|
|
651
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
652
|
+
log.status(`vx: failed to write summary: ${msg}`)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (options.profile !== undefined) {
|
|
656
|
+
try {
|
|
657
|
+
const wrote = await writeRunProfile({
|
|
658
|
+
target: options.profile,
|
|
659
|
+
cwd: options.cwd,
|
|
660
|
+
outcomes: list,
|
|
661
|
+
})
|
|
662
|
+
log.status(`vx: profile written to ${wrote}`)
|
|
663
|
+
} catch (err) {
|
|
664
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
665
|
+
log.status(`vx: failed to write profile: ${msg}`)
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Record each task to the run history in a single SQLite transaction
|
|
670
|
+
// (one fsync instead of N). Group tasks (no `exec`) are skipped —
|
|
671
|
+
// they aren't real runs and the `runs` table is analytics-focused.
|
|
672
|
+
// One invocation header row is written alongside, atomically via
|
|
673
|
+
// recordRunBundle. The Tier-3 input-fingerprint rows (entry_inputs)
|
|
674
|
+
// are NOT built here — they're persisted inside each entry's save
|
|
675
|
+
// transaction (miss path only), so a warm all-cache-hit run does no
|
|
676
|
+
// extra recording work.
|
|
677
|
+
const now = endedAtMs
|
|
678
|
+
const toRecord: RunRecord[] = []
|
|
679
|
+
// Per-task telemetry mirrors `toRecord` 1:1 — built only when a
|
|
680
|
+
// telemetry sink is active, so a no-telemetry run allocates nothing.
|
|
681
|
+
const summaryTasks: TaskTelemetry[] = []
|
|
682
|
+
let failedCount = 0
|
|
683
|
+
let hitLocalCount = 0
|
|
684
|
+
let hitRemoteCount = 0
|
|
685
|
+
for (const o of list) {
|
|
686
|
+
if (!o.hash) continue
|
|
687
|
+
if (isGroupTask(o.node)) continue
|
|
688
|
+
// aborted (killed by a shutdown signal) isn't a real run.
|
|
689
|
+
if (o.status === 'aborted') continue
|
|
690
|
+
if (telemetry !== undefined) {
|
|
691
|
+
const t: TaskTelemetry = {
|
|
692
|
+
taskId: o.node.id,
|
|
693
|
+
project: o.node.projectName,
|
|
694
|
+
task: o.node.taskName,
|
|
695
|
+
status: o.status,
|
|
696
|
+
cacheSource: deriveCacheSource(o.status),
|
|
697
|
+
exitCode: o.exitCode,
|
|
698
|
+
durationMs: o.durationMs,
|
|
699
|
+
}
|
|
700
|
+
if (o.hash !== undefined) t.hash = o.hash
|
|
701
|
+
if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
|
|
702
|
+
if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
|
|
703
|
+
if (o.attempts !== undefined) t.attempts = o.attempts
|
|
704
|
+
if (o.verify !== undefined) t.verify = o.verify
|
|
705
|
+
if (o.outputFp !== undefined) t.outputFp = o.outputFp
|
|
706
|
+
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
707
|
+
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
708
|
+
summaryTasks.push(t)
|
|
709
|
+
}
|
|
710
|
+
toRecord.push({
|
|
711
|
+
hash: o.hash,
|
|
712
|
+
project: o.node.projectName,
|
|
713
|
+
task: o.node.taskName,
|
|
714
|
+
status: o.status,
|
|
715
|
+
exitCode: o.exitCode,
|
|
716
|
+
durationMs: o.durationMs,
|
|
717
|
+
...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
|
|
718
|
+
// Anchor to the REAL per-task wall-clock window: run-start wall time +
|
|
719
|
+
// the task's ns offset (captured for hits and executed tasks alike).
|
|
720
|
+
// The `now - duration` fallback only applies to outcomes without an
|
|
721
|
+
// offset (none today). Using run-end-minus-duration for everything was
|
|
722
|
+
// the old bug that piled every task at the right edge of the timeline.
|
|
723
|
+
startedAt:
|
|
724
|
+
o.wallclockStartNs !== undefined
|
|
725
|
+
? endedAtMsAtStart + Math.round(Number(o.wallclockStartNs) / 1e6)
|
|
726
|
+
: now - o.durationMs,
|
|
727
|
+
endedAt:
|
|
728
|
+
o.wallclockEndNs !== undefined
|
|
729
|
+
? endedAtMsAtStart + Math.round(Number(o.wallclockEndNs) / 1e6)
|
|
730
|
+
: now,
|
|
731
|
+
runId,
|
|
732
|
+
...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
|
|
733
|
+
...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
|
|
734
|
+
...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
|
|
735
|
+
...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
|
|
736
|
+
cacheHit: o.status === 'cache-hit' || o.status === 'cache-hit-remote',
|
|
737
|
+
...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
|
|
738
|
+
})
|
|
739
|
+
if (o.status === 'failed') failedCount++
|
|
740
|
+
if (o.status === 'cache-hit') hitLocalCount++
|
|
741
|
+
if (o.status === 'cache-hit-remote') hitRemoteCount++
|
|
742
|
+
}
|
|
743
|
+
const invocation: InvocationRecord = {
|
|
744
|
+
runId,
|
|
745
|
+
command: options.command ?? process.argv.slice(1).join(' '),
|
|
746
|
+
requestedTasks: JSON.stringify([...options.tasks]),
|
|
747
|
+
cachePolicy: compactCachePolicy(policy),
|
|
748
|
+
concurrency,
|
|
749
|
+
flow: options.flow ?? null,
|
|
750
|
+
startedAt: endedAtMsAtStart,
|
|
751
|
+
endedAt: endedAtMs,
|
|
752
|
+
totalDurationMs: Math.round(totalMs),
|
|
753
|
+
taskCount: toRecord.length,
|
|
754
|
+
failedCount,
|
|
755
|
+
hitCount: hitLocalCount + hitRemoteCount,
|
|
756
|
+
hitLocalCount,
|
|
757
|
+
hitRemoteCount,
|
|
758
|
+
exitOk: ok,
|
|
759
|
+
commitSha: gitContext.commitSha,
|
|
760
|
+
branch: gitContext.branch,
|
|
761
|
+
dirty: gitContext.dirty,
|
|
762
|
+
ci: ciContext.ci,
|
|
763
|
+
ciProvider: ciContext.provider,
|
|
764
|
+
host: hostContext.host,
|
|
765
|
+
os: hostContext.os,
|
|
766
|
+
arch: hostContext.arch,
|
|
767
|
+
vxVersion: VERSION,
|
|
768
|
+
tags: JSON.stringify(options.tags ?? {}),
|
|
769
|
+
}
|
|
770
|
+
cache.recordRunBundle({ runs: toRecord, invocation })
|
|
771
|
+
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
772
|
+
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
773
|
+
// whole block is skipped and the run is byte-identical to before.
|
|
774
|
+
// emitSummary/flush are crash-isolated, so a faulty sink can't fail
|
|
775
|
+
// the run; flush is the sink's last chance to ship buffered records.
|
|
776
|
+
if (telemetry !== undefined && runContextRecord !== undefined) {
|
|
777
|
+
const summary: RunSummaryRecord = {
|
|
778
|
+
v: TELEMETRY_SCHEMA_VERSION,
|
|
779
|
+
run: runContextRecord,
|
|
780
|
+
startedAt: endedAtMsAtStart,
|
|
781
|
+
endedAt: endedAtMs,
|
|
782
|
+
totalDurationMs: Math.round(totalMs),
|
|
783
|
+
taskCount: toRecord.length,
|
|
784
|
+
failedCount,
|
|
785
|
+
hitCount: hitLocalCount + hitRemoteCount,
|
|
786
|
+
hitLocalCount,
|
|
787
|
+
hitRemoteCount,
|
|
788
|
+
exitOk: ok,
|
|
789
|
+
tasks: summaryTasks,
|
|
790
|
+
}
|
|
791
|
+
telemetry.emitSummary(summary)
|
|
792
|
+
await telemetry.flush()
|
|
793
|
+
}
|
|
794
|
+
// End-of-run plugin lifecycle: each event sink's flush() (its last
|
|
795
|
+
// chance to ship buffered records) and each plugin's teardown().
|
|
796
|
+
// Crash-isolated + time-bounded inside teardownPlugins, so a faulty
|
|
797
|
+
// plugin can neither fail nor hang the run. Normal completion path
|
|
798
|
+
// only — the finally below just unsubscribes.
|
|
799
|
+
if (prepared.workspaceConfig?.plugins && prepared.workspaceConfig.plugins.length > 0) {
|
|
800
|
+
await teardownPlugins(
|
|
801
|
+
prepared.workspaceConfig.plugins as readonly VxPlugin[],
|
|
802
|
+
eventSinks?.sinks ?? [],
|
|
803
|
+
(m) => log.status(m),
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
// Drain any still-in-flight background prefetches before closing the
|
|
807
|
+
// cache handle — a prefetch ingesting into a closed SQLite DB would
|
|
808
|
+
// throw. Tasks that resolved as local hits never awaited their
|
|
809
|
+
// prefetch, so some may still be running here. (The local
|
|
810
|
+
// short-circuit's probes are awaited inside startLocalShortCircuit
|
|
811
|
+
// before scheduling, so nothing of its is in flight here.) The
|
|
812
|
+
// background write-through uploads queued by LayeredCache.save
|
|
813
|
+
// settle here for the same reason.
|
|
814
|
+
await prefetchDone
|
|
815
|
+
if (cache instanceof LayeredCache) await cache.drainUploads()
|
|
816
|
+
cache.close()
|
|
817
|
+
|
|
818
|
+
// Tear down SRT's network bridge + (on macOS) log monitor. No-op if
|
|
819
|
+
// no task was sandboxed; otherwise SRT keeps proxy servers alive and
|
|
820
|
+
// the next vx run would init on top of stale state.
|
|
821
|
+
if (anySandboxed) {
|
|
822
|
+
try {
|
|
823
|
+
await resetSandbox()
|
|
824
|
+
} catch (err) {
|
|
825
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
826
|
+
log.status(`vx: sandbox cleanup failed: ${msg}`)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Edge case the summary already reported: the user requested a
|
|
831
|
+
// persistent task (dev server / watcher). The run is "done" in every
|
|
832
|
+
// bookkeeping sense — summary printed, history recorded — but the
|
|
833
|
+
// server is still up and that's the point. Stay in the foreground
|
|
834
|
+
// until it exits: Ctrl-C hits the whole process group (the server
|
|
835
|
+
// dies; our SIGINT handler also exits 130), and a crash resolves the
|
|
836
|
+
// wait so the run returns. Nothing here prints — the UI is unchanged.
|
|
837
|
+
if (keepAlive.length > 0) {
|
|
838
|
+
await Promise.allSettled(keepAlive.map((c) => c.exited))
|
|
839
|
+
for (const child of keepAlive) child.kill('SIGTERM')
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return { ok, outcomes: list }
|
|
843
|
+
} finally {
|
|
844
|
+
// Idempotent; also reached on mid-run throws, so a crashed cycle
|
|
845
|
+
// can't leave a live status-line ticker behind.
|
|
846
|
+
log.runEnd?.()
|
|
847
|
+
process.off('SIGINT', onSigint)
|
|
848
|
+
process.off('SIGTERM', onSigterm)
|
|
849
|
+
// Plugins installed at the top of run() get their bus subscriptions
|
|
850
|
+
// released here. Idempotent; safe even if installPlugins threw.
|
|
851
|
+
disposePlugins?.()
|
|
852
|
+
eventSinks?.dispose()
|
|
853
|
+
telemetry?.dispose()
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Planning mode. Same setup as `run()` — workspace discovery, config
|
|
859
|
+
* load, package graph, task graph — but stops short of execution.
|
|
860
|
+
* Returns a `RunPlan` predicting the cache hit/miss outcome of every
|
|
861
|
+
* task. Used by `--dry-run` and `--graph`.
|
|
862
|
+
*
|
|
863
|
+
* Side-effects are limited to opening + closing the local Cache handle
|
|
864
|
+
* (and running `cache.inputs.runtime` probe commands, which key
|
|
865
|
+
* derivation requires). Cache probing is the byte-free `cache.has()`
|
|
866
|
+
* existence check — no artifact download, no ingest, no accessed_at bump.
|
|
867
|
+
*/
|
|
868
|
+
export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
869
|
+
const log = options.log ?? defaultLogger()
|
|
870
|
+
const prepared = await prepareRun(options, log)
|
|
871
|
+
try {
|
|
872
|
+
if (prepared.empty !== null) return { tasks: [] }
|
|
873
|
+
return await plan({
|
|
874
|
+
nodes: prepared.nodes,
|
|
875
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
876
|
+
workspaceFingerprint: prepared.workspaceFingerprint,
|
|
877
|
+
cache: prepared.cache,
|
|
878
|
+
cachePolicy: options.cache ?? FULL_CACHE_POLICY,
|
|
879
|
+
forwardArgs: options.forwardArgs,
|
|
880
|
+
nestedDirsByProject: prepared.nestedDirsByProject,
|
|
881
|
+
gitFilesCache: prepared.gitFilesCache,
|
|
882
|
+
hashCache: prepared.hashCache,
|
|
883
|
+
})
|
|
884
|
+
} finally {
|
|
885
|
+
prepared.cache.close()
|
|
886
|
+
}
|
|
887
|
+
}
|