Attach while e2e is still running
Lint already green, e2e still going. odu attach from another terminal: full matrix, focus a node, stream its log — including everything that ran before you connected.
Live CI for just · humans + agents
Batch CI turns your task graph into a process and leaves log files. odu keeps the pipeline alive as typed state — watch every run in the browser, attach from a terminal, or let a coding agent drive those same runs over MCP. Your just file is still the pipeline.
Compile the graph, run once, scrape logs. Mid-run you poll a process; agents get a wall of text.
One local service tracks every run. Open the web board, attach from a terminal, or use MCP — diagnose failures while other jobs continue.
One [metadata("ci")] tag. No second YAML describing the graph you already wrote.
Every repository, one board
Open http://127.0.0.1:18440 after starting a run. Select a run to inspect nodes, read an attempt’s logs, retry a failure or cancel work. The CLI and MCP use the same service and run IDs.
nix run github:juspay/odu -- webThis serves in the foreground. If a service is already running, open its URL; use web --upgrade to replace it in this terminal. Ctrl-C stops the web server; CI runs continue. Use web --backgroundwhen you explicitly want a daemon.
Recorded from real runs
The web board shows runs across repositories. The terminal and agent read and control those same runs. These earlier recordings show the workflow; their command and tool names predate the shared service.
odu run / odu attach — one live pipeline, late attach with buffer replay.odu mcp — agent drives the same state while the terminal watches.What you actually get
Moments that batch CI makes painful — and what odu does instead.
Lint already green, e2e still going. odu attach from another terminal: full matrix, focus a node, stream its log — including everything that ran before you connected.
run_wait reports failures before the run settles. Use log_read to diagnose them. Retry unchanged inputs with run_retry; start a new run for a source fix.
Choose Retry on the failed node. A live run gets a new attempt; a finalized run gets a linked replay of the selected scope.
A bare odu run covers every configured platform and posts a GitHub commit status per recipe@platform — a green matrix on your own machines, no hosted runner rented.
Local coordinator · remote lanes
The shared service runs locally. Each run’s coordinator lives on your machine — a bare odu run fans out across every configured platform at once, delegating each lane to a host over plain ssh. The runner travels as a Nix closure, so hosts need only ssh, Nix, and outbound HTTPS. Nothing preinstalled.
List several machines for a platform. odu leases a free one, locks it for the run, and releases when it settles — or when the holder dies.
The lock is an flock on the builder. Whole pool busy? Wait in line — it names who you're behind — or --no-wait to fail fast.
Never a silent fallback. This machine runs only when you name it: --host P=localhost, the sole lane, or an explicit pool member.
Coding agents
odu mcp exposes the live run over MCP. Structured state in, targeted fixes out — no scraping a terminal.
run_start → run_wait → log_read → run_retry. The unified Odu skill teaches this loop, including cursors, request IDs, cancellation and verifying the final result. MCP starts the service when needed.
Wire it with one stdio entry:
nix run github:juspay/odu -- mcpThree steps
No CI-flavored YAML. Tag one recipe; its dependency closure is the pipeline. Hosts are always explicit — never a silent localhost surprise.
# Odu CI: both e2e suites exercise the Nix-built application.
set working-directory := '..'
nix_shell := if env('IN_NIX_SHELL', '') != '' { '' } else { 'nix develop --accept-flake-config -c' }
# The default devshell has no browsers; use the e2e shell unless they are present.
nix_shell_e2e := if env('PLAYWRIGHT_BROWSERS_PATH', '') != '' { '' } else { 'nix develop .#e2e --accept-flake-config -c' }
[parallel]
[metadata("ci")]
default: typecheck unit fmt nix agent-flake-nix e2e-cli e2e-web bun-nix-fresh nixos-module
install:
{{ nix_shell }} bun install --frozen-lockfile
{{ nix_shell }} sh scripts/hydrate-pins.sh
typecheck: install
{{ nix_shell }} bun run typecheck
unit: install
{{ nix_shell }} bun run test:unit
# Build before e2e so a cold store cannot consume a test timeout.
e2e-cli: install nix
{{ nix_shell }} bun run test:e2e-cli
e2e-web: install nix
#!/usr/bin/env bash
set -euo pipefail
odu="$(nix build .#odu --no-link --print-out-paths --accept-flake-config)/bin/odu"
nix build .#odu-runner --no-link --accept-flake-config
cd packages/web-acceptance
ODU_BIN="$odu" {{ nix_shell_e2e }} bun run test
# THE NIXOS MODULE, booted: two VMs, a real nginx, a real `nix copy` through it.
#
# NOT `[linux]`, and that is a correction the darwin lane made: `just` EXCLUDES a
# recipe whose OS attribute does not match the host, and `default` depends on
# this one, so `[linux]` made every darwin node fail before it started with
# "recipe `default` has unknown dependency `nixos-module`". The attribute is a
# runtime check instead, which keeps the dependency resolvable everywhere and
# makes the skip a line in the lane's log rather than a silent green.
#
# On x86_64-linux the host has `/dev/kvm`: ~41 s warm, ~63 s when the VM image
# has to be built. Without KVM the same test runs under TCG and takes minutes.
nixos-module:
#!/usr/bin/env bash
set -euo pipefail
if [ "$(uname -s)" != Linux ]; then
echo "nixos-module: linux only — this lane is $(uname -s), nothing to boot"
exit 0
fi
nix build .#checks.x86_64-linux.nixos-module --no-link --accept-flake-config
fmt:
{{ nix_shell }} nixpkgs-fmt --check *.nix nix/*.nix nix/packages/*.nix
nix:
nix build .#odu .#odu-runner .#web-ui .#agent-flake-source --no-link
# The AGENT SOURCE a remote dial resolves `odu-runner` from, evaluated exactly
# the way `@kolu/surface-remote` evaluates it.
#
# Load-bearing, not a nicety. `.#agent-flake-source` is ASSEMBLE-ONLY
# (`mkAgentSourceTree` — odu proves no attr through it), so the `nix` recipe
# above builds the tree without ever asking whether it exports the runner. What
# asks is this recipe, and it asks with the dial's own command, on the dial's
# own store path: a fileset or flake gap that reaches only a REMOTE host fails
# at `_ci-setup` on somebody's box, minutes into a lane, otherwise. Kolu carries
# the same guard over its `expose` inventory (`ci::agent-flake-nix`).
agent-flake-nix: nix
#!/usr/bin/env bash
set -euo pipefail
source="$(nix build .#agent-flake-source --no-link --print-out-paths --accept-flake-config)"
system="$(nix eval --impure --raw --expr builtins.currentSystem)"
drv="$(nix eval --accept-flake-config --raw "$source#packages.$system.odu-runner.drvPath")"
case "$drv" in
*.drv) ;;
*) echo "odu-runner resolved to '$drv', not a derivation path" >&2; exit 1 ;;
esac
# Work around crates.io rejecting the fetchurl User-Agent on cold stores.
[private]
_prefetch-crates:
{{ nix_shell }} bash scripts/ci-prefetch-crates.sh .
# Verify bun.nix was regenerated after lockfile changes.
bun-nix-fresh: _prefetch-crates
{{ nix_shell }} sh -c '\
set -eu; \
tmp=$(mktemp -d); \
trap "rm -rf $tmp" EXIT; \
nix run .#bun2nix -- -l bun.lock -o "$tmp/bun.nix"; \
nixpkgs-fmt "$tmp/bun.nix" >/dev/null; \
diff -u bun.nix "$tmp/bun.nix" || { \
echo; \
echo "bun.nix is stale relative to bun.lock."; \
echo "Run: just regenerate-bun-nix && git add bun.nix"; \
exit 1; \
}'[metadata("ci")]. Everything reachable from it is the pipeline — here typecheck, unit, fmt, nix, and e2e.Add [metadata("ci")] to one recipe. That dependency closure is what odu runs.
This starts the local service automatically. Use your Nix system in the host pin; this example is x86_64 Linux.
Shape
| Typical local CI | odu | |
|---|---|---|
| Mid-run | Logs / process poll | Live attach + typed snapshot |
| Agent face | Scraped terminal / ad-hoc scripts | MCP tools + resources, fail-fast wait |
| Retry | Restart the whole graph | Rerun node + dependents only |
| Pipeline config | Often a second graph format | Your just DAG is the pipeline |
| Remote hosts | Agents / daemons / ports | ssh + Nix closure, no preinstall |
Tag one recipe, pick a host, attach. Full reference lives in the docs.