Skip to content
vxvx
GitHubBlueskydev.toRSS

History server

@vzn/vx-history-server keeps how long each task takes, how many cores it keeps busy and its peak memory, so a fresh CI runner orders its run by them from the first task. It is a history, not a log: one row per task and branch, and each report folds into it. It speaks the vx-history protocol (/v1). vxHistory() from @vzn/vx-history points vx at it.

The timings go in D1. From packages/vx-history-server in a checkout of the vx repository, with TRUST filled in wrangler.jsonc:

Terminal window
bun install
bunx wrangler login
bunx wrangler deploy

On the first deploy, wrangler creates the D1 database wrangler.jsonc names (vx-history), bound as DB. A report is one statement, so a 10 000-task upload fits the free plan.

Put TLS in front of it: a CI token is a bearer token.

Setting Default What it sets
TRUST empty: refuse all the repositories it serves and the jobs that write protected refs (TRUST)
AUDIENCE the origin it is reached at the aud a CI token must name
READ_ACCESS unset: a token for every request public: a request with no token reads a repository TRUST names, and writes nothing
TIMING_DAYS 30 days a task’s row outlives its last report

On Workers, each setting is a var in wrangler.jsonc.

One row per issuer, repository name, scope (the branch) and task definition hash. The first three are stored as the first 128 bits of their SHA-256, in hex, with no secret key, so the database names no repository or branch. A task’s definition is its command and env.define: a task whose command changed starts a new row, and editing its inputs or deps keeps the old one. Each report folds into its row in one statement, so two jobs reporting at once lose nothing:

  • durationMs and cores: old × 0.7 + new × 0.3. A task that got slower is followed within about three runs, and one outlier moves it 30%.
  • rssBytes: max(new, old × 0.9). A reservation needs the peak, and the decay lets a task that shrank come down.
  • runs: how many reports made the row.

No run’s log, list of runs or raw sample is kept. A row not reported for TIMING_DAYS is deleted: the first upload of each day sweeps.

.vx/workspace.ts
import { defineWorkspace } from '@vzn/vx/config'
import { vxHistory } from '/path/to/vx/packages/vx-history/src/index.ts'
export default defineWorkspace({
plugins: [
vxHistory({
server: 'https://history.example.com', // or VX_HISTORY_URL
issuer: 'https://token.actions.githubusercontent.com',
repository: 'acme/app',
}),
],
})

issuer (the CI issuer’s URL, as its tokens name it) and repository (org/name there) are required with a server: every request names both by their hashes, and the server refuses a token of another repository.

Each run asks the server for its tasks’ rows, its branch’s first, then the default branch’s. It starts on its local history and re-ranks the tasks not yet started when the rows land; it never waits for them. At its end it uploads the timings of the tasks it ran, once. In a distributed run, the orchestrator uploads them, each worker’s measurements included.

Each CI job needs a token for the server’s origin:

.github/workflows/ci.yml
permissions:
contents: read
id-token: write

A refusal is said with the server’s words. A CI job with no token says so once. With no token (off CI) a run reads only, from a server with READ_ACCESS=public, and uploads nothing. The plugin’s other options (assume, window, reservations) are on its reference page.

All under /v1 (PROTOCOL.md):

Route Does
POST /v1/timings/upload { timings: [{ hash, durationMs, cores?, rssBytes? }] }, up to 10 000, each folded into the token’s scope; 204
POST /v1/timings/query { scopes, hashes }, 1 to 8 scopes (each a ref’s hash, as the cache server sends it) and up to 10 000 hashes: each hash’s row in the first scope that holds one
GET /v1/capabilities { maxUploadTimings, maxQueryHashes, maxQueryScopes }

A query answers { timings: { <hash>: { durationMs, cores, rssBytes, runs } } }; a hash no scope holds is absent. Every refusal is { code, message }.

To mount it in a server of your own, createHistoryServer({ store, authorize, timingDays? }) returns a fetch handler, and sqliteStore(db) keeps the rows in any SQLite that bun:sqlite or D1 serves. The README lists the whole API.

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