crates/nornir-warehouse-embed/proto/viz.proto
// A TRIMMED copy of nornir-server's `Viz` service — ONE rpc, `Jobs`, and the one
// message it needs. Package `nornir.v1` so the generated method path
// (`/nornir.v1.Viz/Jobs`) matches the live `nornir-server` — dwarves speaks it as a
// THIN client WITHOUT linking the `nornir` crate (a `dwarves → nornir` edge would be
// a dependency cycle; the same pattern telemetry/warehouse/mimir/bench.proto follow).
//
// WHY dwarves needs it. The 🧬 Jera tab renders the jera job ledger. Until now it
// read `jobs.redb` off the FILESYSTEM (`dwarves_embed::read_ledger_jobs`), and the
// rule it was breaking is written one line above the answer in the upstream proto
// (`nornir-wire/proto/nornir.proto:981-983`):
//
// > the server holds the redb lock, SO CLIENTS GO THROUGH HERE.
//
// The file road is wrong for three MEASURED reasons, only one of which is the lock:
//
// 1. **Ownership, not locking.** `jera::jobs::JobStore::open_read_only` genuinely IS
// lock-tolerant — measured 2026-08-25 on t14s: against a live exclusive writer it
// snapshots the file aside and reads its rows. What it is NOT is *permission*-
// tolerant: `open` calls `redb::Database::create`, so a ledger the reader may read
// but not WRITE (`-rw-r--r--`, owner `dwarves`, reader `alice`) fails with
// `I/O error: Permission denied (os error 13)`, `is_lock_error` says false, and the
// snapshot fallback is never reached. The installed forge runs as `dwarves`; the UI
// does not.
// 2. **The snapshot costs a whole-file copy, every poll.** The lock-tolerant path is a
// byte copy of the entire redb into a temp dir. At the Jera tab's 2 s cadence that is
// a recurring copy of the ledger to render a list.
// 3. **The UI has no business knowing the path.** `dwarves_embed::default_ledger_root()`
// resolves `$HOME/.dwarves/warehouse` — the UI's own home. The ledger `Viz.Jobs`
// serves is the SERVED WORKSPACE's (`<nornir warehouse root>/jobs.redb`), which the
// embedded nornir opens for the process lifetime. Those are two different files, and
// a path the UI guesses can only ever be the wrong one on a real deployment.
//
// WHY only `Jobs` AND `CloneEvents`. The upstream `Viz` is twelve rpcs (Timeline,
// ReleaseEvents, BakeoffResults, Knowledge, TestResults, TestMatrix, BenchTelemetry,
// CloneEvents, Architecture, Jobs, Coverage, HolgerRegistry). The trimmed copy is what
// decides the proxy's surface — tonic generates the server trait from THIS file, so the
// forge grows exactly the methods named here, and the compiler (E0046, no default bodies
// on the generated trait) then FORCES each forward to be wired. Same property
// `mimir.proto` documents. A missing forward is a compile error, never a silent
// `Unimplemented`.
//
// `CloneEvents` joined on 2026-08-27, and the reason is the same defect one axis over.
// MEASURED live: `nornir workspace events nordisk --server …:7900` answered
// `Viz.CloneEvents RPC failed: Unimplemented`, while the embedded nornir on :7878
// implements it (`server_grpc.rs::clone_events` → `query_clone_events(&wh,
// CloneSelector::Workspace)`). Every client that can only see :7900 — which is every
// remote dwarves-ui, t14s included — therefore could not ask it anything. And it is the
// ONLY rpc that answers **"why did member X fail to populate"**: the job ledger's rows
// point AT it (`result_ref: "clone_events:nordisk"`), so the pane could show that a
// populate failed and had no road to the reason.
//
// That is precisely the shape the `Jobs` note below records: everything on the path was
// built and wired and the last hop was missing, so the honest server relayed an
// `Unimplemented` that read as a dead box. Adding an rpc to the trimmed proto is a
// one-line change plus a one-line forward, and NOT adding it is a silent hole — which is
// the argument for widening this file whenever a pane needs a verb, rather than leaving
// the client to discover the gap at runtime.
//
// Keep the field numbers identical to `nornir.proto` (edda's `nornir-wire` leaf) so the
// wire is byte-compatible — `tests/proto_drift.rs` re-derives that contract and fails on
// drift.
syntax = "proto3";
package nornir.v1;
// `JsonResponse` is the one already declared in `workspaces.proto` (same package),
// exactly as `mimir.proto` reuses it — one carrier message, not a per-file twin.
import "workspaces.proto";
service Viz {
// Jobs — the unified job ledger for the SERVED workspace (the same data
// `JobStore::list(JobSelector::Workspace)` returns embedded), as a JSON array of
// `jera::jobs::JobRecord`, newest-first. The served workspace comes from the
// `nornir-workspace` gRPC header, so `JobsRequest.workspace` is free.
//
// PAGINATED: `limit` caps rows (server default ~200) so a long-lived ledger never
// overflows the 4 MiB gRPC decode cap.
rpc Jobs (JobsRequest) returns (JsonResponse);
// CloneEvents — the recent `clone_events` populate outcomes for the SERVED workspace
// (the same data `query_clone_events(&wh, CloneSelector::Workspace)` returns
// embedded), as a JSON array of `nornir_wire::view::rows::CloneEventRow`,
// newest-first. This is the one verb that says *why* a member did not populate
// (ok / failed + error + when), which is what a job row's
// `result_ref: "clone_events:<workspace>"` points at.
//
// The served workspace comes from the `nornir-workspace` gRPC header, so
// `VizTimelineRequest.workspace` is free here — identical to `Jobs`.
//
// NOT paginated upstream: the server caps at 2 000 rows internally
// (`VIZ_CLONE_EVENTS_CAP`) because an unbounded all-time history overflowed the 4 MiB
// gRPC decode cap on a big workspace (`PF-cloneevents`, the bug `JobsRequest.limit`
// exists because of). The cap lives on the SERVER, so this proxy forwards the request
// verbatim and inherits it rather than re-deciding a limit the upstream already owns.
rpc CloneEvents (VizTimelineRequest) returns (JsonResponse);
}
// Field-identical to `nornir.proto`'s (edda's `nornir-wire` leaf) so the wire is
// byte-compatible. One field, and it is usually EMPTY: the served workspace arrives in
// the `nornir-workspace` metadata header, not in the body.
message VizTimelineRequest {
string workspace = 1; // release-time workspace name; empty = the served workspace
}
message JobsRequest {
string workspace = 1; // served workspace whose ledger to read
string kind = 2; // optional jobs::kind filter; empty = all kinds
uint32 limit = 3; // max rows, newest-first; 0 = server default (~200)
}