Skip to main content

majordomus_cli/capability/builtin/
served.rs

1//! The `served` module: whether a deployment serves the commit it was meant to, observed
2//! from outside and recorded as evidence a later reader can re-judge.
3//!
4//! Three operations, deliberately unequal:
5//!
6//! - `served.observe` reaches the network and appends a record, so it is a command of the
7//!   trusted command line only. A browser or an MCP client that could make this process
8//!   probe an arbitrary URL would have been handed a request forger.
9//! - `served.forge` asks the forge which deployments and releases it recorded, through the
10//!   forge's own tool, and keeps the answer for `deploy.observed` to read. It reaches the
11//!   network and spends the caller's credential, so it too is a command line and nothing
12//!   else.
13//! - `served.show` is a read: it asks no network, writes nothing and re-judges what was
14//!   recorded against the commit asked about, which is what makes an old observation go
15//!   stale without anyone deciding it has (see [`crate::served`]).
16//!
17//! ```
18//! use majordomus_cli::capability::builtin::served;
19//! use majordomus_cli::capability::model::CapabilityKind;
20//!
21//! let m = served::module();
22//! let observe = m.capabilities.iter().find(|c| c.capability.id.as_str() == "served.observe").unwrap();
23//! // the operation that reaches the network is on no surface a client can call
24//! assert_eq!(observe.capability.kind, CapabilityKind::Command);
25//! assert!(observe.capability.exposure.http.is_none());
26//! assert!(observe.capability.exposure.mcp.is_none());
27//!
28//! let show = m.capabilities.iter().find(|c| c.capability.id.as_str() == "served.show").unwrap();
29//! assert_eq!(show.capability.exposure.http.as_ref().unwrap().path, "/api/v1/served");
30//! ```
31
32use std::path::{Path, PathBuf};
33use std::time::SystemTime;
34
35use schemars::JsonSchema;
36use serde::{Deserialize, Serialize};
37
38use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
39use crate::capability::handler::{CapabilityError, Context};
40use crate::capability::model::{
41    BenchmarkPolicy, CapabilityKind, CliExposure, Exposure, McpExposure, McpResource, Stability,
42    WaiverReason,
43};
44use crate::capability::module::ModuleDescriptor;
45use crate::capability::CachePolicy;
46use crate::served::{self, Curl, GitAncestry, Judgement, Observation};
47use crate::{capability, module};
48
49use super::get;
50
51/// The URI under which the recorded standing of every deployment is read as a resource.
52pub const SERVED_URI: &str = "majordomus://served";
53
54/// The deployment name an observation carries when the caller names none: the public site.
55pub const DEFAULT_DEPLOYMENT: &str = "pages";
56
57/// The input of `served.observe`: which deployment, where it is, which commit is expected
58/// and how long the probe may take. Everything is optional — the defaults are this
59/// repository's own site, its `build.json` and `HEAD`.
60///
61/// ```
62/// use majordomus_cli::capability::builtin::served::ObserveInput;
63/// let input: ObserveInput = serde_json::from_str(r#"{"commit":"HEAD"}"#).unwrap();
64/// assert_eq!(input.commit.as_deref(), Some("HEAD"));
65/// assert!(serde_json::from_str::<ObserveInput>(r#"{"shell":"rm"}"#).is_err());
66/// ```
67#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
68#[serde(deny_unknown_fields)]
69pub struct ObserveInput {
70    /// The name the observation is recorded under; `pages` when absent.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub deployment: Option<String>,
73    /// The site's base URL; `base_url` of `site/config.toml` when absent.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub url: Option<String>,
76    /// The identity file under the base; `build.json` when absent.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub identity: Option<String>,
79    /// The commit expected to be served, any revision git resolves; `HEAD` when absent.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub commit: Option<String>,
82    /// The bound on the probe in seconds; ten when absent.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub timeout_seconds: Option<u64>,
85    /// Judge without recording. Recording is the default, because an observation nobody
86    /// kept is a measurement nobody can read.
87    #[serde(default)]
88    pub dry_run: bool,
89}
90
91/// What `served.observe` did: the observation, where it was recorded, and the exit status
92/// the command line reports the verdict with.
93///
94/// ```
95/// use majordomus_cli::capability::builtin::served::Observed;
96/// // the shape a caller reads: a dry run judges and records nothing
97/// let json = r#"{"observation":{"schema":"majordomus.served-observation/v1",
98///   "deployment":"pages","url":"https://majordomus.dev/build.json",
99///   "expected":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verdict":"unreachable",
100///   "reason":"nothing was received","at":"1970-01-01T00:00:00Z"},"exit_code":12}"#;
101/// let got: Observed = serde_json::from_str(json).unwrap();
102/// assert_eq!(got.exit_code, 12);
103/// assert!(got.recorded.is_none());
104/// ```
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106pub struct Observed {
107    /// The observation, with its judgement.
108    pub observation: Observation,
109    /// Where it was recorded, repository-relative; absent on a dry run.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub recorded: Option<String>,
112    /// The exit status the command line reports: 0 served, 10 a measured no, 12 unanswered.
113    pub exit_code: i32,
114}
115
116/// What `served.forge` came to: whether the forge answered, and what its answer held. The
117/// answer itself is read through `deploy.observed`; this is the receipt of having asked.
118///
119/// ```
120/// use majordomus_cli::capability::builtin::served::ForgeObserved;
121/// // a forge that could not be asked is said, with the reason, and is exit 12: not answered
122/// let json = r#"{"observed":false,"reason":"gh repo view failed: no network",
123///   "deployments":0,"releases":0,"environments":0,"exit_code":12}"#;
124/// let got: ForgeObserved = serde_json::from_str(json).unwrap();
125/// assert!(!got.observed);
126/// assert_eq!(got.exit_code, 12);
127/// assert!(got.repository.is_none());
128/// ```
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
130pub struct ForgeObserved {
131    /// Whether the forge answered and the answer was recorded.
132    pub observed: bool,
133    /// Why not, in the words of whatever refused; absent when it answered.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub reason: Option<String>,
136    /// `owner/name`, as the forge named the repository.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub repository: Option<String>,
139    /// When the forge was asked, RFC 3339.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub observed_at: Option<String>,
142    /// How many deployments the answer held.
143    pub deployments: usize,
144    /// How many releases the answer held.
145    pub releases: usize,
146    /// How many environments those deployments went to.
147    pub environments: usize,
148    /// The exit status the command line reports: 0 observed, 12 not answered.
149    pub exit_code: i32,
150}
151
152/// The input of `served.show`: the commit every record is re-judged against, and
153/// optionally a single deployment. `HEAD` and every deployment when absent.
154///
155/// ```
156/// use majordomus_cli::capability::builtin::served::ShowInput;
157/// let input: ShowInput = serde_json::from_str(r#"{"deployment":"pages"}"#).unwrap();
158/// assert_eq!(input.deployment.as_deref(), Some("pages"));
159/// assert!(input.commit.is_none(), "absent means HEAD");
160/// // a field this operation does not have is refused rather than ignored
161/// assert!(serde_json::from_str::<ShowInput>(r#"{"url":"https://x"}"#).is_err());
162/// ```
163#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165pub struct ShowInput {
166    /// The commit each record is re-judged against, any revision git resolves; `HEAD` when
167    /// absent.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub commit: Option<String>,
170    /// Only this deployment.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub deployment: Option<String>,
173}
174
175/// One deployment's newest record and what it proves about the commit asked. Both are
176/// carried: the recorded judgement is what was seen, `now` is what it still proves.
177///
178/// ```
179/// use majordomus_cli::capability::builtin::served::DeploymentStanding;
180/// use majordomus_cli::served::ServedVerdict;
181/// let json = r#"{"deployment":"pages","observation":{"schema":"majordomus.served-observation/v1",
182///   "deployment":"pages","url":"https://majordomus.dev/build.json",
183///   "expected":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","verdict":"served",
184///   "reason":"the deployment serves aaaaaaaaaaaa","at":"1970-01-01T00:00:00Z"},
185///   "now":{"verdict":"stale","reason":"it does not contain bbbbbbbbbbbb"}}"#;
186/// let s: DeploymentStanding = serde_json::from_str(json).unwrap();
187/// // seen as served, and no longer proving the commit asked about
188/// assert_eq!(s.observation.judgement.verdict, ServedVerdict::Served);
189/// assert_eq!(s.now.verdict, ServedVerdict::Stale);
190/// ```
191#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
192pub struct DeploymentStanding {
193    /// The deployment name.
194    pub deployment: String,
195    /// The newest record of it.
196    pub observation: Observation,
197    /// That record re-judged against the commit asked about. This, not the recorded
198    /// judgement, is what a reader acts on.
199    pub now: Judgement,
200}
201
202/// Every deployment's standing against one commit: the answer `served.show` gives on the
203/// command line, over HTTP and to an MCP client, from one derivation.
204///
205/// ```
206/// use majordomus_cli::capability::builtin::served::{standing, ServedStanding};
207/// use majordomus_cli::served::{Ancestry, OBSERVATIONS_PATH};
208/// # struct A; impl Ancestry for A {
209/// #     fn has(&self, _: &str) -> Option<bool> { Some(true) }
210/// #     fn contains(&self, _: &str, _: &str) -> Option<bool> { Some(true) } }
211/// let dir = tempfile::tempdir().unwrap();
212/// let s: ServedStanding = standing(dir.path(), &"a".repeat(40), None, &A);
213/// assert_eq!(s.observations, OBSERVATIONS_PATH);
214/// assert!(s.deployments.is_empty(), "nothing observed yet");
215/// assert_eq!(s.unreadable, 0);
216/// ```
217#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
218pub struct ServedStanding {
219    /// The full commit every record was re-judged against.
220    pub expected: String,
221    /// The checkout-local file the records were read from.
222    pub observations: String,
223    /// Lines of that file that were not readable observations.
224    pub unreadable: usize,
225    /// One entry per deployment observed, in name order.
226    pub deployments: Vec<DeploymentStanding>,
227}
228
229fn root_of(ctx: &Context) -> PathBuf {
230    PathBuf::from(&ctx.index.repository.root)
231}
232
233/// Resolves `rev` to a full commit sha, refusing what git does not resolve to a commit.
234fn resolve(root: &Path, rev: &str) -> Result<String, CapabilityError> {
235    if rev.starts_with('-') {
236        return Err(CapabilityError::InvalidInput(format!(
237            "`{rev}` is not a revision"
238        )));
239    }
240    let out = crate::git::read_only(root)
241        .args(["rev-parse", "--verify", "--quiet", "--end-of-options"])
242        .arg(format!("{rev}^{{commit}}"))
243        .output()
244        .map_err(|e| CapabilityError::Internal(format!("cannot run git: {e}")))?;
245    let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
246    if !out.status.success() || sha.len() != 40 {
247        return Err(CapabilityError::InvalidInput(format!(
248            "`{rev}` does not name a commit this clone holds"
249        )));
250    }
251    Ok(sha)
252}
253
254/// The `base_url` of the site configuration, when the checkout has one.
255fn configured_base(root: &Path) -> Option<String> {
256    let text = std::fs::read_to_string(root.join("site/config.toml")).ok()?;
257    text.lines().find_map(|l| {
258        let rest = l
259            .trim()
260            .strip_prefix("base_url")?
261            .trim_start()
262            .strip_prefix('=')?;
263        Some(rest.trim().trim_matches('"').to_string())
264    })
265}
266
267fn observe(ctx: &Context, input: ObserveInput) -> Result<Observed, CapabilityError> {
268    let root = root_of(ctx);
269    let expected = resolve(&root, input.commit.as_deref().unwrap_or("HEAD"))?;
270    let base = match input.url {
271        Some(u) => u,
272        None => configured_base(&root).ok_or_else(|| {
273            CapabilityError::InvalidInput(
274                "no `url` was given and site/config.toml names no base_url".into(),
275            )
276        })?,
277    };
278    served::check_base(&base).map_err(CapabilityError::InvalidInput)?;
279    let identity = input
280        .identity
281        .unwrap_or_else(|| served::DEFAULT_IDENTITY.into());
282    if identity.contains("..") || identity.contains('?') || identity.contains('#') {
283        return Err(CapabilityError::InvalidInput(format!(
284            "`{identity}` is not a plain path under the site"
285        )));
286    }
287    let deployment = input
288        .deployment
289        .unwrap_or_else(|| DEFAULT_DEPLOYMENT.into());
290    let fetch = Curl {
291        max_seconds: input
292            .timeout_seconds
293            .unwrap_or(served::DEFAULT_PROBE_SECONDS)
294            .max(1),
295    };
296    let obs = served::observe(
297        &deployment,
298        &base,
299        &identity,
300        &expected,
301        &fetch,
302        &GitAncestry { root: &root },
303        SystemTime::now(),
304    );
305    let recorded = if input.dry_run {
306        None
307    } else {
308        served::append(&root, &obs).map_err(|e| {
309            CapabilityError::Internal(format!("cannot record the observation: {e}"))
310        })?;
311        Some(served::OBSERVATIONS_PATH.to_string())
312    };
313    Ok(Observed {
314        exit_code: obs.judgement.verdict.exit_code(),
315        observation: obs,
316        recorded,
317    })
318}
319
320fn forge(ctx: &Context, _: super::Empty) -> Result<ForgeObserved, CapabilityError> {
321    Ok(forge_with(crate::deploy::observed::GH, &root_of(ctx)))
322}
323
324/// Ask the forge through `program`, record what it said, and say what that came to. A forge
325/// that could not be asked is an answer of its own, never an error: the caller is a job that
326/// goes on to read whatever was last recorded.
327fn forge_with(program: &str, root: &Path) -> ForgeObserved {
328    match crate::deploy::observed::refresh_with(program, root) {
329        Ok(o) => ForgeObserved {
330            observed: true,
331            reason: None,
332            environments: crate::deploy::observed::environments(&o).len(),
333            deployments: o.deployments.len(),
334            releases: o.releases.len(),
335            repository: Some(o.repository),
336            observed_at: Some(o.observed_at),
337            exit_code: 0,
338        },
339        Err(reason) => ForgeObserved {
340            observed: false,
341            reason: Some(reason),
342            repository: None,
343            observed_at: None,
344            deployments: 0,
345            releases: 0,
346            environments: 0,
347            exit_code: 12,
348        },
349    }
350}
351
352fn show(ctx: &Context, input: ShowInput) -> Result<ServedStanding, CapabilityError> {
353    let root = root_of(ctx);
354    let expected = resolve(&root, input.commit.as_deref().unwrap_or("HEAD"))?;
355    Ok(standing(
356        &root,
357        &expected,
358        input.deployment.as_deref(),
359        &GitAncestry { root: &root },
360    ))
361}
362
363/// The standing of every recorded deployment against `expected`. Public so that a consumer
364/// that judges an intent's deployment criterion reads the same derivation as this read.
365///
366/// ```
367/// use majordomus_cli::capability::builtin::served::standing;
368/// use majordomus_cli::served::{append, observe, Ancestry, Fetch, Fetched, ServedVerdict};
369/// use std::time::SystemTime;
370/// struct Linear;  // a <- b
371/// impl Ancestry for Linear {
372///     fn has(&self, c: &str) -> Option<bool> { Some(c.starts_with('a') || c.starts_with('b')) }
373///     fn contains(&self, d: &str, a: &str) -> Option<bool> { Some(d >= a) }
374/// }
375/// struct Serves(String);
376/// impl Fetch for Serves {
377///     fn fetch(&self, _: &str) -> Fetched {
378///         Fetched::Body(format!(r#"{{"commit":"{}","dirty":false}}"#, self.0).into_bytes())
379///     }
380/// }
381/// let (a, b) = ("a".repeat(40), "b".repeat(40));
382/// let dir = tempfile::tempdir().unwrap();
383/// let o = observe("pages", "https://s", "build.json", &a, &Serves(a.clone()), &Linear,
384///                 SystemTime::UNIX_EPOCH);
385/// append(dir.path(), &o).unwrap();
386///
387/// // the record still proves the commit it was taken against ...
388/// let now = standing(dir.path(), &a, None, &Linear);
389/// assert_eq!(now.deployments[0].now.verdict, ServedVerdict::Served);
390/// // ... and stops proving once the commit asked about moves past it
391/// let later = standing(dir.path(), &b, None, &Linear);
392/// assert_eq!(later.deployments[0].now.verdict, ServedVerdict::Stale);
393/// ```
394pub fn standing(
395    root: &Path,
396    expected: &str,
397    only: Option<&str>,
398    git: &dyn served::Ancestry,
399) -> ServedStanding {
400    let (all, unreadable) = served::read_all(root);
401    let mut names: Vec<&str> = all.iter().map(|o| o.deployment.as_str()).collect();
402    // the canonical order every other collection is shown in; natural_cmp is total, so
403    // equal names are adjacent and dedup still removes every repeat
404    crate::order::canonical_strings(&mut names);
405    names.dedup();
406    let deployments = names
407        .into_iter()
408        .filter(|n| only.is_none_or(|o| o == *n))
409        .filter_map(|n| served::latest(&all, n))
410        .map(|o| DeploymentStanding {
411            deployment: o.deployment.clone(),
412            now: o.rejudge(expected, git),
413            observation: o.clone(),
414        })
415        .collect();
416    ServedStanding {
417        expected: expected.into(),
418        observations: served::OBSERVATIONS_PATH.into(),
419        unreadable,
420        deployments,
421    }
422}
423
424impl BenchmarkCases for ObserveInput {
425    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
426        // nothing: the capability is waived because it probes the network; timing it in a
427        // loop would measure a CDN, not this executable
428        Vec::new()
429    }
430}
431
432impl BenchmarkCases for ShowInput {
433    fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
434        vec![
435            NamedCase::new("head", ShowInput::default()),
436            NamedCase::new(
437                "pages-against-head",
438                ShowInput {
439                    commit: Some("HEAD".into()),
440                    deployment: Some(DEFAULT_DEPLOYMENT.into()),
441                },
442            ),
443        ]
444    }
445}
446
447/// The module: `served.observe` and `served.forge` (commands of the trusted command line)
448/// and `served.show` (a read on every surface).
449///
450/// ```
451/// use majordomus_cli::capability::builtin::served;
452/// let ids: Vec<_> = served::module().capabilities.iter()
453///     .map(|c| c.capability.id.as_str().to_string()).collect();
454/// assert_eq!(ids, vec!["served.observe", "served.forge", "served.show"]);
455/// ```
456pub fn module() -> ModuleDescriptor {
457    module! {
458        id: "served",
459        title: "Served deployments",
460        description: "Whether a deployment serves the commit it was meant to: the build identity a site serves, read from outside the way a visitor reads it, judged by commit containment and recorded so that a later reader can re-judge it. Only `served` passes; a site that could not be reached, an identity that could not be read, a build from an uncommitted tree and a served commit this clone does not hold are each an unanswered question, never a yes. Observing reaches the network and is a command of the trusted command line; reading the recorded standing is a read on every surface.",
461        stability: Stability::BehaviorallyVerified,
462        capabilities: [
463            capability! {
464                id: "served.observe",
465                kind: CapabilityKind::Command,
466                title: "Observe what a deployment serves",
467                description: "Fetches the build identity the site serves (`build.json` under the configured base URL, one bounded probe, no shell), judges it against the expected commit (HEAD unless named) by containment and appends the observation to the checkout-local record. Exit 0 when the deployment serves a build containing the commit, 10 when it measurably does not (behind, or built dirty), 12 when the question could not be answered (unreachable, unreadable, or a served commit this clone lacks).",
468                input: ObserveInput,
469                output: Observed,
470                stability: Stability::BehaviorallyVerified,
471                exposure: Exposure {
472                    mcp: None,
473                    http: None,
474                    cli: Some(CliExposure { path: vec!["served".into(), "observe".into()] }),
475                },
476                tags: ["deployment", "evidence", "verification"],
477                cache: CachePolicy::Disabled,
478                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::ExternalDependency },
479                handler: observe,
480            },
481            capability! {
482                id: "served.forge",
483                kind: CapabilityKind::Command,
484                title: "Ask the forge what it recorded as deployed and released",
485                description: "Asks the forge, through its own command-line tool and with the caller's credential, for the newest deployments to every environment and the newest releases, in one query, and records the answer in the checkout-local state `deploy.observed` reads. Exit 0 when the forge answered and the answer was recorded, 12 when it could not be asked (no tool, no credential, no network, a repository the forge does not know): what was recorded before then stands, with its age.",
486                input: super::Empty,
487                output: ForgeObserved,
488                stability: Stability::BehaviorallyVerified,
489                exposure: Exposure {
490                    mcp: None,
491                    http: None,
492                    cli: Some(CliExposure { path: vec!["served".into(), "forge".into()] }),
493                },
494                tags: ["deployment", "evidence", "forge"],
495                cache: CachePolicy::Disabled,
496                benchmark: BenchmarkPolicy::Waived { reason: WaiverReason::ExternalDependency },
497                handler: forge,
498            },
499            capability! {
500                id: "served.show",
501                title: "What each deployment was last seen serving",
502                description: "The newest recorded observation of each deployment, re-judged against a commit (HEAD unless named). A record keeps proving every commit its served build contains and stops proving the moment the commit asked about is not among them; a record that received nothing proves nothing. Asks no network and writes nothing.",
503                input: ShowInput,
504                output: ServedStanding,
505                stability: Stability::BehaviorallyVerified,
506                exposure: Exposure {
507                    mcp: Some(McpExposure {
508                        tool: Some("majordomus_served".into()),
509                        resource: Some(McpResource { uri: SERVED_URI.into(), name: "served".into() }),
510                    }),
511                    http: get("/api/v1/served"),
512                    cli: Some(CliExposure { path: vec!["served".into(), "show".into()] }),
513                },
514                tags: ["deployment", "evidence", "verification"],
515                cache: CachePolicy::Disabled,
516                handler: show,
517            },
518        ],
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use crate::capability::model::HttpMethod;
526    use crate::served::{BuildIdentity, ServedVerdict, OBSERVATION_SCHEMA};
527
528    const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
529    const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
530
531    struct Linear;
532    impl served::Ancestry for Linear {
533        fn has(&self, c: &str) -> Option<bool> {
534            Some(c == A || c == B)
535        }
536        fn contains(&self, d: &str, a: &str) -> Option<bool> {
537            Some(d == a || (d == B && a == A))
538        }
539    }
540
541    fn record(deployment: &str, commit: &str, verdict: ServedVerdict) -> Observation {
542        Observation {
543            schema: OBSERVATION_SCHEMA.into(),
544            deployment: deployment.into(),
545            url: "https://s/build.json".into(),
546            expected: commit.into(),
547            served: (verdict == ServedVerdict::Served).then(|| BuildIdentity {
548                commit: commit.into(),
549                dirty: false,
550                source_version: None,
551                source_hash: None,
552            }),
553            judgement: Judgement {
554                verdict,
555                reason: "r".into(),
556            },
557            at: "1970-01-01T00:00:00Z".into(),
558        }
559    }
560
561    /// Asking the forge is a receipt either way: what it held when it answered, and the
562    /// reason when it could not be asked, which is exit 12 and never an error.
563    #[cfg(unix)]
564    #[test]
565    fn asking_the_forge_says_what_it_held_or_why_it_could_not_be_asked() {
566        use std::os::unix::fs::PermissionsExt;
567        let dir = tempfile::tempdir().unwrap();
568        let program = dir.path().join("fake-gh");
569        std::fs::write(
570            &program,
571            "#!/bin/sh\ncase \"$1\" in\n  repo) echo '{\"nameWithOwner\": \"o/r\"}' ;;\n  api) cat \"$(dirname \"$0\")/answer.json\" ;;\nesac\n",
572        )
573        .unwrap();
574        std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap();
575        std::fs::write(
576            dir.path().join("answer.json"),
577            serde_json::json!({"data": {"repository": {
578                "url": "https://forge.example/o/r",
579                "deployments": {"nodes": [
580                    {"databaseId": 2, "environment": "github-pages", "createdAt": "2026-10-12T21:00:00Z",
581                     "commitOid": A, "latestStatus": {"state": "SUCCESS"}},
582                    {"databaseId": 1, "environment": "preview", "createdAt": "2026-10-11T21:00:00Z",
583                     "commitOid": B, "latestStatus": {"state": "SUCCESS"}}
584                ]},
585                "releases": {"nodes": [{"tagName": "v1.0.0", "name": "1.0.0",
586                     "publishedAt": "2026-10-10T00:00:00Z", "url": "https://forge.example/o/r/releases/tag/v1.0.0"}]}
587            }}})
588            .to_string(),
589        )
590        .unwrap();
591        let root = tempfile::tempdir().unwrap();
592        let asked = forge_with(&program.to_string_lossy(), root.path());
593        assert!(asked.observed, "{asked:?}");
594        assert_eq!(asked.repository.as_deref(), Some("o/r"));
595        assert_eq!(
596            (
597                asked.deployments,
598                asked.environments,
599                asked.releases,
600                asked.exit_code
601            ),
602            (2, 2, 1, 0)
603        );
604        assert!(asked.observed_at.is_some() && asked.reason.is_none());
605        // recorded: the read every surface has answers from it
606        assert!(crate::deploy::observed::report(root.path()).observed);
607
608        let refused = forge_with("/no/such/program", root.path());
609        assert!(!refused.observed);
610        assert_eq!(refused.exit_code, 12);
611        assert!(refused.reason.unwrap().contains("could not run"));
612        // and what was recorded before stands
613        assert!(crate::deploy::observed::report(root.path()).observed);
614    }
615
616    /// The capability itself, in a repository the forge does not know: the same receipt.
617    #[test]
618    fn the_capability_answers_a_receipt_where_the_forge_knows_nothing() {
619        let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
620            .expect("a synthetic repository");
621        let ctx = repo.context().expect("a context");
622        let asked = forge(&ctx, super::super::Empty {}).unwrap();
623        assert!(!asked.observed, "{asked:?}");
624        assert_eq!(asked.exit_code, 12);
625        assert!(asked.reason.is_some());
626    }
627
628    /// The asymmetry is the security property: the operation that reaches the network is on
629    /// no surface a browser or an MCP client can call.
630    #[test]
631    fn only_the_read_is_reachable_from_http_and_mcp() {
632        let m = module();
633        assert_eq!(m.id.as_str(), "served");
634        let by_id = |id: &str| {
635            m.capabilities
636                .iter()
637                .find(|c| c.capability.id.as_str() == id)
638                .unwrap()
639        };
640        let observe = &by_id("served.observe").capability;
641        assert_eq!(observe.kind, CapabilityKind::Command);
642        assert!(observe.exposure.http.is_none() && observe.exposure.mcp.is_none());
643        assert_eq!(
644            observe.exposure.cli.as_ref().unwrap().path,
645            ["served", "observe"]
646        );
647        let forge = &by_id("served.forge").capability;
648        assert_eq!(forge.kind, CapabilityKind::Command);
649        assert!(forge.exposure.http.is_none() && forge.exposure.mcp.is_none());
650        assert_eq!(
651            forge.exposure.cli.as_ref().unwrap().path,
652            ["served", "forge"]
653        );
654        let show = &by_id("served.show").capability;
655        assert_eq!(show.kind, CapabilityKind::Query);
656        assert_eq!(show.exposure.http.as_ref().unwrap().method, HttpMethod::Get);
657        assert_eq!(show.exposure.http.as_ref().unwrap().path, "/api/v1/served");
658        assert_eq!(
659            show.exposure.mcp.as_ref().unwrap().tool.as_deref(),
660            Some("majordomus_served")
661        );
662    }
663
664    #[test]
665    fn standing_rejudges_the_newest_record_of_each_deployment() {
666        let dir = tempfile::tempdir().unwrap();
667        for o in [
668            record("pages", A, ServedVerdict::Served),
669            record("fly", A, ServedVerdict::Unreachable),
670            record("pages", B, ServedVerdict::Served),
671        ] {
672            served::append(dir.path(), &o).unwrap();
673        }
674        let s = standing(dir.path(), B, None, &Linear);
675        let rows: Vec<_> = s
676            .deployments
677            .iter()
678            .map(|d| {
679                (
680                    d.deployment.as_str(),
681                    d.observation.expected.as_str(),
682                    d.now.verdict,
683                )
684            })
685            .collect();
686        assert_eq!(
687            rows,
688            vec![
689                ("fly", A, ServedVerdict::Unreachable),
690                ("pages", B, ServedVerdict::Served)
691            ]
692        );
693        assert_eq!(s.unreadable, 0);
694
695        let older = standing(dir.path(), A, Some("pages"), &Linear);
696        assert_eq!(older.deployments.len(), 1);
697        assert_eq!(
698            older.deployments[0].now.verdict,
699            ServedVerdict::Served,
700            "B contains A"
701        );
702    }
703
704    /// The deployments are one collection with one order: the canonical one, which reads
705    /// `mirror-10` after `mirror-2`, where a byte sort puts it first.
706    #[test]
707    fn deployments_are_listed_once_each_in_canonical_order() {
708        let dir = tempfile::tempdir().unwrap();
709        for name in ["pages", "mirror-10", "mirror-2", "mirror-2"] {
710            served::append(dir.path(), &record(name, A, ServedVerdict::Served)).unwrap();
711        }
712        let s = standing(dir.path(), A, None, &Linear);
713        let names: Vec<_> = s
714            .deployments
715            .iter()
716            .map(|d| d.deployment.as_str())
717            .collect();
718        assert_eq!(names, ["mirror-2", "mirror-10", "pages"]);
719    }
720
721    #[test]
722    fn a_served_record_is_stale_against_a_commit_it_does_not_contain() {
723        let dir = tempfile::tempdir().unwrap();
724        served::append(dir.path(), &record("pages", A, ServedVerdict::Served)).unwrap();
725        let s = standing(dir.path(), B, None, &Linear);
726        assert_eq!(
727            s.deployments[0].observation.judgement.verdict,
728            ServedVerdict::Served
729        );
730        assert_eq!(s.deployments[0].now.verdict, ServedVerdict::Stale);
731    }
732
733    #[test]
734    fn revisions_resolve_to_full_commits_and_option_shaped_input_is_refused() {
735        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
736        assert_eq!(resolve(root, "HEAD").unwrap().len(), 40);
737        for bad in ["--output=/tmp/x", "no-such-revision-anywhere"] {
738            assert!(
739                matches!(resolve(root, bad), Err(CapabilityError::InvalidInput(_))),
740                "{bad}"
741            );
742        }
743    }
744
745    #[test]
746    fn the_configured_base_is_read_from_the_site_configuration() {
747        let dir = tempfile::tempdir().unwrap();
748        assert_eq!(configured_base(dir.path()), None);
749        std::fs::create_dir_all(dir.path().join("site")).unwrap();
750        std::fs::write(
751            dir.path().join("site/config.toml"),
752            "title = \"x\"\nbase_url = \"https://example.test\"\n",
753        )
754        .unwrap();
755        assert_eq!(
756            configured_base(dir.path()).as_deref(),
757            Some("https://example.test")
758        );
759    }
760}