@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -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 +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.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 +512 -0
  90. 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
+ }