@vzn/vx 0.0.0 → 0.0.12

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