@xevion/tempo
v0.2.1
Published
A task graph for a project's checks, formatters, and dev servers
Readme
@xevion/tempo
A task graph for the scripts a project accumulates: checks, formatters, dev servers, one-off commands. Works with Bun, Node.js (22+), and Deno.
Install
bun add -d @xevion/tempo # or npm/pnpm/yarnQuick Start
Create a tempo.config.ts in your project root:
import { defineConfig, presets, task } from "@xevion/tempo";
export default defineConfig({
tasks: [
...presets.biome({ name: "web" }),
task({ name: "web:types", body: "bunx tsc --noEmit", tags: ["check"] }),
task({
name: "web:dev",
body: "bun run --hot src/index.ts",
tags: ["dev"],
persistent: true,
}),
],
commands: {
check: { description: "Run every check in parallel", tags: ["check"] },
fmt: { description: "Apply every formatter", tags: ["format"], concurrency: 1 },
dev: { description: "Run the dev server", tags: ["dev"] },
},
});tempo check # everything tagged "check", in parallel
tempo check web # narrowed to one component, dependencies preserved
tempo fmt # everything tagged "format", one at a time
tempo list # every task with its tags and dependencies
tempo run web:types # specific tasks by nameA target names a task, a tag, or a namespace (web covers web:*). One that matches nothing
in the command's selection is an error listing what does, never a quietly smaller run.
requireTargets: true on a command makes a bare invocation the same error, for a workspace
where "everything" is no longer a sensible default.
The model
A task is the atom: a name, a body, tags, requirements, and edges. A command is a
selector over tasks rather than a runner of its own — check means "everything tagged
check". Adding a task to a tag adds it to the command; there is no second place to register it.
A body is a shell string, an argv array, or a TypeScript function, and all three are peers. A function body gets the same row, timing, cancellation, and JSON record as a spawned process:
task({
name: "api:bindings",
tags: ["check"],
body: async (ctx) => {
const { stdout } = await ctx.capture(["cargo", "metadata", "--format-version", "1"]);
if (!stdout.includes("ts-rs")) ctx.fail("ts-rs is not in the dependency tree");
await ctx.run(["cargo", "test", "export_bindings"]);
},
});needs and after are orthogonal. needs(x) requires x to succeed and pulls it into the run;
after(x) orders only, and is inert unless x was already selected. Use after for a reporting
step that should still run when the thing it reports on failed.
Caching
Declare what a task reads and writes, and it is skipped while its inputs are unchanged:
task({
name: "pkg:build",
body: "bun run build",
tags: ["build"],
inputs: ["src/**/*.ts", "package.json"],
outputs: ["dist/**/*.mjs"],
});Inputs are fingerprinted by content, not timestamps. Inside a git work tree they are the files
git ls-files -co --exclude-standard lists, so ignored trees such as target/ are never
walked; a pattern with no wildcard names one file and counts even when it is ignored. Digests are
remembered in .tempo/hashes.json by size and timestamps, so a cached task re-reads only what
changed. Outputs are checked by size and mtime, so a hand edit to a generated file reruns its
task; they are found by walking below each pattern's literal prefix, so ignored build artifacts
work. cacheKey adds anything else the result depends on, such as a compiler version.
--no-cache recomputes everything for one run. Fingerprints live in .tempo/, which ignores
itself, so nothing reaches your diff. A cache hit satisfies dependents: anything that needs
this task proceeds immediately.
Dev sessions
A persistent task with a watch spec runs under a supervisor that restarts it when its files
change, re-running its needs first:
task({
name: "api:dev",
body: ["cargo", "run", "-p", "server"],
tags: ["dev"],
persistent: true,
needs: ["api:build"],
// interrupt: the rebuild rewrites the running binary, so stop before rebuilding.
watch: { paths: ["src"], exts: [".rs"], interrupt: true },
readyWhen: async (ctx) => (await ctx.run("curl -sf localhost:8080/health")) === 0,
});readyWhen gates dependents on serving rather than on spawning. interrupt decides ordering:
stop-then-rebuild when the rebuild would overwrite the running binary, otherwise
rebuild-then-swap so a failed build costs nothing.
Requirements
A task can declare what its environment must provide. Missing requirements skip the task locally and fail it under CI, so a machine without a tool loses a check rather than pretending:
task({
name: "ci:actionlint",
body: "actionlint",
tags: ["check"],
requires: [{ tool: "actionlint", hint: "brew install actionlint" }],
});{ tool }, { env }, and { file } are all accepted.
Locking
lock: true serializes a task against every other tempo process in the project — for a tool
whose incremental cache corrupts when two invocations race. A string names its own lock file, so
tasks contending for one cache can share a lock without taking the rest. A blocked task waits
rather than failing, and the wait is reported beside the work:
✓ mod:verify (41.2s, waited 12.8s)Presets
Presets generate the tasks for a toolchain. override replaces a body, or drops the task with
false, and naming a task the preset does not produce is an error rather than a silent no-op:
...presets.rust({
name: "api",
override: {
lint: "cargo clippy --all-targets --all-features -- -D warnings",
build: false,
},
})| Preset | Tasks it generates |
|--------|--------------------|
| presets.rust | format, format-fix, lint, test, build |
| presets.biome | format, format-fix, lint, build, and type-check/test on request |
| presets.go | format, format-fix, lint, build, test |
| presets.gradle | format, format-fix, lint, compile, test |
Throttling
A run takes the whole machine by default. throttle hands most of it back while something
else needs it, which is what makes a full gate survivable during a game or another build:
export default defineConfig({
throttle: {
whileRunning: ["bf4.exe"], // omit to throttle unconditionally
cores: 2, // whole physical cores, SMT siblings included
concurrency: 2, // a cap, never a raise
env: { CARGO_BUILD_JOBS: "2" },
},
tasks: [...],
})Each child is spawned behind taskset -c <cpus> chrt -i 0 ionice -c3, so every tool
downstream sizes its own thread pool from the affinity mask without a per-tool flag. Linux
only. whileRunning is checked again as tasks spawn, so a game launched mid-session throttles
the next rebuild; the concurrency cap is fixed when the run starts. throttle: false on a
command exempts it, and on a task keeps that one process (a dev server, say) at full priority.
CLI
Global flags are parsed before the command, so -- and everything after it survives:
| Flag | Effect |
|------|--------|
| --config <path> | Use a specific config |
| --dry-run | Print the layers that would run |
| --json | Emit the raw engine record stream as JSON Lines on stdout |
| -c, --concurrency <n> | Cap parallel tasks |
| --no-cache | Ignore fingerprints |
| --no-throttle | Ignore config.throttle for this run |
--json is the engine's own record stream, not a rendering of it: stdout carries records and
nothing else, so it stays parseable while a task is writing to the terminal.
Exports
import { defineConfig, presets, task } from "@xevion/tempo";
import { Graph, run, supervise } from "@xevion/tempo/engine";
import { c, elapsed } from "@xevion/tempo/fmt";@xevion/tempo/engine is the graph and scheduler on their own, for driving the engine without
the CLI.
Global installs
A config can import from @xevion/tempo even when the package is not in the project's
node_modules — the CLI registers itself as a virtual module, so a globally installed tempo
works in a project with no package.json at all. Type information still needs a real
devDependency, since the compiler cannot see a module that only exists at runtime.
