Skip to main content

majordomus_cli/capability/builtin/
server.rs

1//! The `server` module: the shared server of this checkout, and of every other checkout of
2//! the same git repository — what each lease says, whether the server it names answers,
3//! and whether what answers is the code on disk.
4//!
5//! The lease has always carried this — the pid, the address, when the server started, the
6//! executable it runs — and nothing served it: a person read the file with `cat`, a shell
7//! recipe parsed it with `sed`, and a client learned only what the election happened to
8//! log. Two readers had grown their own ideas of "ready" on the way (the environment's
9//! connection attempt, the health route's registry count), and neither asked the question
10//! the lease can answer: is the process behind this address current? This is that answer,
11//! on every surface the registry projects to, from the one typed reading of the file
12//! ([`crate::lease::LeaseFile`]).
13//!
14//! # One repository, every server of it
15//!
16//! A server serves a checkout; a linked worktree is a checkout of its own with a lease of
17//! its own, and until now nothing said that the two belong to one repository. The status
18//! lists every checkout git registers for the repository,
19//! reads the lease of each (through the worktree topology reader), and names the git repository they share
20//! ([`crate::repository::git_identity`]), so that a session in one worktree can see the
21//! servers — and through them the peers — of the others.
22//!
23//! # Two questions, two costs
24//!
25//! Listing every checkout costs a lease read and a probe per checkout, and a machine with
26//! forty worktrees registered pays forty of each. A caller that only wants to know about
27//! the checkout it is in says so — `checkouts: this` — and that answer reads one lease and
28//! probes one server; no other checkout is enumerated, read or probed. The default is
29//! `repository`, the whole list, because that is what every caller of this capability got
30//! before the field existed.
31//!
32//! Read on every call and never cached: the lease is written by other processes.
33//!
34//! ```
35//! use majordomus_cli::capability::builtin::server::{
36//!     module, standing_of, Checkouts, ServerStanding, ServerStatusInput,
37//! };
38//! use majordomus_cli::lease::LeaseFile;
39//! use std::time::Duration;
40//!
41//! // asking nothing asks the wide question, as it always did
42//! assert_eq!(
43//!     serde_json::from_str::<ServerStatusInput>("{}").unwrap().checkouts,
44//!     Checkouts::Repository
45//! );
46//!
47//! // the module composes one capability, and its decision is a pure function
48//! let m = module();
49//! assert_eq!(m.id.as_str(), "server");
50//! assert!(m.capabilities.iter().any(|e| e.capability.id.to_string() == "server.status"));
51//! let (standing, reason) = standing_of(&LeaseFile::Absent, Duration::ZERO, |_| false, "1.0.0");
52//! assert_eq!(standing, ServerStanding::Absent);
53//! assert!(reason.is_none());
54//! ```
55
56use std::path::{Path, PathBuf};
57use std::time::Duration;
58
59use clap::ValueEnum;
60use schemars::JsonSchema;
61use serde::{Deserialize, Serialize};
62
63use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
64use crate::capability::handler::{CapabilityError, Context};
65use crate::capability::model::{
66    CachePolicy, CliExposure, Exposure, McpExposure, McpResource, Stability,
67};
68use crate::capability::module::ModuleDescriptor;
69use crate::lease::{self, ExecutableIdentity, LeaseDocument, LeaseFile, VersionVerdict};
70use crate::repository::{self, GitIdentity, Repository};
71use crate::{capability, module};
72
73use super::get;
74
75/// The URI under which `server.status` is read as an MCP resource.
76pub const SERVER_URI: &str = "majordomus://server";
77
78/// Which checkouts a reading of the server status covers.
79///
80/// The two answers cost differently and that is the whole reason the choice exists: the
81/// repository's checkouts are read from git's registry of them and each one costs a lease
82/// read and a probe, while this checkout alone costs one of each and enumerates nothing.
83/// The word each variant serialises to is the word the query parameter, the tool input and
84/// the command-line flag all take.
85///
86/// ```
87/// use majordomus_cli::capability::builtin::server::Checkouts;
88/// assert_eq!(serde_json::to_value(Checkouts::This).unwrap(), "this");
89/// assert_eq!(serde_json::to_value(Checkouts::Repository).unwrap(), "repository");
90/// assert_eq!(Checkouts::default(), Checkouts::Repository, "the wide answer is what a caller who says nothing has always got");
91/// ```
92#[derive(
93    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ValueEnum,
94)]
95#[serde(rename_all = "snake_case")]
96pub enum Checkouts {
97    /// Every checkout git registers for the repository, the primary first — a lease read
98    /// and a probe each. The default, because it is the answer this capability gave before
99    /// there was anything to ask.
100    #[default]
101    Repository,
102    /// This checkout alone. No other checkout is enumerated, no other lease is read and no
103    /// other server is probed; `servers` holds the one entry, and it is this one.
104    This,
105}
106
107/// What to ask of `server.status`: how wide the answer is.
108///
109/// One field, because the standing of a server is not something a caller filters — it is
110/// something a caller wants for one checkout or for all of them, and the difference is
111/// forty round trips on a machine with forty worktrees.
112///
113/// ```
114/// use majordomus_cli::capability::builtin::server::{Checkouts, ServerStatusInput};
115/// // the field is optional on every surface, and its absence is the wide answer
116/// let asked_nothing: ServerStatusInput = serde_json::from_str("{}").unwrap();
117/// assert_eq!(asked_nothing.checkouts, Checkouts::Repository);
118/// let narrow: ServerStatusInput = serde_json::from_str(r#"{"checkouts":"this"}"#).unwrap();
119/// assert_eq!(narrow.checkouts, Checkouts::This);
120/// ```
121#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
122#[serde(deny_unknown_fields)]
123pub struct ServerStatusInput {
124    /// Which checkouts the answer covers. Absent means `repository`.
125    #[serde(default)]
126    pub checkouts: Checkouts,
127}
128
129impl BenchmarkCases for ServerStatusInput {
130    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
131        // both questions are timed, because the difference between them is the point of
132        // the field; both appear as examples in the OpenAPI document
133        vec![
134            NamedCase::new(
135                "repository",
136                ServerStatusInput {
137                    checkouts: Checkouts::Repository,
138                },
139            ),
140            NamedCase::new(
141                "this-checkout",
142                ServerStatusInput {
143                    checkouts: Checkouts::This,
144                },
145            ),
146        ]
147    }
148}
149
150/// Where a checkout's server stands, decided from its lease, whether the server the lease
151/// names answers for that checkout, and whether what answers is this executable's code.
152///
153/// ```
154/// use majordomus_cli::capability::builtin::server::ServerStanding;
155/// let all = [ServerStanding::Absent, ServerStanding::Starting, ServerStanding::Ready,
156///            ServerStanding::Outdated, ServerStanding::Stale];
157/// let words: Vec<&str> = all.iter().map(|s| s.as_str()).collect();
158/// assert_eq!(words, ["absent", "starting", "ready", "outdated", "stale"]);
159/// assert_eq!(serde_json::from_str::<ServerStanding>("\"stale\"").unwrap(), ServerStanding::Stale);
160/// ```
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
162#[serde(rename_all = "snake_case")]
163pub enum ServerStanding {
164    /// No lease: nothing serves this checkout.
165    Absent,
166    /// A lease without an address, young enough that its owner is still binding.
167    Starting,
168    /// The server the lease names answers for this checkout, from the code on disk, at
169    /// this executable's version.
170    Ready,
171    /// The server answers, but from older code than this executable, or from a file that
172    /// has been replaced since it started: everything it says is yesterday's.
173    Outdated,
174    /// The lease names a server that does not answer, or is not a lease at all.
175    Stale,
176}
177
178impl ServerStanding {
179    /// The word every surface prints: the same one the JSON carries, so that a shell
180    /// comparing the two never has to know which case each side chose.
181    ///
182    /// ```
183    /// use majordomus_cli::capability::builtin::server::ServerStanding;
184    /// assert_eq!(ServerStanding::Outdated.as_str(), "outdated");
185    /// assert_eq!(serde_json::to_value(ServerStanding::Outdated).unwrap(), "outdated");
186    /// ```
187    pub fn as_str(self) -> &'static str {
188        match self {
189            ServerStanding::Absent => "absent",
190            ServerStanding::Starting => "starting",
191            ServerStanding::Ready => "ready",
192            ServerStanding::Outdated => "outdated",
193            ServerStanding::Stale => "stale",
194        }
195    }
196}
197
198/// What this executable would want a server of this checkout to be: the address it would
199/// bind, the version and the executable it would serve from. What `standing` is measured
200/// against.
201///
202/// ```
203/// use majordomus_cli::capability::builtin::server::Desired;
204/// let d = Desired { host: "127.0.0.1".into(), port: 8741, version: "1.0.0".into(), executable: None };
205/// let v = serde_json::to_value(&d).unwrap();
206/// assert_eq!(v["port"], 8741);
207/// assert!(v.get("executable").is_none(), "an executable that cannot be located is not written as null");
208/// ```
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
210pub struct Desired {
211    /// The interface a server started here would bind: what `MAJORDOMUS_HTTP_HOST` names
212    /// in this process's environment, and loopback when it names nothing.
213    pub host: String,
214    /// The port it asks for first.
215    pub port: u16,
216    /// This executable's version.
217    pub version: String,
218    /// This executable, when it can be located.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub executable: Option<ExecutableIdentity>,
221}
222
223/// A lease as a reader sees it: what the server wrote about itself, without the token that
224/// makes the file the server's own.
225///
226/// ```
227/// use majordomus_cli::capability::builtin::server::LeaseView;
228/// let view = LeaseView { pid: 7, url: Some("http://127.0.0.1:8741".into()),
229///     started_at: "2026-09-10T00:00:00Z".into(), executable: None, version: Some("1.0.0".into()) };
230/// let v = serde_json::to_value(&view).unwrap();
231/// assert_eq!(v["pid"], 7);
232/// assert!(v.get("token").is_none() && v.get("root").is_none());
233/// ```
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
235pub struct LeaseView {
236    /// The server's process id. Not proof of a live server — only a probe that is answered
237    /// is — but decisions are made from it: a live pid earns a server that does not answer
238    /// the election's busy wait before its lease is taken over, and `serve ensure`'s patience
239    /// before it starts another; `serve stop` sends its `SIGTERM` to this pid.
240    pub pid: u32,
241    /// The address, once bound.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub url: Option<String>,
244    /// When the server took the lease, RFC 3339.
245    pub started_at: String,
246    /// The executable the server was started from, when it could be located.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub executable: Option<ExecutableIdentity>,
249    /// The executable's version; absent for a server too old to have written one.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub version: Option<String>,
252}
253
254impl LeaseView {
255    fn of(d: &LeaseDocument) -> Self {
256        LeaseView {
257            pid: d.pid,
258            url: d.url.clone(),
259            started_at: d.started_at.clone(),
260            executable: d.executable.clone(),
261            version: d.version.clone(),
262        }
263    }
264}
265
266/// One checkout of the repository, and the server its lease names.
267///
268/// ```
269/// use majordomus_cli::capability::builtin::server::{ServerStanding, ServerView};
270/// let view = ServerView { worktree: "/r".into(), branch: Some("master".into()), checkout_id: "c".into(),
271///     primary: true, this_checkout: true, standing: ServerStanding::Absent, reason: None, lease: None, peers: None };
272/// let v = serde_json::to_value(&view).unwrap();
273/// assert_eq!(v["standing"], "absent");
274/// assert!(v.get("lease").is_none() && v.get("peers").is_none(), "what there is not is not written");
275/// ```
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
277pub struct ServerView {
278    /// The checkout, absolute and canonical.
279    pub worktree: PathBuf,
280    /// The branch checked out there, when git names one.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub branch: Option<String>,
283    /// The checkout's identity: what its server answers as `repository_id`.
284    pub checkout_id: String,
285    /// Whether this is the primary checkout rather than a linked worktree.
286    pub primary: bool,
287    /// Whether this is the checkout the answering process serves.
288    pub this_checkout: bool,
289    /// Where its server stands.
290    pub standing: ServerStanding,
291    /// Why, when the standing is not `ready`: what the lease held, or what answered.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub reason: Option<String>,
294    /// The lease, when the file holds one.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub lease: Option<LeaseView>,
297    /// How many peers the server reports attached, when it answers.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub peers: Option<usize>,
300}
301
302/// The shared server of this checkout and of every other checkout of the repository.
303///
304/// ```
305/// use majordomus_cli::capability::builtin::server::{Desired, ServerStanding, ServerStatus};
306/// let status = ServerStatus { checkout_id: "c".into(), git: None,
307///     desired: Desired { host: "127.0.0.1".into(), port: 8741, version: "1.0.0".into(), executable: None },
308///     this_process: None, standing: ServerStanding::Absent, servers: Vec::new(),
309///     connections: None };
310/// let text = serde_json::to_string(&status).unwrap();
311/// assert_eq!(serde_json::from_str::<ServerStatus>(&text).unwrap(), status);
312/// ```
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
314pub struct ServerStatus {
315    /// This checkout's identity: what this process answers as `repository_id`.
316    pub checkout_id: String,
317    /// The git repository this checkout belongs to; absent where git cannot be asked.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub git: Option<GitIdentity>,
320    /// What this executable would serve, and what `standing` is measured against.
321    pub desired: Desired,
322    /// The lease this process holds, when it is the server; absent when the question was
323    /// answered by a process that serves nothing.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub this_process: Option<LeaseView>,
326    /// Where this checkout's server stands.
327    pub standing: ServerStanding,
328    /// Every checkout of the repository, the primary first, each with its server. One entry
329    /// — this checkout — where git cannot be asked.
330    pub servers: Vec<ServerView>,
331    /// What this process's HTTP server did to connections that held it without a request:
332    /// the read deadline it applies, the connections that deadline closed, and the requests
333    /// refused because every handler was busy. Absent when this process serves no HTTP.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub connections: Option<crate::http::deadline::ConnectionCounts>,
336}
337
338/// Decide where a server stands from what its lease file holds, how old the file is,
339/// whether the server it names answers for the checkout, and this executable's version.
340///
341/// Pure, so that every branch of the decision is a test rather than a deployment:
342///
343/// ```
344/// use majordomus_cli::capability::builtin::server::{standing_of, ServerStanding};
345/// use majordomus_cli::lease::{LeaseDocument, LeaseFile, SCHEMA};
346/// use std::time::Duration;
347///
348/// let young = Duration::ZERO;
349/// let never = |_: &str| false;
350/// let always = |_: &str| true;
351/// assert_eq!(standing_of(&LeaseFile::Absent, young, never, "1.0.0").0, ServerStanding::Absent);
352/// assert_eq!(standing_of(&LeaseFile::Empty, young, never, "1.0.0").0, ServerStanding::Starting);
353/// assert_eq!(standing_of(&LeaseFile::Corrupt("not JSON".into()), young, never, "1.0.0").0, ServerStanding::Stale);
354///
355/// let lease = |url: Option<&str>, version: Option<&str>| LeaseDocument {
356///     schema: SCHEMA.into(), pid: 1, token: "t".into(), root: "/r".into(),
357///     url: url.map(str::to_string), started_at: "2026-09-10T00:00:00Z".into(),
358///     executable: None, version: version.map(str::to_string),
359/// };
360/// // still binding, then abandoned
361/// assert_eq!(standing_of(&LeaseFile::Document(lease(None, None)), young, never, "1.0.0").0, ServerStanding::Starting);
362/// assert_eq!(standing_of(&LeaseFile::Document(lease(None, None)), Duration::from_secs(3600), never, "1.0.0").0, ServerStanding::Stale);
363/// // names a server that does not answer
364/// assert_eq!(standing_of(&LeaseFile::Document(lease(Some("http://127.0.0.1:1"), Some("1.0.0"))), young, never, "1.0.0").0, ServerStanding::Stale);
365/// // answers from this version
366/// assert_eq!(standing_of(&LeaseFile::Document(lease(Some("http://127.0.0.1:1"), Some("1.0.0"))), young, always, "1.0.0").0, ServerStanding::Ready);
367/// // answers from another version, or from before versions were written down
368/// assert_eq!(standing_of(&LeaseFile::Document(lease(Some("http://127.0.0.1:1"), Some("0.9.0"))), young, always, "1.0.0").0, ServerStanding::Outdated);
369/// assert_eq!(standing_of(&LeaseFile::Document(lease(Some("http://127.0.0.1:1"), None)), young, always, "1.0.0").0, ServerStanding::Outdated);
370/// ```
371pub fn standing_of(
372    file: &LeaseFile,
373    age: Duration,
374    answers: impl Fn(&str) -> bool,
375    version: &str,
376) -> (ServerStanding, Option<String>) {
377    let doc = match file {
378        LeaseFile::Absent => return (ServerStanding::Absent, None),
379        LeaseFile::Empty if age > lease::timings().bind_grace => {
380            return (
381                ServerStanding::Stale,
382                Some("empty lease: its owner never wrote it".into()),
383            )
384        }
385        LeaseFile::Empty => return (ServerStanding::Starting, None),
386        LeaseFile::Corrupt(reason) => {
387            return (
388                ServerStanding::Stale,
389                Some(format!("corrupt lease: {reason}")),
390            )
391        }
392        LeaseFile::Document(doc) => doc,
393    };
394    let Some(url) = doc.url.as_deref() else {
395        return if age > lease::timings().bind_grace {
396            (
397                ServerStanding::Stale,
398                Some("abandoned lease: its owner never published a URL".into()),
399            )
400        } else {
401            (ServerStanding::Starting, None)
402        };
403    };
404    if !answers(url) {
405        return (
406            ServerStanding::Stale,
407            Some(format!(
408                "the server the lease names at {url} does not answer for this checkout"
409            )),
410        );
411    }
412    if let Some(reason) = doc
413        .executable
414        .as_ref()
415        .and_then(ExecutableIdentity::replaced)
416    {
417        return (ServerStanding::Outdated, Some(reason));
418    }
419    let theirs = doc.version.as_deref().unwrap_or_default();
420    match lease::version_verdict(doc.version.as_deref(), version) {
421        VersionVerdict::Same => (ServerStanding::Ready, None),
422        VersionVerdict::Unpublished => (
423            ServerStanding::Outdated,
424            Some(format!(
425                "the server published no version, so it is older than this executable ({version})"
426            )),
427        ),
428        VersionVerdict::ServerOlder => (
429            ServerStanding::Outdated,
430            Some(format!(
431                "the server is serving version {theirs}, older than this executable ({version}); the next client or `serve ensure` of this executable replaces it"
432            )),
433        ),
434        VersionVerdict::ServerNewer => (
435            ServerStanding::Outdated,
436            Some(format!(
437                "the server is serving version {theirs}, newer than this executable ({version}); clients of this executable attach to it and say they are older"
438            )),
439        ),
440        VersionVerdict::Unordered => (
441            ServerStanding::Outdated,
442            Some(format!(
443                "the server is serving version {theirs}; this executable is {version}"
444            )),
445        ),
446    }
447}
448
449/// The repository-relative path of a checkout's local half — the directory its lease is
450/// written into — or the layer's default when no manifest can be read at that checkout.
451///
452/// A directory that is not a repository of the layer still gets an answer, because "does a
453/// server serve this checkout" is a question about a path and not about a manifest.
454///
455/// ```
456/// use majordomus_cli::capability::builtin::server::local_half;
457/// assert_eq!(local_half(std::path::Path::new("/nonexistent/checkout")), ".ai/local");
458/// ```
459pub fn local_half(root: &Path) -> String {
460    Repository::open(root)
461        .map(|r| r.local_path())
462        .unwrap_or_else(|_| ".ai/local".to_string())
463}
464
465/// Where the server of one checkout stands, with the lease the verdict was read from.
466///
467/// [`standing_of`] is the decision; this is the one reading that feeds it — the lease file
468/// of `worktree` under `local_half`, how old that file is, one probe of the address it
469/// names, against this executable's version. Every surface that asks where a server stands
470/// asks through here, so that `server.status` and the `server` check of `health.report`
471/// cannot drift into two answers.
472///
473/// Costs one small file read, and one HTTP round trip bounded by the declared probe timeout
474/// ([`crate::lease::timings`]) when the lease names an address. Nothing is cached: the
475/// file is written by other processes.
476///
477/// ```
478/// use majordomus_cli::capability::builtin::server::{local_half, standing_at, ServerStanding};
479/// let nowhere = std::path::Path::new("/nonexistent/checkout");
480/// let (standing, reason, lease) = standing_at(nowhere, &local_half(nowhere));
481/// assert_eq!(standing, ServerStanding::Absent);
482/// assert!(reason.is_none(), "there is nothing to explain: no lease, no server");
483/// assert!(lease.document().is_none());
484/// ```
485pub fn standing_at(
486    worktree: &Path,
487    local_half: &str,
488) -> (ServerStanding, Option<String>, LeaseFile) {
489    let file = lease::lease_file(worktree, local_half);
490    let read = LeaseFile::read(&file);
491    // this process's own lease is answered from memory: a probe would be a request to
492    // itself, queued behind the very request asking (lease::is_own)
493    let own = read.document().is_some_and(lease::is_own);
494    let (standing, reason) = standing_of(
495        &read,
496        lease::file_age(&file),
497        |url| own || lease::probe(url, worktree),
498        crate::VERSION,
499    );
500    (standing, reason, read)
501}
502
503/// How many peers the server at `url` reports, through the route the registry declares
504/// for `peers.list`. `None` when it does not answer or the route is not declared.
505fn peers_of(ctx: &Context, url: &str) -> Option<usize> {
506    let path = ctx
507        .registry
508        .get("peers.list")?
509        .exposure
510        .http
511        .as_ref()?
512        .path
513        .clone();
514    // the probe timeout the policy declares, as the election reads it (I2129)
515    let timeout = lease::timings().probe_timeout;
516    let reply = crate::mcp::bridge::request(url, "GET", &path, &[], None, timeout).ok()?;
517    if reply.status != 200 {
518        return None;
519    }
520    let value: serde_json::Value = serde_json::from_str(&reply.body).ok()?;
521    value["count"].as_u64().map(|n| n as usize)
522}
523
524/// The checkouts one reading covers: every checkout git registers for the repository, the
525/// primary first, or this one alone.
526///
527/// The narrow answer is the cheap one by construction rather than by filtering — git is
528/// not asked for its registry of work trees, so nothing downstream has another checkout's
529/// path to read a lease at or a server to probe. It spawns no process of its own either:
530/// `primary` comes from the one stat `git_identity` already did (a linked work tree has a
531/// `.git` file, the primary a directory) and the branch from that checkout's own HEAD, so
532/// the narrow entry says everything about this checkout that the wide list would have
533/// said, at the price of two file reads.
534pub(crate) fn checkouts_of(
535    root: &Path,
536    git: Option<&GitIdentity>,
537    which: Checkouts,
538) -> Vec<(PathBuf, Option<String>, bool)> {
539    let here = |primary: bool| vec![(root.to_path_buf(), repository::branch_at(root), primary)];
540    let Some(git) = git else {
541        // not a git repository: this checkout is the only one there is, and it is the
542        // primary of nothing
543        return here(true);
544    };
545    if which == Checkouts::This {
546        return here(!git.linked);
547    }
548    match crate::worktree::topology::read(root) {
549        Ok(records) => records
550            .into_iter()
551            .filter(|r| !r.bare)
552            .enumerate()
553            .map(|(i, r)| (r.path, r.branch, i == 0))
554            .collect(),
555        Err(_) => here(!git.linked),
556    }
557}
558
559fn server_status(ctx: &Context, input: ServerStatusInput) -> Result<ServerStatus, CapabilityError> {
560    let root = PathBuf::from(&ctx.index.repository.root);
561    let root = root.canonicalize().unwrap_or(root);
562    let local_half = local_half(&root);
563    let git = repository::git_identity(&root);
564    let linked = git.as_ref().is_some_and(|g| g.linked);
565    let checkouts = checkouts_of(&root, git.as_ref(), input.checkouts);
566    let mut servers = Vec::with_capacity(checkouts.len());
567    for (path, branch, primary) in checkouts {
568        let worktree = path.canonicalize().unwrap_or(path);
569        let (standing, reason, read) = standing_at(&worktree, &local_half);
570        let peers = match standing {
571            // this process's own board is in memory; asking it over the wire would queue
572            // behind this very request (lease::is_own)
573            ServerStanding::Ready | ServerStanding::Outdated
574                if read.document().is_some_and(lease::is_own) =>
575            {
576                Some(ctx.peers.list().iter().filter(|p| p.attached).count())
577            }
578            ServerStanding::Ready | ServerStanding::Outdated => read
579                .document()
580                .and_then(|d| d.url.as_deref())
581                .and_then(|url| peers_of(ctx, url)),
582            _ => None,
583        };
584        servers.push(ServerView {
585            checkout_id: repository::identity(&worktree),
586            primary,
587            this_checkout: worktree == root,
588            branch,
589            standing,
590            reason,
591            lease: read.document().map(LeaseView::of),
592            peers,
593            worktree,
594        });
595    }
596    let standing = servers
597        .iter()
598        .find(|s| s.this_checkout)
599        .map(|s| s.standing)
600        .unwrap_or(ServerStanding::Absent);
601    Ok(ServerStatus {
602        checkout_id: repository::identity(&root),
603        git,
604        desired: Desired {
605            host: desired_host(linked),
606            // the port this checkout asks for on every start (I2702)
607            port: crate::cli::checkout_port(&root),
608            version: crate::VERSION.into(),
609            executable: lease::executable_identity(),
610        },
611        this_process: lease::held().as_ref().map(LeaseView::of),
612        standing,
613        servers,
614        connections: crate::http::deadline::counts(),
615    })
616}
617
618/// The interface a server started from this environment would bind — the same resolution
619/// `serve` and `mcp` make for this checkout, without a flag, so that `desired` and a bind
620/// cannot disagree.
621fn desired_host(linked: bool) -> String {
622    let environment = std::env::var(crate::cli::HTTP_HOST_ENV).ok();
623    crate::cli::resolve_checkout_http_host(None, environment.as_deref(), linked).0
624}
625
626/// The module: one capability, `server.status`, composed into the application by
627/// `builtin::modules`; adding a second capability to it touches this file alone.
628///
629/// ```
630/// use majordomus_cli::capability::builtin::server::module;
631/// assert_eq!(module().capabilities.len(), 1);
632/// ```
633pub fn module() -> ModuleDescriptor {
634    module! {
635        id: "server",
636        title: "Server",
637        description: "The shared server of this checkout and of every other checkout of the same git repository: what each lease says, whether the server it names answers, whether what answers is the code on disk at this executable's version, and how many peers each one holds.",
638        stability: Stability::BehaviorallyVerified,
639        capabilities: [
640            capability! {
641                id: "server.status",
642                title: "The shared server, and every server of the repository",
643                description: "Where this checkout's server stands — absent, starting, ready, outdated or stale — measured against what this executable would serve; the lease this process holds when it is the server; and, unless `checkouts` narrows it to this one, every checkout git registers for the repository, the primary first, each with its lease, its standing and the reason, and the peers its server reports. `checkouts: this` reads one lease and probes one server and enumerates no other checkout, which is what a caller asking only about the checkout it is in should pay. Read from the lease files and the servers on every call; nothing is cached, because the leases are written by other processes.",
644                input: ServerStatusInput,
645                output: ServerStatus,
646                stability: Stability::BehaviorallyVerified,
647                exposure: Exposure {
648                    mcp: Some(McpExposure {
649                        tool: Some("majordomus_server".into()),
650                        resource: Some(McpResource { uri: SERVER_URI.into(), name: "server".into() }),
651                    }),
652                    http: get("/api/v1/server"),
653                    cli: Some(CliExposure { path: vec!["serve".into(), "status".into()] }),
654                },
655                tags: ["server", "lease", "coordination", "introspection"],
656                cache: CachePolicy::Disabled,
657                handler: server_status,
658            },
659        ]
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    /// A server whose version cannot be ordered against this executable's — a pre-release
668    /// beside its release — is outdated, and the reason names both versions without
669    /// claiming which is older.
670    #[test]
671    fn a_version_that_cannot_be_ordered_is_outdated_naming_both() {
672        let doc = LeaseDocument {
673            schema: lease::SCHEMA.into(),
674            pid: 1,
675            token: "t".into(),
676            root: "/r".into(),
677            url: Some("http://127.0.0.1:1".into()),
678            started_at: "2026-09-10T00:00:00Z".into(),
679            executable: None,
680            version: Some("1.0.0-rc.1".into()),
681        };
682        let (standing, reason) =
683            standing_of(&LeaseFile::Document(doc), Duration::ZERO, |_| true, "1.0.0");
684        assert_eq!(standing, ServerStanding::Outdated);
685        let reason = reason.expect("a reason");
686        assert_eq!(
687            reason,
688            "the server is serving version 1.0.0-rc.1; this executable is 1.0.0"
689        );
690    }
691}