Skip to content
vxvx
GitHubBlueskydev.toRSS
Releases

Clear output, kept CI runs, shared timings

Every vx verb now speaks in one shape and says how to fix what it refuses, a CI run keeps the logs and outputs you need to debug it, a new branch forecasts from main's timings, and a run your local cache answers stays off the network.

vx 0.0.638: Clear output, kept CI runs, shared timings

Every vx verb now speaks in one shape and tells you how to fix what it refuses. A CI run keeps the logs and outputs you need to debug it, a new branch forecasts from main’s timings, and a run your local cache answers stays off the network.

There are breaking changes, mostly in what vx prints, so read the list at the end if a script reads vx’s output.

Enjoy the release!

In this release

A refusal used to be one long vx run: … line, with the suggestion and the help pointer packed into parentheses at the end. Each verb also printed its own way, so vx info, vx lock and a migration report looked like three different tools.

Now every verb draws from one set of parts. A refusal is a red ✗ with the verb and the problem, then the facts one per line, and the fix in bold:

Terminal window
$ vx run build --concurency 4
✗ vx run unknown flag: --concurency
see vx run --help
fix --concurrency
$ vx build --all
✗ vx `build` is a task here, not a command
fix vx run build --all

A config typo names the file at its line and shows the source, from any verb:

Terminal window
$ vx run build --all
✗ vx run config refused
.vx/workspace.ts:4:3
unknown field "concurrency"
allowed: cache, lock, plugins, run, watch
2 |
3 | export default defineWorkspace({
> 4 | concurrency: 4,
| ^

Success is one green line, and a verb with nothing to do says so instead of refusing:

Terminal window
$ vx lock
✓ vx lock 1 project config → .vx/projects.lock.json
$ vx why app#build
› vx why no runs of app#build yet
fix vx run app#build

vx info, vx show, vx upgrade, vx cache clean, vx last, vx init and bunx @vzn/vx-migrate use the same parts: a header naming the verb or task, then the facts in one aligned column, and the next command last. vx show lists projects as a table:

Terminal window
$ vx show
project dir tasks
app app 1 task
$ vx show app#build
─ app#build ──────────────────────────────────────────────────
dir app
command mkdir -p dist && cp src/a.txt dist/a.txt
inputs.files src/**
outputs.files dist/**

A task you just added but have not locked now says what to run:

Terminal window
$ vx run deploy --all
✗ vx run no projects declare task(s): deploy
Tasks: build
A run reads the lock: after editing a vx.config, run `vx lock`

Plugins draw with the same parts: @vzn/vx exports them as ui (ui.notice, ui.status, ui.report, ui.fields, ui.table), so a plugin’s output looks like vx’s (Draw the terminal). --format json keeps its flat message.

When a task failed on CI, its log and the reports it wrote, a junit file or a coverage dump, died with the runner. Nothing could show you that run later.

Now a run that writes a remote cache (CI by default) keeps each failed task’s log and its declared outputs, and each uncached task’s log, in that remote. Nothing extra goes to your local cache, which already has them. At the end, each plugin’s teardown(run) gets a report: the run id, commit, CI run URL and job, and every task with its status, duration, key and the names of what vx kept. ctx.cache.getRun(name) reads those back on any machine.

vx writes no manifest of its own; a plugin decides what to record. This one keeps every run and adds a verb that prints its logs:

import { definePlugin, type PluginCache, type VxPlugin } from '@vzn/vx'
export function runLog(): VxPlugin {
let cache: PluginCache
const text = (b: Uint8Array | null) => new TextDecoder().decode(b ?? new Uint8Array())
return definePlugin(import.meta, {
setup(ctx) {
cache = ctx.cache
},
async teardown(run) {
if (run) await cache.put(`run/${run.runId}`, new TextEncoder().encode(JSON.stringify(run)))
},
commands: {
'show-run': {
description: 'print a run',
async run([id], c) {
const run = JSON.parse(text(await c.cache.get(`run/${id}`)))
for (const t of run.tasks) if (t.log) console.log(text(await c.cache.getRun(t.log)))
return 0
},
},
},
})
}

A PR bot or a run explorer is now a plugin away. See Setup and teardown.

Last release taught vx how long each task usually takes, from the median of its last five timings on your branch. A fresh CI runner or a branch you just made had no timings yet, so its first run guessed.

With a remote cache declared, scheduleHistoryPlugin() from @vzn/vx-schedule-history now carries timings there, with nothing to configure. A CI run on main writes each task’s usual time under its commit. When a task has no timing on your branch, the plugin reads the timings at your branch’s merge-base with main, or up to four commits before it. It waits at most 500 ms, and a run whose tasks all have timings asks the cache nothing.

A forecast now reads this branch first, then main’s, then any branch’s. defaultBranch names your main branch if it is not main. Plugins get the same store: ctx.cache has get, put, has and hasMany, and an entry, once written, is never replaced.

The run’s footer said only how many tasks missed. It did not say what the local and the remote cache each did, or whether results went up for the next run. Now each layer gets its own counts:

Terminal window
$ vx run build --all
cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
local 1↑
$ rm -rf app/dist && vx run build --all
cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
local 1↓
$ vx run build --all
cache ▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰▰
local 1●

↑ is saved to that layer, ↓ restored from it, ● already on disk and ○ a task with no cache. With a remote, a second half follows, such as · remote 3↓ 1↑. N not saved, N not downloaded and N ↑ failed show only when they are not zero.

That line also shows a fix: a run whose every task was in the local cache still asked the remote which keys it held before starting. vx now asks the local cache first and the remote only for what it lacks, so a run the local cache answers makes no network call.

One edit could start a cycle and then one or two more, re-running tasks for nothing. Three things the run itself did were read as your edits: an output a cache hit restored, an output another task read through a glob like **/*, and, on macOS, a write at a project’s top level. Watch now tells all three apart from an edit, so an edit runs one cycle.

The second case was a real gap in the configs too: a task reading **/* beside another’s **/dist/** hashed files it did not own. turbo() and nx() now take those outputs back out of the reader’s inputs, and vx lock refuses a hand-written config like that and names the ! entry to add.

cache.outputs.clean: false keeps a task’s outputs on disk before a miss, for tools such as tsc that read their last output. Until now vx refused to save the entry if a file from before the run was left unchanged, so a task like that often never cached at all.

Setting clean: false is now a plain opt-out: vx saves everything the output globs match, leftovers included, and a hit restores all of it on every machine. Set it on a task, or for every task in .vx/workspace.ts:

.vx/workspace.ts
import { defineWorkspace } from '@vzn/vx/config'
export default defineWorkspace({ cache: { outputs: { clean: false } } })
Terminal window
$ ls app/dist
old.txt
$ vx run build --all
⏺︎ 15ms success miss app#build
$ rm -rf app/dist && vx run build --all && ls app/dist
a.txt
old.txt

A retry still starts from clean outputs, and the default, clean: true, is unchanged.

  • vx run --affected --dry names the workspace file that selected every task: affected: .vx/workspace.ts changed (workspace-wide), and --dry=json gives { "kind": "workspace", "file": … }.
  • In a Turbo or Nx repo, what vx cannot map is reported once by vx lock, not before every run.
  • bunx @vzn/vx-migrate maps an Nx repo with no package.json, with the edges written out.
  • Every worktree of a repo now shares the flaky record.

The first run resets vx’s local index once: run history and the flaky record start over, and cached outputs still hit. Then:

  • Refusals: vx <verb>: <message> → the laid-out form above. Scripts should read --format json or the exit code, not stderr.
  • vx show, vx info and vx upgrade text → the tables and reports above; --format json is unchanged.
  • vx lock: locked N project configs → ✓ vx lock N project configs. applyMigration’s lock option returns { code, said }.
  • teardown() → teardown(run?); PluginCache gains getRun(name), so a test double adds getRun: async () => null.
  • *.tsbuildinfo always ignored → an ordinary file. Gitignore it to keep it out of keys.
  • VX_CACHE_OUTPUTS_CLEAN → removed. Set cache.outputs.clean instead.
  • cache.outputs.clean: false → saves leftovers too.
  • vx lock refuses a cached task whose inputs glob over another task’s outputs; turbo() and nx() tasks like that miss once.
  • cachePolicy in run history and telemetry: lR,lW,rR → local:rw,remote:r.

What to do for each: Upgrading.

Terminal window
npm install -D @vzn/vx@latest
npx @vzn/vx lock

A standalone binary updates itself with vx upgrade.

Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.