Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/registry.rs

//! Server-global **workspace registry** — the persistent index of every
//! workspace the `nornir` server tracks.
//!
//! This is the index the self-sync poll loop updates, `nornir workspace ls`
//! reads, and (next phase) the `Workspaces.*` gRPC service / viz picker query.
//! It is distinct from a *warehouse* (one per workspace, under `builds/`) and
//! from a [`crate::workspace::WorkspaceDescriptor`] (the per-workspace
//! `nornir-workspace.toml` that lists members) — the registry just *indexes*
//! those, keyed by workspace name.
//!
//! **Storage:** a single redb file at `<server.root>/registry.redb`. redb
//! stores **bytes**, not structs, so each row is `workspace_name (&str) →`
//! the [`Workspace`] record serialized with serde_json. The server is the sole
//! writer, matching the redb/iceberg single-writer discipline used for the
//! warehouse.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use redb::{Database, ReadableTable, TableDefinition};
use serde::{Deserialize, Serialize};

/// `name → serde_json(Workspace)`. Bytes in, bytes out.
const TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("workspaces");

/// Validate that a workspace name is a single safe path segment before it is
/// ever joined onto the registry root and handed to `remove_dir_all`/`open`.
///
/// A workspace name keys the registry **and** names an on-disk dir
/// (`<root>/<name>/builds`), so a name like `..`, `/etc`, or `a/b` could make the
/// purge path escape the registry root and `remove_dir_all` an arbitrary
/// directory. Reject empty names, anything containing a path separator (`/` or
/// `\`), the parent ref `..`, the current-dir ref `.`, and absolute / rooted
/// names. This is the data-loss guard the nordisk wipe taught us to add.
pub fn validate_ws_name(name: &str) -> Result<()> {
    if name.is_empty() {
        bail!("workspace name must not be empty");
    }
    if name == "." || name == ".." {
        bail!("workspace name must not be `.` or `..`");
    }
    if name.contains('/') || name.contains('\\') {
        bail!("workspace name `{name}` must be a single path segment (no `/` or `\\`)");
    }
    // Defense in depth: reject anything the OS would treat as more than one
    // normal component (absolute paths, drive prefixes, `..` components).
    let p = Path::new(name);
    let mut comps = p.components();
    match (comps.next(), comps.next()) {
        (Some(std::path::Component::Normal(c)), None) if c == name => Ok(()),
        _ => bail!("workspace name `{name}` is not a single safe path segment"),
    }
}

/// How a workspace's source is provided.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
    /// Server polls member git URLs and republishes (server-user `nornir`).
    Monitored,
    /// A thin client pushes its working-tree source in; server computes.
    Pushed,
    /// Sources live outside (fat-style external checkout); only `builds/` is
    /// server-owned and `git/` stays empty.
    External,
}

impl Mode {
    pub fn as_str(&self) -> &'static str {
        match self {
            Mode::Monitored => "monitored",
            Mode::Pushed => "pushed",
            Mode::External => "external",
        }
    }

    /// Does the server open a SERVED warehouse handle for a row in this mode —
    /// the ONE writer of that decision, shared by boot, runtime-register and the
    /// tests (never re-implemented at a call site).
    ///
    /// Mode gates **fetching**: only [`Mode::Monitored`] has git remotes the poll
    /// loop pulls. It must never gate **reading** — all three modes own a
    /// server-computed `<root>/<name>/builds` warehouse, so all three are served:
    ///
    /// * `Monitored` — the server clones and republishes into `builds/`.
    /// * `Pushed`    — a thin client pushes source in and *the server computes*;
    ///   if it were not served, a pushed workspace could never be read at all,
    ///   and `republish_served` would bail before writing the result.
    /// * `External`  — sources live outside, but `builds/` is explicitly
    ///   server-owned, which is precisely the thing being served.
    ///
    /// The match is exhaustive on purpose: a new mode has to state its answer
    /// here rather than inherit a silent `false`.
    pub fn is_served(&self) -> bool {
        match self {
            Mode::Monitored | Mode::Pushed | Mode::External => true,
        }
    }
    /// Parse a mode string; anything unrecognized ⇒ [`Mode::Pushed`].
    pub fn parse(s: &str) -> Self {
        match s {
            "monitored" => Mode::Monitored,
            "external" => Mode::External,
            _ => Mode::Pushed,
        }
    }
}

/// The server's startup serve report, in ONE place, so the line the operator
/// reads can never contradict what `nornir workspace ls` returns.
///
/// `registered` is what the registry HOLDS; `served` is how many of those rows
/// got an open warehouse handle; `failed` is `(name, error)` for each row that
/// could not be opened.
///
/// The bug this exists to make unrepresentable: the old line said
/// `serving 0 workspace(s) (empty registry — register one …)` whenever the
/// served map was empty — including when the registry held a fully-populated
/// workspace. That sent every reader down the registration path for a workspace
/// that was already registered. "Empty registry" is now claimed only when the
/// registry is genuinely empty; any other zero has to name its reason.
pub fn serving_line(registered: usize, served: usize, failed: &[(String, String)]) -> String {
    if registered == 0 {
        return "nornir-server: serving 0 workspace(s) (empty registry — register one with \
                `nornir workspace register <name> --descriptor <toml>`)"
            .to_string();
    }
    if failed.is_empty() && served == registered {
        return format!("nornir-server: serving {served} workspace(s)");
    }
    let why = failed
        .iter()
        .map(|(n, e)| format!("`{n}`: {e}"))
        .collect::<Vec<_>>()
        .join("; ");
    format!(
        "nornir-server: serving {served} of {registered} registered workspace(s) — \
         {} could NOT be opened: {why}",
        registered - served
    )
}

/// Per-member sync state (meaningful in [`Mode::Monitored`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemberState {
    pub name: String,
    /// The member's FETCH SOURCE, as resolved by `RepoSpec::source`: a git URL for
    /// a `git =` member, or the absolute local checkout for a FAT `path =` member
    /// (`gitio::clone_or_fetch` clones from a local path unchanged). Empty ⇒ no
    /// resolvable source, and the monitor SKIPS the member.
    pub remote: String,
    /// Tracked ref/branch; empty ⇒ `main`.
    pub git_ref: String,
    /// Last SHA seen via `git ls-remote`; empty ⇒ never polled.
    pub last_seen_sha: String,
    /// RFC3339 of the last successful fetch; empty ⇒ never.
    pub last_synced: String,
    /// `"ok"` | `"fetching"` | `"error: …"` | "".
    pub sync_state: String,
    /// Working-tree freshness of the server's clone (AUT6). The SHA above can
    /// only see *committed* state; this digest sees uncommitted edits too. A
    /// thin client compares its own local digest against this to flag staleness.
    /// Equals [`crate::gitio::CLEAN_WORKTREE_DIGEST`] when the clone is clean;
    /// empty ⇒ never computed. See [`crate::gitio::worktree_freshness`].
    #[serde(default)]
    pub worktree_digest: String,
    /// `true` ⇒ the server's clone has uncommitted changes (tracked mods, staged,
    /// or untracked). Mirrors `worktree_digest != CLEAN_WORKTREE_DIGEST`.
    #[serde(default)]
    pub worktree_dirty: bool,
}

/// What registering a workspace changed in its member list
/// ([`Registry::register`]).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MemberChange {
    /// There was no row before: every member is new, and none is "added".
    pub first: bool,
    /// Members the row did not have (or had from another remote): to clone.
    pub added: Vec<String>,
    /// Members the row had and the descriptor no longer names.
    pub removed: Vec<String>,
}

impl MemberChange {
    pub fn is_empty(&self) -> bool {
        self.added.is_empty() && self.removed.is_empty()
    }

    /// `added openzl-sys-rs; removed old-thing (its clone and its rows in the
    /// code room stay: nothing retires a member's data)` — empty when nothing
    /// changed.
    pub fn line(&self) -> String {
        let mut parts = Vec::new();
        if !self.added.is_empty() {
            parts.push(format!("added {}", self.added.join(", ")));
        }
        if !self.removed.is_empty() {
            parts.push(format!("removed {} (its clone and its rows in the code room stay: nothing retires a member's data)", self.removed.join(", ")));
        }
        parts.join("; ")
    }
}

/// One registry row: a workspace the server tracks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
    pub name: String,
    pub mode: Mode,
    /// Seed: a local path or git URL to the workspace's `nornir-workspace.toml`.
    pub descriptor: String,
    /// Poll interval for monitored workspaces, e.g. `"60s"`; empty ⇒ default.
    pub poll: String,
    /// Iceberg snapshot id the warehouse currently publishes; empty ⇒ none yet.
    pub current_snapshot: String,
    pub members: Vec<MemberState>,
    /// The descriptor's raw TOML **content**, stored IN the registry so the row is
    /// the self-contained source of truth — the server re-reads `deep_scan`/members
    /// from here, never a sibling `descriptors/*.toml` file. Empty for a git-URL
    /// seed whose content isn't known until first fetch. `#[serde(default)]` so rows
    /// written before this field round-trip cleanly.
    #[serde(default)]
    pub descriptor_content: String,
    pub created_at: String,
    pub updated_at: String,
}

impl Workspace {
    /// Build a record, seeding members from a local `nornir-workspace.toml`
    /// descriptor when it exists — every repo resolved through `RepoSpec::source`,
    /// so a `git =` member seeds its URL and a FAT `path =` member seeds its
    /// absolute local checkout (see [`Self::members_from`]). A git-URL descriptor
    /// (not a local path) yields an empty member list until first fetch. Pass
    /// `created_at` to preserve it across a re-register.
    pub fn new(
        name: String,
        descriptor: String,
        mode: Mode,
        poll: String,
        created_at: Option<String>,
    ) -> Self {
        // Read the descriptor FILE when the seed is a local path, capturing its raw
        // content into the row so the registry — not the file — is the source of
        // truth thereafter (a git-URL seed leaves both empty until first fetch).
        let mut members = Vec::new();
        let mut content = String::new();
        let dpath = std::path::Path::new(&descriptor);
        // A DIRECTORY seed resolves to its descriptor file (FAT-first); a plain
        // directory read would fail and silently yield 0 members. A git-URL seed
        // (not a local path) resolves to None → members stay empty until fetch.
        if let Some(dfile) = crate::workspace::resolve_descriptor_path(dpath)
            && let Ok(text) = std::fs::read_to_string(&dfile)
        {
            if let Ok(desc) = crate::workspace::WorkspaceDescriptor::from_content(&text) {
                members = Self::members_from(&desc);
            }
            content = text;
        }
        Self::assemble(name, descriptor, content, mode, poll, members, created_at)
    }

    /// Build a record from descriptor **content** (thin-mode: the client shipped the
    /// TOML). Members come from the content and the content itself is stored in the
    /// row — nothing touches disk, so no `descriptors/*.toml` file is needed.
    pub fn from_content(
        name: String,
        descriptor: String,
        content: String,
        mode: Mode,
        poll: String,
        created_at: Option<String>,
    ) -> Self {
        let members = crate::workspace::WorkspaceDescriptor::from_content(&content)
            .map(|d| Self::members_from(&d))
            .unwrap_or_default();
        Self::assemble(name, descriptor, content, mode, poll, members, created_at)
    }

    /// Seed one [`MemberState`] per descriptor repo, routing through the SHARED
    /// [`RepoSpec::source`] resolver (LAW 5: one writer both member kinds go
    /// through) rather than reading `spec.git` directly.
    ///
    /// Why this matters: a FAT descriptor declares its members as `path =
    /// "/abs/checkout"`, not `git = "…"`. Reading only `spec.git` left every such
    /// member with an EMPTY `remote`, and an empty remote is *skipped* by
    /// [`crate::monitor::fetch_workspace_with_progress`] — so a 19-member FAT
    /// workspace materialized 0 checkouts and `republish_with` then failed with
    /// "0 of 19 member(s) have a `.git` clone", an error that blames the deploy
    /// key / network for members that never had a remote to fetch at all.
    ///
    /// `gitio::clone_or_fetch` passes a LOCAL PATH through unchanged (only
    /// `http(s)` is SSH-coerced), so a resolved local checkout works as a clone
    /// source with no other change: the monitor clones `path` → `<root>/<ws>/git/
    /// <member>` exactly as it clones a git URL, and the whole downstream build
    /// (dep graph, knowledge scan, snapshot) is untouched.
    ///
    /// A member whose `path` is RELATIVE and whose descriptor came from content
    /// with no base dir (thin mode: `WorkspaceDescriptor::from_content` leaves
    /// `descriptor_dir` empty) cannot be resolved — it keeps an empty remote
    /// rather than fabricating a wrong one.
    fn members_from(desc: &crate::workspace::WorkspaceDescriptor) -> Vec<MemberState> {
        use crate::workspace::descriptor::RepoSource;
        desc.repos
            .iter()
            .map(|(mname, spec)| {
                let remote = match spec.source(&desc.descriptor_dir) {
                    // A local (FAT) checkout is a legitimate clone source — but
                    // only once resolved to an ABSOLUTE path.
                    Ok(RepoSource::Path { resolved, fallback }) => {
                        if resolved.is_absolute() {
                            resolved.to_string_lossy().into_owned()
                        } else {
                            // Unresolvable relative path: prefer the declared git
                            // fallback over a fabricated path.
                            fallback.map(|g| g.url).unwrap_or_default()
                        }
                    }
                    Ok(RepoSource::Git(g)) => g.url,
                    // Neither `path` nor `git`: nothing to fetch from.
                    Err(_) => String::new(),
                };
                MemberState {
                    name: mname.clone(),
                    remote,
                    git_ref: spec.branch.clone().unwrap_or_default(),
                    ..Default::default()
                }
            })
            .collect()
    }

    #[allow(clippy::too_many_arguments)]
    fn assemble(
        name: String,
        descriptor: String,
        descriptor_content: String,
        mode: Mode,
        poll: String,
        members: Vec<MemberState>,
        created_at: Option<String>,
    ) -> Self {
        let now = chrono::Utc::now().to_rfc3339();
        Workspace {
            name,
            mode,
            descriptor,
            poll,
            current_snapshot: String::new(),
            members,
            descriptor_content,
            created_at: created_at.unwrap_or_else(|| now.clone()),
            updated_at: now,
        }
    }
}

/// The redb-backed registry. Open once; cheap to clone-free reuse.
pub struct Registry {
    db: Database,
    /// The server root the registry lives under (`<root>/registry.redb`). Each
    /// workspace's on-disk build data sits beside it at `<root>/<name>/builds/`;
    /// [`Registry::remove`] resolves that from here when purging.
    root: PathBuf,
}

impl Registry {
    /// Open (creating if absent) the registry at `<root>/registry.redb`.
    pub fn open(root: &Path) -> Result<Self> {
        std::fs::create_dir_all(root)
            .with_context(|| format!("create registry root {}", root.display()))?;
        let path = root.join("registry.redb");
        let db = Database::create(&path).with_context(|| format!("open {}", path.display()))?;
        // Materialize the table so first-time reads don't fail.
        let w = db.begin_write()?;
        {
            let _ = w.open_table(TABLE)?;
        }
        w.commit()?;
        Ok(Self {
            db,
            root: root.to_path_buf(),
        })
    }

    /// The on-disk `builds/` dir for `name` (`<root>/<name>/builds`): the
    /// per-workspace warehouse + `catalog.redb` + `jobs.redb`. Resolving it here (off
    /// the registry root) keeps the layout in one place for the purge path.
    pub fn builds_dir(&self, name: &str) -> PathBuf {
        self.root.join(name).join("builds")
    }

    /// Registry root (the dir holding every `<name>/` workspace dir). Used by the
    /// purge path to assert a resolved `builds_dir` cannot escape the root.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Insert or replace a workspace row.
    pub fn upsert(&self, ws: &Workspace) -> Result<()> {
        let bytes = serde_json::to_vec(ws).context("encode workspace record")?;
        let w = self.db.begin_write()?;
        {
            let mut t = w.open_table(TABLE)?;
            t.insert(ws.name.as_str(), bytes.as_slice())?;
        }
        w.commit()?;
        Ok(())
    }

    /// Fetch one workspace by name.
    /// **Register `ws` over whatever row is there**, in ONE transaction, and
    /// say what changed: which members are new, which are gone. A member that
    /// stays (same name, same remote) keeps what the server already knew of
    /// it — its last seen commit, when it was synced — and the row keeps its
    /// `current_snapshot`: registering a workspace again is how a member is
    /// added, not a reason to forget the other seventeen.
    pub fn register(&self, ws: &Workspace) -> Result<(Workspace, MemberChange)> {
        let w = self.db.begin_write()?;
        let (stored, change) = {
            let mut t = w.open_table(TABLE)?;
            let old: Option<Workspace> = match t.get(ws.name.as_str())? {
                Some(v) => Some(serde_json::from_slice(v.value()).context("decode workspace record")?),
                None => None,
            };
            let mut new = ws.clone();
            let mut change = MemberChange { first: old.is_none(), ..Default::default() };
            if let Some(old) = &old {
                new.current_snapshot = old.current_snapshot.clone();
                for m in &mut new.members {
                    match old.members.iter().find(|o| o.name == m.name) {
                        Some(o) if o.remote == m.remote => {
                            let git_ref = m.git_ref.clone();
                            *m = o.clone();
                            m.git_ref = git_ref;
                        }
                        // the same name from another remote is another clone
                        Some(_) => change.added.push(m.name.clone()),
                        None => change.added.push(m.name.clone()),
                    }
                }
                change.removed = old.members.iter().filter(|o| !new.members.iter().any(|m| m.name == o.name)).map(|o| o.name.clone()).collect();
            }
            let bytes = serde_json::to_vec(&new).context("encode workspace record")?;
            t.insert(new.name.as_str(), bytes.as_slice())?;
            (new, change)
        };
        w.commit()?;
        Ok((stored, change))
    }

    /// **Write back what a fetch LEARNED about members into the CURRENT row**,
    /// in one transaction: a member that is still in the row under the same
    /// remote gets its sync fields; nothing else of the row is touched, no
    /// member is added and none is dropped. Returns `false` when the
    /// workspace is gone.
    ///
    /// NOT [`upsert`](Self::upsert): a fetch reads the row, talks to N remotes
    /// — for most of a minute on a real workspace — and then writes. Writing
    /// back the row it READ undoes whatever was registered meanwhile; a member
    /// added to a workspace was silently gone again before it was ever cloned
    /// (seen on a server: `register` said 18 members, `git/` kept 17).
    pub fn merge_member_states(&self, name: &str, fetched: &[MemberState]) -> Result<bool> {
        let w = self.db.begin_write()?;
        let existed = {
            let mut t = w.open_table(TABLE)?;
            let cur = t.get(name)?.map(|v| v.value().to_vec());
            match cur {
                Some(bytes) => {
                    let mut ws: Workspace = serde_json::from_slice(&bytes).context("decode workspace record")?;
                    for m in &mut ws.members {
                        if let Some(f) = fetched.iter().find(|f| f.name == m.name && f.remote == m.remote) {
                            let git_ref = m.git_ref.clone();
                            *m = f.clone();
                            m.git_ref = git_ref;
                        }
                    }
                    ws.updated_at = chrono::Utc::now().to_rfc3339();
                    let out = serde_json::to_vec(&ws).context("encode workspace record")?;
                    t.insert(name, out.as_slice())?;
                    true
                }
                None => false,
            }
        };
        w.commit()?;
        Ok(existed)
    }

    pub fn get(&self, name: &str) -> Result<Option<Workspace>> {
        let r = self.db.begin_read()?;
        let t = r.open_table(TABLE)?;
        match t.get(name)? {
            Some(v) => Ok(Some(
                serde_json::from_slice(v.value()).context("decode workspace record")?,
            )),
            None => Ok(None),
        }
    }

    /// All workspaces, in key (name) order.
    pub fn list(&self) -> Result<Vec<Workspace>> {
        let r = self.db.begin_read()?;
        let t = r.open_table(TABLE)?;
        let mut out = Vec::new();
        for row in t.iter()? {
            let (_k, v) = row?;
            out.push(serde_json::from_slice(v.value()).context("decode workspace record")?);
        }
        Ok(out)
    }

    /// Atomically set a workspace's `current_snapshot` (and bump `updated_at`)
    /// inside a SINGLE redb write transaction — read the row, mutate only those
    /// two fields, write it back, all under the one writer lock. This closes the
    /// republish read-modify-write window where a concurrent `fetch_workspace`
    /// member-SHA write could be clobbered by a stale read-then-upsert in
    /// `republish_served`. Returns whether a row existed to update.
    pub fn set_snapshot(&self, name: &str, snapshot: &str) -> Result<bool> {
        let w = self.db.begin_write()?;
        let existed = {
            let mut t = w.open_table(TABLE)?;
            let cur = t.get(name)?.map(|v| v.value().to_vec());
            match cur {
                Some(bytes) => {
                    let mut ws: Workspace =
                        serde_json::from_slice(&bytes).context("decode workspace record")?;
                    ws.current_snapshot = snapshot.to_string();
                    ws.updated_at = chrono::Utc::now().to_rfc3339();
                    let out = serde_json::to_vec(&ws).context("encode workspace record")?;
                    t.insert(name, out.as_slice())?;
                    true
                }
                None => false,
            }
        };
        w.commit()?;
        Ok(existed)
    }

    /// Remove a workspace; returns whether the registry row existed.
    ///
    /// This is the **shared core** both faces land on: the CLI `nornir workspace rm`
    /// (locally, or via the `Workspaces.Remove` RPC) and the viz "🗑 Kill workspace"
    /// confirm (always via the RPC) both call here, so the purge semantics are fixed
    /// once, not per-face.
    ///
    /// - `purge == false` (default, back-compat): drop ONLY the registry row. The
    ///   workspace's `builds/` warehouse stays on disk.
    /// - `purge == true`: after dropping the row, also `remove_dir_all` the
    ///   workspace's [`builds_dir`](Self::builds_dir) (`<root>/<name>/builds/` —
    ///   warehouse + `catalog.redb` + `jobs.redb`), so the workspace is fully gone.
    ///   The on-disk wipe is **independent of whether a registry row existed**: an
    ///   earlier non-purge `rm` (or viz Kill without "delete data") leaves an orphan
    ///   `builds/` behind with no row, and a later `rm <name> --purge` must finish
    ///   that cleanup rather than bail. A missing builds dir is a no-op.
    ///
    /// Returns `true` if **anything was removed** — either the registry row OR (under
    /// `purge`) an orphaned `builds/` dir — so the caller reports success instead of
    /// "no such workspace" when it cleaned up disk-only leftovers.
    pub fn remove(&self, name: &str, purge: bool) -> Result<bool> {
        // Validate BEFORE touching the FS — the purge runs even when no row
        // matches, so a malicious/garbage name must be rejected up front.
        validate_ws_name(name)?;
        let w = self.db.begin_write()?;
        let existed = {
            let mut t = w.open_table(TABLE)?;
            let removed = t.remove(name)?.is_some();
            removed
        };
        w.commit()?;
        let mut purged = false;
        if purge {
            let builds = self.builds_dir(name);
            if builds.exists() {
                // Canonicalize and assert the resolved dir is still under the
                // registry root before the irreversible wipe — a symlink or a
                // name that slipped validation must never let remove_dir_all
                // escape `<root>/`.
                let canon_builds = std::fs::canonicalize(&builds)
                    .with_context(|| format!("canonicalize builds dir {}", builds.display()))?;
                let canon_root = std::fs::canonicalize(&self.root).with_context(|| {
                    format!("canonicalize registry root {}", self.root.display())
                })?;
                if !canon_builds.starts_with(&canon_root) {
                    bail!(
                        "refusing to purge {}: resolved outside registry root {}",
                        canon_builds.display(),
                        canon_root.display()
                    );
                }
                std::fs::remove_dir_all(&canon_builds)
                    .with_context(|| format!("purge builds dir {}", canon_builds.display()))?;
                purged = true;
            }
        }
        Ok(existed || purged)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn rec(name: &str, mode: Mode) -> Workspace {
        Workspace {
            name: name.into(),
            mode,
            descriptor: "/tmp/ws/nornir-workspace.toml".into(),
            poll: "60s".into(),
            current_snapshot: String::new(),
            members: vec![MemberState {
                name: "holger".into(),
                remote: "ssh://git@git.gunnar.rs:2222/nordisk/holger.git".into(),
                git_ref: "main".into(),
                ..Default::default()
            }],
            descriptor_content: String::new(),
            created_at: "2026-06-08T00:00:00Z".into(),
            updated_at: "2026-06-08T00:00:00Z".into(),
        }
    }

    fn member(name: &str, remote: &str) -> MemberState {
        MemberState { name: name.into(), remote: remote.into(), ..Default::default() }
    }

    /// **Registering a workspace again adds what is new and keeps what it
    /// knew**: the members that stay keep their last seen commit and sync
    /// time, the row keeps its snapshot, and the change is said — added,
    /// removed.
    ///
    /// RED WHEN REVERTED: write the new row over the old one (`upsert`) and
    /// `holger` has forgotten its commit, the snapshot is empty.
    #[test]
    fn registering_again_keeps_what_was_known_and_says_what_changed() {
        let tmp = tempfile::tempdir().unwrap();
        let reg = Registry::open(tmp.path()).unwrap();
        let mut first = rec("nordisk", Mode::Monitored);
        first.members.push(member("old-thing", "git@x:old-thing"));
        let (_, change) = reg.register(&first).unwrap();
        assert!(change.first && change.is_empty() && change.line().is_empty(), "a first register adds no member to anything");
        // the server learns things about it
        let mut known = reg.get("nordisk").unwrap().unwrap();
        known.members[0].last_seen_sha = "0b07d30f".into();
        known.members[0].last_synced = "2026-10-13T20:00:00Z".into();
        known.members[0].sync_state = "ok".into();
        reg.merge_member_states("nordisk", &known.members).unwrap();
        reg.set_snapshot("nordisk", "holger@7").unwrap();

        // the descriptor gains a member and loses one
        let mut again = rec("nordisk", Mode::Monitored);
        again.members.push(member("openzl-sys-rs", "ssh://git@x/openzl-sys-rs.git"));
        let (stored, change) = reg.register(&again).unwrap();
        assert_eq!((change.first, change.added.clone(), change.removed.clone()), (false, vec!["openzl-sys-rs".to_string()], vec!["old-thing".to_string()]));
        assert!(change.line().starts_with("added openzl-sys-rs; removed old-thing (its clone and its rows"), "{}", change.line());
        let holger = &stored.members[0];
        assert_eq!((holger.last_seen_sha.as_str(), holger.sync_state.as_str(), stored.current_snapshot.as_str()), ("0b07d30f", "ok", "holger@7"));
        assert_eq!(stored.members[1].last_seen_sha, "", "the new member is new");
        assert_eq!(reg.get("nordisk").unwrap().unwrap().members.len(), 2);
        // the same name from ANOTHER remote is another clone: new
        let mut moved = rec("nordisk", Mode::Monitored);
        moved.members[0].remote = "git@elsewhere:holger".into();
        let (stored, change) = reg.register(&moved).unwrap();
        assert_eq!((change.added, stored.members[0].last_seen_sha.as_str()), (vec!["holger".to_string()], ""));
    }

    /// **A fetch does not undo a register that landed while it ran.** The
    /// fetch read the row with one member, talked to the remote, and writes
    /// back what it learned: into the row as it is NOW, which has two.
    ///
    /// RED WHEN REVERTED: `upsert` the row the fetch read and the second
    /// member is gone again — it was registered and never cloned.
    #[test]
    fn what_a_fetch_learned_is_merged_into_the_row_as_it_is_now() {
        let tmp = tempfile::tempdir().unwrap();
        let reg = Registry::open(tmp.path()).unwrap();
        reg.register(&rec("nordisk", Mode::Monitored)).unwrap();
        // a fetch starts: this is what it read
        let mut read = reg.get("nordisk").unwrap().unwrap();
        // … meanwhile the workspace is registered again, with one more member
        let mut again = rec("nordisk", Mode::Monitored);
        again.poll = "30s".into();
        again.members.push(member("openzl-sys-rs", "ssh://git@x/openzl-sys-rs.git"));
        reg.register(&again).unwrap();
        // … and the fetch ends
        read.members[0].last_seen_sha = "9c1e22aa".into();
        read.members[0].sync_state = "ok".into();
        assert!(reg.merge_member_states("nordisk", &read.members).unwrap());
        let now = reg.get("nordisk").unwrap().unwrap();
        assert_eq!(now.members.iter().map(|m| (m.name.as_str(), m.last_seen_sha.as_str())).collect::<Vec<_>>(), vec![("holger", "9c1e22aa"), ("openzl-sys-rs", "")]);
        assert_eq!(now.poll, "30s", "nothing else of the row is the fetch's to write");
        // a member the row no longer has is not brought back, a workspace that is gone is not created
        assert!(reg.merge_member_states("nordisk", &[member("old-thing", "git@x:old-thing")]).unwrap());
        assert_eq!(reg.get("nordisk").unwrap().unwrap().members.len(), 2);
        assert!(!reg.merge_member_states("gone", &read.members).unwrap());
    }

    /// INJECT-ASSERT: a FAT descriptor's `path =` members must seed a member whose
    /// `remote` is the resolved local checkout — the monitor SKIPS any member with
    /// an empty remote, so an empty remote here means the whole workspace silently
    /// materializes 0 checkouts and the build dies with "0 of N member(s) have a
    /// `.git` clone" (which then misblames the deploy key). Asserts the resolved
    /// VALUE, not merely that a member exists.
    ///
    /// RED before the `RepoSpec::source` routing: `members_from` read only
    /// `spec.git`, so both members below came back with `remote: ""`.
    #[test]
    fn fat_path_members_seed_the_local_checkout_as_their_remote() {
        // A mixed descriptor: one FAT `path=` member, one `git=` member.
        let content = r#"
[workspace]
name = "mixed"

[repos.localmember]
path = "/home/alice/git/dwarves"

[repos.remotemember]
git = "ssh://git@git.gunnar.rs:2222/nordisk/holger.git"
branch = "main"
"#;
        let ws = Workspace::from_content(
            "mixed".into(),
            "/tmp/ws".into(),
            content.into(),
            Mode::Monitored,
            "60s".into(),
            None,
        );
        assert_eq!(ws.members.len(), 2, "both members must be seeded");

        let local = ws
            .members
            .iter()
            .find(|m| m.name == "localmember")
            .expect("the path= member must be present");
        assert_eq!(
            local.remote, "/home/alice/git/dwarves",
            "a FAT `path=` member must carry its resolved checkout as the fetch \
             source; an empty remote is silently skipped by the monitor"
        );

        let remote = ws
            .members
            .iter()
            .find(|m| m.name == "remotemember")
            .expect("the git= member must be present");
        assert_eq!(
            remote.remote, "ssh://git@git.gunnar.rs:2222/nordisk/holger.git",
            "the git= path must be unchanged by the source() routing"
        );
        assert_eq!(remote.git_ref, "main", "branch must still be carried");
    }

    /// The `path` + `git` PRECEDENCE, and the unresolvable case.
    ///
    /// * an ABSOLUTE `path=` alongside a `git=` fallback must fetch from the LOCAL
    ///   path — that is the whole point of a FAT descriptor (codeberg-independent,
    ///   no deploy key). Reading `spec.git` first inverted this and went to the
    ///   network for a member that was sitting on local disk. This arm is what makes
    ///   the test non-blind: under the pre-fix logic it returns the git URL.
    /// * a RELATIVE `path=` with no descriptor base dir (thin mode: content shipped
    ///   without a file, so `descriptor_dir` is empty) is UNRESOLVABLE — it must fall
    ///   back to the declared git remote, and must never fabricate a bogus path.
    #[test]
    fn unresolvable_relative_path_member_falls_back_to_git_never_fabricates() {
        let content = r#"
[workspace]
name = "rel"

[repos.fatwithfallback]
path = "/home/alice/git/skade"
git = "ssh://git@git.gunnar.rs:2222/nordisk/skade.git"

[repos.bothdeclared]
path = "../sibling"
git = "ssh://git@git.gunnar.rs:2222/nordisk/sibling.git"

[repos.pathonly]
path = "../orphan"
"#;
        let ws = Workspace::from_content(
            "rel".into(),
            "/tmp/ws".into(),
            content.into(),
            Mode::Monitored,
            String::new(),
            None,
        );

        // PRECEDENCE: local path WINS over the git fallback when it resolves.
        let fat = ws
            .members
            .iter()
            .find(|m| m.name == "fatwithfallback")
            .unwrap();
        assert_eq!(
            fat.remote, "/home/alice/git/skade",
            "an absolute `path=` must win over its `git=` fallback — a FAT member \
             must not be fetched over the network"
        );

        let both = ws
            .members
            .iter()
            .find(|m| m.name == "bothdeclared")
            .unwrap();
        assert_eq!(
            both.remote, "ssh://git@git.gunnar.rs:2222/nordisk/sibling.git",
            "an unresolvable relative path must defer to its git fallback"
        );

        let orphan = ws.members.iter().find(|m| m.name == "pathonly").unwrap();
        assert!(
            orphan.remote.is_empty(),
            "no base dir and no git fallback must leave the remote EMPTY, not a \
             fabricated relative path — got {:?}",
            orphan.remote
        );
    }

    #[test]
    fn roundtrip_upsert_get_list_remove() {
        let dir = std::env::temp_dir().join(format!("nornir-reg-{}", std::process::id()));
        let reg = Registry::open(&dir).unwrap();

        assert!(reg.get("a").unwrap().is_none());
        reg.upsert(&rec("a", Mode::Monitored)).unwrap();
        reg.upsert(&rec("b", Mode::Pushed)).unwrap();

        let a = reg.get("a").unwrap().unwrap();
        assert_eq!(a.mode, Mode::Monitored);
        assert_eq!(a.members[0].remote, "ssh://git@git.gunnar.rs:2222/nordisk/holger.git");

        let all = reg.list().unwrap();
        assert_eq!(all.len(), 2);
        assert_eq!(all[0].name, "a"); // key order

        assert!(reg.remove("a", false).unwrap());
        assert!(!reg.remove("a", false).unwrap());
        assert_eq!(reg.list().unwrap().len(), 1);

        std::fs::remove_dir_all(&dir).ok();
    }

    /// INJECT-ASSERT: the shared purge core. `remove(.., true)` deletes the
    /// workspace's `<root>/<name>/builds/` dir; `remove(.., false)` leaves it (the
    /// back-compat registry-only path). Resolution is off the registry root.
    #[test]
    fn remove_purge_deletes_builds_dir_non_purge_leaves_it() {
        let dir = std::env::temp_dir().join(format!("nornir-purge-{}", std::process::id()));
        std::fs::remove_dir_all(&dir).ok();
        let reg = Registry::open(&dir).unwrap();

        // Two workspaces, each with an on-disk builds/ tree holding warehouse files.
        for ws in ["keep", "nuke"] {
            reg.upsert(&rec(ws, Mode::Pushed)).unwrap();
            let builds = reg.builds_dir(ws);
            std::fs::create_dir_all(builds.join("warehouse")).unwrap();
            std::fs::write(builds.join("catalog.redb"), b"x").unwrap();
            std::fs::write(builds.join("jobs.redb"), b"y").unwrap();
            assert!(builds.exists());
        }

        // Non-purge: row gone, builds/ stays.
        assert!(reg.remove("keep", false).unwrap());
        assert!(reg.get("keep").unwrap().is_none());
        assert!(
            reg.builds_dir("keep").exists(),
            "non-purge must leave builds/"
        );

        // Purge: row gone AND builds/ removed.
        assert!(reg.remove("nuke", true).unwrap());
        assert!(reg.get("nuke").unwrap().is_none());
        assert!(
            !reg.builds_dir("nuke").exists(),
            "purge must remove builds/"
        );

        // Purge of a missing builds dir is a no-op (row already gone ⇒ false).
        assert!(!reg.remove("nuke", true).unwrap());

        // Orphaned builds/ with NO registry row (an earlier non-purge `rm` left it):
        // `rm --purge` must still wipe the dir AND report success (true), not bail.
        let orphan = reg.builds_dir("orphan");
        std::fs::create_dir_all(orphan.join("warehouse")).unwrap();
        std::fs::write(orphan.join("catalog.redb"), b"x").unwrap();
        assert!(
            reg.get("orphan").unwrap().is_none(),
            "no registry row for orphan"
        );
        assert!(
            reg.remove("orphan", true).unwrap(),
            "purge of orphaned builds/ (no row) must report success"
        );
        assert!(
            !reg.builds_dir("orphan").exists(),
            "orphan builds/ must be wiped"
        );
        // Non-purge on a row-less name with no builds/ is still a no-op (false).
        assert!(!reg.remove("orphan", false).unwrap());

        std::fs::remove_dir_all(&dir).ok();
    }

    /// Bug #4: set_snapshot updates only current_snapshot (+ updated_at) in one
    /// txn and preserves every other field (e.g. a concurrently-written member
    /// SHA), and is a no-op false when the row is absent.
    #[test]
    fn set_snapshot_is_atomic_field_update() {
        let dir = std::env::temp_dir().join(format!("nornir-reg-snap-{}", std::process::id()));
        std::fs::remove_dir_all(&dir).ok();
        let reg = Registry::open(&dir).unwrap();

        assert!(
            !reg.set_snapshot("missing", "s1").unwrap(),
            "no row → false"
        );

        reg.upsert(&rec("w", Mode::Monitored)).unwrap();
        assert!(reg.set_snapshot("w", "snap-123").unwrap());
        let got = reg.get("w").unwrap().unwrap();
        assert_eq!(got.current_snapshot, "snap-123");
        // Untouched fields survive the targeted update.
        assert_eq!(got.members[0].remote, "ssh://git@git.gunnar.rs:2222/nordisk/holger.git");
        assert_eq!(got.mode, Mode::Monitored);

        std::fs::remove_dir_all(&dir).ok();
    }

    /// Bug #2: an unsafe workspace name must be rejected before the purge path
    /// can `remove_dir_all` anything — empty, separators, `..`, `.`, absolute.
    #[test]
    fn validate_ws_name_rejects_path_escapes() {
        assert!(validate_ws_name("nordisk").is_ok());
        assert!(validate_ws_name("a-b_c.123").is_ok());
        for bad in [
            "",
            ".",
            "..",
            "/",
            "/etc",
            "a/b",
            "..\\..",
            "../sibling",
            "\\",
            ".",
        ] {
            assert!(validate_ws_name(bad).is_err(), "must reject {bad:?}");
        }
        // And `remove` itself refuses an escaping name even with purge=true.
        let dir = std::env::temp_dir().join(format!("nornir-reg-bad-{}", std::process::id()));
        std::fs::remove_dir_all(&dir).ok();
        let reg = Registry::open(&dir).unwrap();
        assert!(reg.remove("../escape", true).is_err());
        std::fs::remove_dir_all(&dir).ok();
    }
}

/// One row of the `workspace ls` roster — the projection BOTH the fat (registry
/// [`Workspace`]) and thin (proto) faces map into, so they render identically
/// (CLI⟺UI parity, the Nornir-tab roster's data). AUT9.
///
/// This is a CORE (ungated) type — `registry` compiles in the default CLI build,
/// which does NOT pull the optional `nornir-wire` leaf. So the wire-crossing twin
/// is a **mirror** in `nornir_wire::view::rows::RosterRow` (the wire-stable shape a
/// thin viz client decodes without a `nornir` edge), and the boundary conversion
/// [`From`] lives below, gated to the feature sets that carry the wire leaf. The
/// two structs are field-identical, so a JSON produced from either decodes as the
/// other (the byte-compatible wire contract).
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct RosterRow {
    pub name: String,
    pub mode: String,
    pub members: usize,
    pub last_synced: String,
    /// PO1 — the rolled-up POPULATE verdict for this workspace (latest clone/fetch
    /// outcome per member → a workspace state). `"green"` (all members' latest ok),
    /// `"red"` (≥1 member's latest is an error), `"stale"` (never populated — no
    /// clone_events), or `""` when the populate roll-up wasn't requested
    /// (`workspace ls` without `--status`). The roster's red/green badge.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub populate: String,
    /// The first failing member (when `populate == "red"`), else empty.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub failing_member: String,
    /// That member's latest error detail (when `populate == "red"`), else empty.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub last_error: String,
}

/// Boundary conversion into the `nornir-wire` mirror (`RosterRow` → the wire-stable
/// [`nornir_wire::view::rows::RosterRow`]). The one place that maps the core type
/// onto the wire shape, so a server producer can emit the mirror and the
/// read-client deserialize it without a `nornir` edge. Gated to the feature sets
/// that pull the wire leaf.
#[cfg(any(feature = "server", feature = "viz", feature = "mcp"))]
impl From<RosterRow> for nornir_wire::view::rows::RosterRow {
    fn from(r: RosterRow) -> Self {
        Self {
            name: r.name,
            mode: r.mode,
            members: r.members,
            last_synced: r.last_synced,
            populate: r.populate,
            failing_member: r.failing_member,
            last_error: r.last_error,
        }
    }
}

/// The reverse boundary conversion (wire mirror → core `RosterRow`), so a client
/// that deserialized the wire mirror can hand the core type back to code that
/// still wants it (the viz pane, until it moves to the wire type in Step 4).
#[cfg(any(feature = "server", feature = "viz", feature = "mcp"))]
impl From<nornir_wire::view::rows::RosterRow> for RosterRow {
    fn from(r: nornir_wire::view::rows::RosterRow) -> Self {
        Self {
            name: r.name,
            mode: r.mode,
            members: r.members,
            last_synced: r.last_synced,
            populate: r.populate,
            failing_member: r.failing_member,
            last_error: r.last_error,
        }
    }
}

/// `workspace ls` as a uniform [`crate::cli_outcome::CommandOutcome`] — the CLI twin
/// of the Nornir-tab workspace roster. `rows` are already projected from the
/// canonical source (fat: `Registry::list`; thin: `Workspaces.List` RPC) — this only
/// shapes + renders. `ok ⟺ ≥1 workspace` (an empty registry is a valid resting state
/// for a user, but for the autopilot a seeded-then-empty roster is RED).
pub fn roster_outcome(rows: &[RosterRow]) -> crate::cli_outcome::CommandOutcome {
    use crate::cli_outcome::CommandOutcome;
    if rows.is_empty() {
        return CommandOutcome::fail(
            "workspace ls",
            "no workspaces registered — `nornir workspace register …`",
        );
    }
    // PO1: show the POPULATE column only when at least one row carries a verdict
    // (i.e. `--status` was passed / the viz roster requested the roll-up).
    let has_populate = rows.iter().any(|r| !r.populate.is_empty());
    let mut human = if has_populate {
        format!(
            "{:<20} {:<10} {:>7}  {:<22} {:<8} {}",
            "NAME", "MODE", "MEMBERS", "LAST SYNCED", "POPULATE", "LAST ERROR"
        )
    } else {
        format!(
            "{:<20} {:<10} {:>7}  {}",
            "NAME", "MODE", "MEMBERS", "LAST SYNCED"
        )
    };
    for r in rows {
        if has_populate {
            let mark = match r.populate.as_str() {
                "green" => "✓ ok",
                "red" => "✗ failed",
                "stale" => "· stale",
                _ => "—",
            };
            let err = if r.failing_member.is_empty() {
                String::new()
            } else {
                format!("{}: {}", r.failing_member, r.last_error)
            };
            human.push_str(&format!(
                "\n{:<20} {:<10} {:>7}  {:<22} {:<8} {}",
                r.name, r.mode, r.members, r.last_synced, mark, err
            ));
        } else {
            human.push_str(&format!(
                "\n{:<20} {:<10} {:>7}  {}",
                r.name, r.mode, r.members, r.last_synced
            ));
        }
    }
    CommandOutcome::ok("workspace ls", serde_json::json!(rows), human)
}

#[cfg(test)]
mod roster_outcome_tests {
    use super::*;

    #[test]
    fn empty_registry_roster_is_red() {
        let o = roster_outcome(&[]);
        assert_eq!(o.command, "workspace ls");
        assert!(!o.is_sannr(), "an empty roster is RED for the autopilot");
    }

    #[test]
    fn populated_roster_is_sannr_with_rows() {
        let rows = vec![
            RosterRow {
                name: "nordisk".into(),
                mode: "monitored".into(),
                members: 8,
                last_synced: "2026-06-23".into(),
                ..Default::default()
            },
            RosterRow {
                name: "holger".into(),
                mode: "local".into(),
                members: 1,
                last_synced: "never".into(),
                ..Default::default()
            },
        ];
        let o = roster_outcome(&rows);
        assert!(o.is_sannr(), "a populated roster is a true (sannr) outcome");
        let arr = o.data.as_array().unwrap();
        assert_eq!(arr.len(), 2);
        assert_eq!(arr[0]["name"], serde_json::json!("nordisk"));
        assert_eq!(arr[0]["members"], serde_json::json!(8));
    }

    /// PO1 (inject-assert): a roster with a RED workspace (a failing member) and a
    /// GREEN workspace renders the verdicts + names the failing member in the
    /// human view, and carries them in the JSON data. The CLI twin of the
    /// Nornir-tab roster's red/green badge.
    #[test]
    fn roster_with_populate_verdicts_shows_red_and_failing_member() {
        let rows = vec![
            RosterRow {
                name: "nordisk".into(),
                mode: "monitored".into(),
                members: 8,
                last_synced: "2026-06-23".into(),
                populate: "red".into(),
                failing_member: "korp".into(),
                last_error: "Couldn't obtain Username".into(),
            },
            RosterRow {
                name: "holger".into(),
                mode: "monitored".into(),
                members: 2,
                last_synced: "2026-06-23".into(),
                populate: "green".into(),
                ..Default::default()
            },
        ];
        let o = roster_outcome(&rows);
        assert!(o.is_sannr());
        // JSON carries the verdict + failing member for the failed workspace.
        let arr = o.data.as_array().unwrap();
        let nordisk = arr.iter().find(|r| r["name"] == "nordisk").unwrap();
        assert_eq!(nordisk["populate"], "red");
        assert_eq!(nordisk["failing_member"], "korp");
        assert!(
            nordisk["last_error"]
                .as_str()
                .unwrap()
                .contains("Couldn't obtain Username")
        );
        let holger = arr.iter().find(|r| r["name"] == "holger").unwrap();
        assert_eq!(holger["populate"], "green");
        // A green workspace omits the failing-member/error keys (skip_serializing_if).
        assert!(holger.get("failing_member").is_none());
        // The human render shows the POPULATE column + names the failing member.
        assert!(
            o.human.contains("POPULATE"),
            "populate column shown: {}",
            o.human
        );
        assert!(o.human.contains("✗ failed"));
        assert!(o.human.contains("korp: Couldn't obtain Username"));
    }
}

#[cfg(test)]
mod every_registered_workspace_is_served_tests {
    //! REGRESSION (2026-08-04): the live server printed
    //! `serving 0 workspace(s) (empty registry — register one with …)` while
    //! `nornir workspace ls --server` returned `nordisk  pushed  19` and the
    //! server had 19 real member clones on disk. Two readers of ONE registry,
    //! disagreeing inside one process.
    //!
    //! Cause: the boot loop gated *serving* on `ws.mode == Mode::Monitored`, and
    //! `nordisk` was registered `pushed` (the CLI's default when `--monitored` is
    //! not passed). Never served ⇒ `republish_served` bailed with
    //! `not served; skipping republish` ⇒ `current_snapshot` stayed empty ⇒
    //! `builds/` stayed empty ⇒ every Data/Lenses/Mímir read was refused with
    //! `workspace `nordisk` is not served (served: [])`.
    //!
    //! Both halves are pinned here, against the SHARED writers the boot loop
    //! itself calls ([`Mode::is_served`], [`serving_line`]) — not a re-implemented
    //! copy of the loop, which is what let the previous `empty_registry_tests`
    //! walk stay green through the whole bug.
    use super::*;

    fn tmp_root(tag: &str) -> PathBuf {
        std::env::temp_dir().join(format!(
            "nornir-serve-all-{tag}-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ))
    }

    /// Mode gates FETCHING, never READING: every mode gets a served handle.
    #[test]
    fn pushed_and_external_rows_are_served_not_just_monitored() {
        assert!(Mode::Monitored.is_served(), "monitored is served");
        assert!(
            Mode::Pushed.is_served(),
            "a PUSHED workspace is one the server COMPUTES for — if it is not \
             served it can never be read, and republish_served bails before \
             writing the result. This is the nordisk bug."
        );
        assert!(
            Mode::External.is_served(),
            "an EXTERNAL workspace's `builds/` is explicitly server-owned — that \
             warehouse is exactly the thing being served"
        );
    }

    /// The startup line must never claim "empty registry" over a non-empty one,
    /// and must still claim it when the registry really IS empty (so the fix
    /// cannot degenerate into deleting the message).
    #[test]
    fn the_startup_line_never_misdescribes_a_non_empty_registry() {
        // Genuinely empty ⇒ the onboarding message is correct and must survive.
        let empty = serving_line(0, 0, &[]);
        assert!(
            empty.contains("empty registry"),
            "a truly empty registry still says so: {empty}"
        );

        // The live shape: 1 row registered, 0 served because the open failed.
        let live = serving_line(
            1,
            0,
            &[("nordisk".to_string(), "open warehouse failed".to_string())],
        );
        assert!(
            !live.contains("empty registry"),
            "THE LIE: 1 registered row must never be reported as an empty \
             registry — that is what sent two people down the registration path \
             for an already-registered workspace. Got: {live}"
        );
        assert!(
            live.contains("nordisk") && live.contains("open warehouse failed"),
            "an unserved row must NAME itself and its reason: {live}"
        );
        assert!(live.contains('1'), "the registered count is stated: {live}");

        // All rows served ⇒ the plain count, and it must agree with `ls`.
        let ok = serving_line(1, 1, &[]);
        assert_eq!(ok, "nornir-server: serving 1 workspace(s)");
    }

    /// End-to-end over a REAL redb registry: register exactly the row shape the
    /// live server holds (mode `pushed`, members resolved) and assert the boot
    /// selection serves it — so `serving N` and `workspace ls` return the same N.
    #[test]
    fn a_pushed_row_in_a_real_registry_is_selected_for_serving_and_the_counts_agree() {
        let root = tmp_root("redb");
        let reg = Registry::open(&root).expect("open registry");

        let content = "[workspace]\nname = \"nordisk\"\n\n\
                       [repos.skade]\ngit = \"https://x/skade.git\"\n\
                       [repos.znippy]\ngit = \"https://x/znippy.git\"\n";
        let ws = Workspace::from_content(
            "nordisk".into(),
            "/home/alice/git/workspace_nordisk/nornir-workspace.toml".into(),
            content.into(),
            // The live row's mode — the CLI default when `--monitored` is omitted.
            Mode::Pushed,
            String::new(),
            None,
        );
        reg.upsert(&ws).expect("upsert pushed row");

        // READER A — what `nornir workspace ls --server` counts.
        let listed = reg.list().expect("list");
        assert_eq!(listed.len(), 1, "the registry HOLDS the row");
        assert_eq!(listed[0].mode, Mode::Pushed, "and it is `pushed`, as live");
        assert_eq!(listed[0].members.len(), 2, "with its members resolved");

        // READER B — the boot loop's serve selection, through the SHARED predicate.
        let to_serve: Vec<&Workspace> = listed.iter().filter(|w| w.mode.is_served()).collect();

        assert_eq!(
            to_serve.len(),
            listed.len(),
            "THE DISAGREEMENT: `ls` counted {} row(s) and the serve selection \
             counted {}. A registered, member-resolved workspace that no reader \
             can touch is the whole defect.",
            listed.len(),
            to_serve.len()
        );
        assert_eq!(to_serve[0].name, "nordisk");

        // And the line the operator reads agrees with both.
        assert_eq!(
            serving_line(listed.len(), to_serve.len(), &[]),
            "nornir-server: serving 1 workspace(s)"
        );

        let _ = std::fs::remove_dir_all(&root);
    }
}