1use 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
51pub const SERVED_URI: &str = "majordomus://served";
53
54pub const DEFAULT_DEPLOYMENT: &str = "pages";
56
57#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
68#[serde(deny_unknown_fields)]
69pub struct ObserveInput {
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub deployment: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub url: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub identity: Option<String>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub commit: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub timeout_seconds: Option<u64>,
85 #[serde(default)]
88 pub dry_run: bool,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106pub struct Observed {
107 pub observation: Observation,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub recorded: Option<String>,
112 pub exit_code: i32,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
130pub struct ForgeObserved {
131 pub observed: bool,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub reason: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub repository: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub observed_at: Option<String>,
142 pub deployments: usize,
144 pub releases: usize,
146 pub environments: usize,
148 pub exit_code: i32,
150}
151
152#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165pub struct ShowInput {
166 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub commit: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub deployment: Option<String>,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
192pub struct DeploymentStanding {
193 pub deployment: String,
195 pub observation: Observation,
197 pub now: Judgement,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
218pub struct ServedStanding {
219 pub expected: String,
221 pub observations: String,
223 pub unreadable: usize,
225 pub deployments: Vec<DeploymentStanding>,
227}
228
229fn root_of(ctx: &Context) -> PathBuf {
230 PathBuf::from(&ctx.index.repository.root)
231}
232
233fn 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
254fn 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
324fn 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
363pub 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 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 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
447pub 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 #[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 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 assert!(crate::deploy::observed::report(root.path()).observed);
614 }
615
616 #[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 #[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 #[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}