odu-web — the service face of odu
The browser face of odu is not a tab watching one live run — it is the service layer above the runner: a run ledger that outlives coordinators, the page a commit status Details link points at, forge triggers, and a multi-repo fleet dashboard. That territory belongs to juspay/vira today, so this is also the plan for whether odu-web replaces it. Proposed in five phases, with UI prototypes.
The question that produced this note: now that odu has a TUI and an MCP face, what would the web app look like — and should it replace juspay/vira? The answer reframes the question: the web app worth building is not a third rendering of the attach surface. It is a new program above the runner — ledger, triggers, fleet — and the browser is merely how you look at it. proposed
Status: proposed · maturity seedling · the runner this builds on is odu (Phase 1 + MCP face shipped) · the app shell is surface-app (kolu and drishti are its two consumers; odu-web would be the third)
Two products hide in “the web app”
The first is the attach face: a browser tab rendering one live run — the TUI with rounder corners. The odu note already judged this one correctly, and the judgement stands: nobody opens a tab to watch a run they kicked off from their shell, the TUI and MCP faces serve the single-operator case better, and a hosted page is the one client that drags in the authz boundary the other faces dodge. Built alone, the attach face is cost with no constituency.
The second is the service face, and it has three constituencies the runner cannot reach today:
- The PR author who never typed
odu run. odu posts commit statuses; a GitHub status carries a Details link; today odu has nothing for it to point at. The moment a teammate’s check goes red, the product they need is a URL: this SHA, this node, this log. - The team asking “what is CI doing right now, everywhere.” Fan-in across lanes, hosts — and repos. A single-repo web dashboard has no reason to exist; multi-repo is what justifies the tab.
- Anyone asking about yesterday. The coordinator’s state dies with it; only per-SHA log files survive. History, trends, “when did this lane start flaking” — there is no client that can answer, because there is nothing durable to ask.
Every one of those is a property of a layer above the runner — persistence, triggers, identity across runs — not of a fourth rendering of nodes · nodeLog · rerun. That is the product. The attach face then falls out of it for free (Phase 2 below), instead of the other way around.
The wedge — be the target_url
The smallest shippable slice of the service face is also the highest-value one: the run page, the URL odu’s commit statuses start carrying as target_url. It is read-only, history-backed, and needs neither triggers nor live attach — only a run ledger: today’s per-SHA on-disk log layout (which already survives runner death, by design) formalized into a queryable record of runs, verdicts, durations, and logs that odu-web ingests as runs complete.
Three properties make this the wedge and not just the first feature:
- It is the first moment odu is visible to people who never invoked it. Every hosted CI’s adoption loop runs through the Details link; odu’s gate half (statuses, branch protection) shipped in Phase 1 of the odu note — this completes that loop.
- It forces the ledger, and nothing else. No triggers, no live protocol in the browser, and almost no authz — a read-only page behind whatever the team already uses (tailnet, basic auth). The greyed-out rerun button in the mockup is the Phase 4 boundary, visible but inert.
- It survives everything. The page renders from the ledger, not from a live coordinator — a run that finished last week and a runner that died mid-flight both have a page.
The service — a coordinator of coordinators
Everything past the wedge is one program growing around the ledger. The load-bearing decision is that none of it lands in the runner. odu keeps its shape — spawn, own one DAG, serve three primitives, exit relevance when the run is done. odu-web is the thing with opinions about many runs: it ingests forge events (webhook first, vira-style polling as the fallback for forges or networks without one), resolves repo × SHA into an odu invocation exactly the way the MCP face’s run tool does today, attaches to the live coordinator as a read client, mirrors nodes and nodeLog into the browser, and writes the verdict into the ledger when the run settles.
Because the browser speaks the same useCell / useStream Solid hooks every other face rides, the live view is the part that costs the least — surface-app already solves the app-shell problems (freshness, build identity, PWA install), with kolu and drishti as its two production consumers. A live run page looks like the wedge page with the verdict still warm:
And the fleet view is the same data one level up — repo is just another fan-in axis, the generalization of the lane matrix that the odu note catalogued as open:
The boundary the browser alone forces arrives on schedule, not up front: read-observer vs mutator. rerun is remote code execution, and a hosted page is the first client whose holder is not automatically the operator. The wedge and the live observer ship with mutations simply absent from the wire — the same trick odu mcp used to dodge run.configure — and Phase 4 introduces the mutator role as an explicit grant rather than retrofitting auth onto a surface that already leaked it.
Does it replace vira?
It should — on the timescale where the service face actually exists, and not before. vira is “no-frills CI for small teams using Nix”: a Haskell web app with acid-state persistence, managing registered repos and building them in Nix environments. It works today; nothing here argues for switching anything off this week. The argument is structural, and it is the same one the odu note ran against justci:
| Axis | vira today | odu-web as planned |
|---|---|---|
| Trigger model | watches registered repos, builds on its own initiative | Phase 3 — webhook first, polling fallback |
| Persistence | acid-state database | the run ledger (Phase 1), grown from odu’s per-SHA layout |
| Multi-repo | yes — its core shape | Phase 3 — repo as a fan-in axis |
| Run granularity | the build, coarse | odu’s per-node DAG: statuses, logs, surgical rerun per node |
| Live observation | page-grade | snapshot-then-delta surface; late attach replays everything |
| Agent face | none | shipped — odu mcp (
#3 ) |
| Forge gate | — | shipped — byte-compatible commit statuses + protect |
| Stack | Haskell · htmx · acid-state | TypeScript · @kolu/surface · surface-app (Solid) |
Two stacks under one author is a losing position — justci already taught that lesson here. vira’s differentiators (triggers, persistence, multi-repo web UI) are things odu-web must build anyway; odu’s differentiators (the typed live surface, per-node DAG, the MCP face) are things vira’s batch-of-builds shape cannot retrofit, for the same reason justci couldn’t. So the honest framing is sunset-when-superseded: vira keeps serving its teams until odu-web’s Phase 3 covers a Nix team’s daily loop, and Phase 4’s exit criterion below names the test rather than the wish.
Phases
Phase 0 lives in the runner and is already on odu’s Phase 2 backlog; everything after it lives in odu-web, each phase shippable on its own.
- Phase 0 · prerequisites in the runnerIdle attach + the long-lived runner — a coordinator you can reach with nothing running — plus run identity stable enough for a ledger to reference (repo, SHA, run, node). Both named on the odu roadmap; odu-web is the consumer that makes them load-bearing. This phase has its own work order — odu-runner. Run identity shipped (
#28 · juspay/odu#28 ): every run now writes a durable
(repo, sha, seq)RunRecordto.ci/<sha7>/runs/<seq>.json, listable viaodu runs— the ledger Phase 1 reads is now real on disk. Still ahead: the serve/run split + idle attach (R1, riding the surface-daemon spine) and the lifecycle (R3). - Phase 1 · the ledger and the run pageFormalize the per-SHA on-disk layout into a queryable run ledger that outlives coordinators; serve the read-only per-SHA run page; flip odu’s commit statuses to carry
target_url. No triggers, no live wire to the browser, no new authz beyond a read gate. Exit criterion: a red check on a kolu PR links to a page that names the failed node and shows its log. - Phase 2 · the live observerThe surface-app PWA (third consumer after kolu and drishti): odu-web attaches to live coordinators and mirrors
nodes/nodeLoginto the browser over the sameuseCell/useStreamhooks the future OpenTUI face would share — “faces over one surface” becomes shared view code. Read-only: mutations stay off the wire entirely. - Phase 3 · triggers and the fleetForge-event ingestion (webhook first, vira-style polling as fallback), the repo registry, runs spawned per push the way the MCP
runtool spawns them today, and the multi-repo fleet dashboard. This is the line where odu crosses from tool to service — and the first phase that overlaps vira’s core. - Phase 4 · mutators, and the vira questionThe read-observer/mutator split as an explicit grant;
rerunfrom the run page for holders of it. Then the sunset test, stated as an exit criterion: when a Nix team’s daily loop — push, gate, red check, diagnose, rerun, green — runs through odu-web without reaching for vira, retire vira. Until it passes, both live.