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}