A CI runner you attach to. odu (Tamil ஓடு — run) runs your
just recipe DAG across machines, posts GitHub
commit statuses, and — unlike every batch CI tool — holds the run as live,
typed state you can attach to from a terminal dashboard while it runs.
$ odu run # the whole DAG, every configured platform
$ odu monitor # attach a live dashboard to the run (other terminal)
$ odu logs -f e2e@x86_64-linux # follow one node's output
Local CI tools translate your task graph into a batch process, run it, and leave you log files. Want to know what's happening mid-run? You scrape logs or poll a process supervisor's socket with a separately-versioned client.
odu inverts that. The runner owns the pipeline as state and serves it as three typed primitives over plain ssh (an oRPC contract, base64-framed over stdio — no daemons, no ports, no agents to install):
| Primitive | Call | What it carries |
|---|---|---|
| Cell | surface.nodes.get({}) |
The whole pipeline's state — one snapshot, then deltas as nodes change. |
| Stream | surface.nodeLog.get({ id }) |
One node's output — a buffered snapshot first (late subscribers replay from the top), then appends. |
| Procedure | surface.node.rerun({ id }) |
The only mutation: reset a node + its transitive dependents and reschedule. |
Every face is a thin adapter over the same contract: the bundled terminal dashboard today; a web dashboard and an MCP server for coding agents are designed on the same surface (see the roadmap below).
odu run (coordinator, your machine)
├─ strict gate: refuse a dirty tree, pin HEAD via `git worktree`
├─ ingest: `just --dump` → the [metadata("ci")] recipe's dependency DAG
├─ per platform lane (hosts.json):
│ nix copy the runner derivation → realise on the host →
│ ssh host odu-runner --stdio → configure over the surface →
│ the host fetches your pushed SHA into a writable per-SHA workspace
│ and runs each node as `just --no-deps <recipe>`
├─ fan-in: lane states merge into one surface, served on .ci/odu.sock
│ (odu status / logs / monitor attach to it, live)
├─ logs: .ci/<sha>/<platform>/<recipe>.log — durable even if the runner dies
└─ GitHub: commit status per <recipe>@<platform> context, posted on
transitions read from the state cell (credentials never leave your machine)
A lane host needs ssh + Nix + outbound https. Nothing else: the runner
binary travels as a Nix closure, the toolchain comes from your repo's dev
shell, and the source arrives by git fetch of the pushed SHA.
nix run github:juspay/odu -- run --no-strict # from anywhere
nix run .#odu -- run # inside a consuming repojust install # pnpm install + hydrate @kolu/* from the npins kolu pin
just typecheck
just test # the loopback falsifiability suite
just run -- run --no-strict --platform x86_64-linux --host x86_64-linux=localhost fmtodu consumes the @kolu/surface
libraries upstream, not vendored — the
drishti pattern: npins pins
juspay/kolu, nix/overlay.nix extracts each package as a store path, and
scripts/hydrate-kolu-packages.sh copies the raw TypeScript into
node_modules/@kolu/ (just update-pins to advance the pin). The repo runs
its own CI with itself: nix run .#odu -- run against the
[metadata("ci")] DAG in ci/mod.just.
~/.config/odu/hosts.json (or $ODU_HOSTS; falls back to justci's
~/.config/justci/hosts.json so migrating needs zero config):
{
"x86_64-linux": "my-linux-builder",
"aarch64-darwin": "me@mac-mini.local"
}Keys are Nix system tuples; values are anything ssh can dial, or
localhost (runs directly, skipping the closure copy). Missing platforms
drop from the fanout. --host PLAT=ADDR pins a platform for one run —
that is how kolu's warm-pool lease (ci/pu/run.sh) injects a leased box.
Exactly one recipe carries [metadata("ci")]; its dependency closure is the
pipeline:
[metadata("ci")]
default: build test lintodu run [recipe[@platform]…] run (selectors compose; bare names fan out
to every platform)
--platform P (repeatable) slice the fanout
--host P=ADDR (repeatable) one-shot host pin
--root NAMEPATH alternative DAG root
--no-deps skip the dependency closure
--no-post strict, but no GitHub writes
--no-snapshot live tree, implies --no-post
--no-strict ≡ --no-snapshot --no-post (dev iteration)
--progress json one NDJSON line per node transition
odu status [-o json] snapshot a live run
odu logs [-f] <node> replay (+ follow) one node's log
odu monitor [-o json] live dashboard (tty) / transition stream
odu dump | graph resolved pipeline as JSON / Mermaid
odu protect [--dry-run] sync branch protection's required contexts
Strict by default: a real CI run refuses a dirty tree, tests the pinned HEAD commit, posts statuses. The opt-outs exist for dev iteration, not CI.
- Pushed SHAs only on remote lanes. Hosts fetch your commit from the origin remote (anonymous https). odu does not ship git bundles, so a remote lane can't test an unpushed commit. Localhost lanes can.
- Live-tree mode is localhost-only.
--no-snapshot/--no-strictrun the live working tree, but only a localhost lane sees it — a remote lane still fetches the committed HEAD. So on a dirty tree odu refuses remote lanes in live mode rather than hand back a verdict that silently tested stale code; slice to local platforms with--platform, or commit+push for a remote run. - One-shot lanes. If the ssh link to a lane dies mid-run, that lane's
unfinished nodes are marked
errored(GitHub stateerror) and the run fails — live state does not survive a runner restart in Phase 1; the per-SHA log files do. - One run per checkout.
.ci/odu.sockis the lock; a secondodu runin the same checkout refuses to start. - Idle attach is not here yet.
odu statuswith no live run exits 1; a long-lived idle runner you can attach to is Phase-2 territory.
odu grew out of kolu's mini-ci example, replaced
justci as the kolu repo's own CI
(juspay/kolu#1252 — same status
contexts, same per-SHA log layout, same strict-mode flag table, so the
migration was invisible to branch protection), and then graduated here, the
way kolu's remote-process-monitor example became
drishti. The design history, the justci
comparison, and the phased roadmap (web + MCP faces) live in the kolu Atlas:
A CI runner you attach to.
License: AGPL-3.0-or-later.