1use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
17use crate::capability::handler::{CapabilityError, Context};
18use crate::capability::model::{CachePolicy, Exposure, McpExposure, McpResource, Stability};
19use crate::capability::module::ModuleDescriptor;
20use crate::environment::preflight::{self, Preflight, Probe, RulesTally};
21use crate::environment::{
22 resolve, EnvironmentQuery, FieldSource, Inputs, RepositoryEnvironment, Resolution,
23};
24use crate::repository::Repository;
25use crate::{capability, module};
26
27use super::{get, mcp};
28
29pub const ENVIRONMENT_URI: &str = "majordomus://environment";
31
32pub const PREFLIGHT_URI: &str = "majordomus://environment/preflight";
34
35#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44pub struct PreflightInput {
45 #[serde(default)]
49 pub probe: bool,
50}
51
52impl BenchmarkCases for PreflightInput {
53 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
54 vec![NamedCase::new("sealed", PreflightInput { probe: false })]
55 }
56}
57
58fn preflight(ctx: &Context, input: PreflightInput) -> Result<Preflight, CapabilityError> {
59 let root = std::path::Path::new(&ctx.index.repository.root);
60 let repository = Repository::open(root).map_err(|e| {
61 CapabilityError::Internal(format!("the repository could not be re-read: {e}"))
62 })?;
63 let share = crate::share::Share::locate(None, root).ok();
67 let environment = resolve(
68 &Inputs {
69 repository: &repository,
70 share: share.as_ref(),
71 index: Some(&ctx.index),
72 registry: Some(&ctx.registry),
73 policy: None,
74 },
75 &EnvironmentQuery {
76 resolution: Resolution::Full,
77 probe_services: false,
78 use_cache: false,
79 write_cache: false,
80 },
81 );
82 let tally = RulesTally::of(&rules_report(ctx, root));
83 let policy = crate::policy::LoadedPolicy::load(&repository).map_err(|e| e.to_string());
84 let mut observations = preflight::observe(
85 root,
86 &environment,
87 policy.as_ref().map_err(Clone::clone),
88 Some(tally),
89 Probe {
91 cache: false,
92 ..if input.probe {
93 Probe::asked()
94 } else {
95 Probe::sealed()
96 }
97 },
98 );
99 if observations.server.this_process {
103 observations.peers = Ok(preflight::PeersObservation::of(&ctx.peers.list()));
104 }
105 Ok(preflight::derive(&observations))
106}
107
108fn rules_report(ctx: &Context, root: &std::path::Path) -> crate::rules::RulesReport {
114 ctx.execute("rules.report", serde_json::json!({}))
115 .ok()
116 .and_then(|v| serde_json::from_value(v).ok())
117 .unwrap_or_else(|| {
118 let ledger = crate::evidence::Ledger::load(root)
119 .unwrap_or_else(|_| crate::evidence::Ledger::empty());
120 crate::rules::report(&ctx.index, &ledger)
121 })
122}
123
124#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
126#[serde(deny_unknown_fields)]
127pub struct EnvironmentInput {
128 #[serde(default)]
132 pub probe_services: bool,
133}
134
135impl BenchmarkCases for EnvironmentInput {
136 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
137 vec![NamedCase::new(
138 "default",
139 EnvironmentInput {
140 probe_services: false,
141 },
142 )]
143 }
144}
145
146#[derive(Debug, Default, Serialize, Deserialize, JsonSchema)]
148#[serde(deny_unknown_fields)]
149pub struct ExplainInput {
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub field: Option<String>,
154}
155
156impl BenchmarkCases for ExplainInput {
157 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
158 vec![
159 NamedCase::new("all", ExplainInput { field: None }),
160 NamedCase::new(
161 "one-field",
162 ExplainInput {
163 field: Some("project.version".into()),
164 },
165 ),
166 ]
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
172pub struct EnvironmentProvenance {
173 pub resolution: Resolution,
175 pub fields: Vec<FieldSource>,
177}
178
179fn snapshot(
180 ctx: &Context,
181 input: EnvironmentInput,
182) -> Result<RepositoryEnvironment, CapabilityError> {
183 let root = std::path::Path::new(&ctx.index.repository.root);
184 let repository = Repository::open(root).map_err(|e| {
185 CapabilityError::Internal(format!("the repository could not be re-read: {e}"))
186 })?;
187 Ok(resolve(
191 &Inputs {
192 repository: &repository,
193 share: None,
194 index: Some(&ctx.index),
195 registry: Some(&ctx.registry),
196 policy: None,
197 },
198 &EnvironmentQuery {
199 resolution: Resolution::Full,
200 probe_services: input.probe_services,
201 use_cache: false,
202 write_cache: false,
206 },
207 ))
208}
209
210fn explain(ctx: &Context, input: ExplainInput) -> Result<EnvironmentProvenance, CapabilityError> {
211 let environment = snapshot(
212 ctx,
213 EnvironmentInput {
214 probe_services: false,
215 },
216 )?;
217 let fields = match &input.field {
218 None => environment.provenance.clone(),
219 Some(field) => {
220 let found: Vec<FieldSource> = environment
221 .provenance
222 .iter()
223 .filter(|p| p.field == *field || p.field.starts_with(&format!("{field}.")))
224 .cloned()
225 .collect();
226 if found.is_empty() {
227 return Err(CapabilityError::NotFound(format!(
228 "no field named '{field}'; `environment.explain` with no field lists every one"
229 )));
230 }
231 found
232 }
233 };
234 Ok(EnvironmentProvenance {
235 resolution: environment.resolution,
236 fields,
237 })
238}
239
240pub fn module() -> ModuleDescriptor {
242 module! {
243 id: "environment",
244 title: "Repository environment",
245 description: "What this checkout is right now: the project and its version, the repository and its layer, version control, the toolchains it declares, what the layer holds, the workflows a person can run, the provider projections and the local services — one typed snapshot, with a provenance entry for every value in it. The direnv banner, the Cockpit's overview and this route are renderings of the same value.",
246 stability: Stability::BehaviorallyVerified,
247 capabilities: [
248 capability! {
249 id: "environment.status",
250 title: "The repository environment",
251 description: "One snapshot of this checkout: project identity, repository identity, version control, declared toolchains, what the layer holds counted per kind, the workflows the runner describes, the provider projections against the policy that renders them, and the local services with the address a running server published. Every value carries where it came from.",
252 input: EnvironmentInput,
253 output: RepositoryEnvironment,
254 stability: Stability::BehaviorallyVerified,
255 exposure: Exposure {
256 mcp: Some(McpExposure {
257 tool: Some("majordomus_environment".into()),
258 resource: Some(McpResource { uri: ENVIRONMENT_URI.into(), name: "environment".into() }),
259 }),
260 http: get("/api/v1/environment"),
261 cli: None,
262 },
263 tags: ["environment", "repository", "introspection"],
264 cache: CachePolicy::Disabled,
279 handler: snapshot,
280 },
281 capability! {
282 id: "environment.explain",
283 title: "Where an environment value came from",
284 description: "The provenance of the snapshot: for each field, what decided it — a compile-time constant, a file, a command, or the cache — which resolver read it, and how far it can be trusted. Narrow it to one field, or to a prefix, by name.",
285 input: ExplainInput,
286 output: EnvironmentProvenance,
287 stability: Stability::BehaviorallyVerified,
288 exposure: Exposure {
289 mcp: mcp("majordomus_environment_explain"),
290 http: get("/api/v1/environment/explain"),
291 cli: None,
292 },
293 tags: ["environment", "provenance", "introspection"],
294 handler: explain,
295 },
296 capability! {
297 id: "environment.preflight",
298 title: "Whether Majordomus is in force here, and what proves it",
299 description: "One verdict per claim about this checkout — git; the episode, its briefing, the task and the handover; the policy, the rule corpus and the ADRs; the shared server and the MCP, API and Cockpit surfaces it serves; the peer board; recorded test runs, rule enforcement, provider projections, generated documentation and the deployment — each `verified`, `active`, `fresh`, `stale`, `degraded`, `unavailable`, `failed`, `unknown` or `not_applicable`, with the evidence it rests on. A verdict that asserts something is in force cannot be produced without evidence. The same value the command line prints and the entry banner summarises.",
300 input: PreflightInput,
301 output: Preflight,
302 stability: Stability::BehaviorallyVerified,
303 exposure: Exposure {
304 mcp: Some(McpExposure {
305 tool: Some("majordomus_preflight".into()),
306 resource: Some(McpResource { uri: PREFLIGHT_URI.into(), name: "preflight".into() }),
307 }),
308 http: get("/api/v1/environment/preflight"),
309 cli: None,
310 },
311 tags: ["environment", "governance", "verification", "evidence"],
312 cache: CachePolicy::Disabled,
315 handler: preflight,
316 },
317 ],
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
328 fn the_preflight_counts_the_rules_rules_report_answers() {
329 let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
330 let ctx = repo.context().expect("a context");
331 let root = std::path::Path::new(&ctx.index.repository.root);
332 let ledger = crate::evidence::Ledger::load(root).expect("a readable ledger");
333 assert_eq!(
334 rules_report(&ctx, root),
335 crate::rules::report(&ctx.index, &ledger)
336 );
337 let path = root.join(crate::evidence::LEDGER_PATH);
338 std::fs::create_dir_all(path.parent().expect("a parent")).expect("a directory");
339 std::fs::write(&path, "{ not a ledger").expect("an unreadable ledger");
340 assert!(ctx.execute("rules.report", serde_json::json!({})).is_err());
341 assert_eq!(
342 rules_report(&ctx, root),
343 crate::rules::report(&ctx.index, &crate::evidence::Ledger::empty())
344 );
345 }
346
347 #[test]
351 fn the_declaration_yields_the_projections_it_claims() {
352 let m = module();
353 assert_eq!(m.id.as_str(), "environment");
354 let expected: &[(&str, &str, &str)] = &[
355 (
356 "environment.status",
357 "majordomus_environment",
358 "/api/v1/environment",
359 ),
360 (
361 "environment.explain",
362 "majordomus_environment_explain",
363 "/api/v1/environment/explain",
364 ),
365 (
366 "environment.preflight",
367 "majordomus_preflight",
368 "/api/v1/environment/preflight",
369 ),
370 ];
371 let ids: Vec<&str> = m
372 .capabilities
373 .iter()
374 .map(|e| e.capability.id.as_str())
375 .collect();
376 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
377 assert_eq!(
378 ids, want,
379 "the module declares a different set of capabilities"
380 );
381 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
382 let exposure = &executable.capability.exposure;
383 assert_eq!(
384 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
385 Some(*tool),
386 "{id} lost or renamed its MCP tool"
387 );
388 assert_eq!(
389 exposure.http.as_ref().map(|h| h.path.as_str()),
390 Some(*path),
391 "{id} lost or renamed its HTTP route"
392 );
393 }
394 let resource = m.capabilities[0]
395 .capability
396 .exposure
397 .mcp
398 .as_ref()
399 .and_then(|m| m.resource.as_ref())
400 .expect("the snapshot is readable as a resource");
401 assert_eq!(resource.uri, ENVIRONMENT_URI);
402 }
403
404 #[test]
411 fn the_snapshot_is_not_cached_because_it_carries_the_moment_it_was_taken() {
412 let m = module();
413 let status = m
414 .capabilities
415 .iter()
416 .find(|e| e.capability.id.as_str() == "environment.status")
417 .expect("the module declares the snapshot");
418 assert!(
419 !status.capability.cache.is_enabled(),
420 "environment.status answers with a wall clock in it, so it cannot be cached"
421 );
422 }
423
424 #[test]
428 fn a_served_snapshot_writes_nothing() {
429 let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
430 let ctx = repo.context().expect("a context");
431 let cache = crate::environment::Cache::path(repo.root(), ".ai/local");
432 let environment = snapshot(
433 &ctx,
434 EnvironmentInput {
435 probe_services: false,
436 },
437 )
438 .expect("a snapshot");
439 assert_eq!(environment.resolution, Resolution::Full);
440 assert!(
441 !cache.exists(),
442 "a served request wrote the checkout's cache"
443 );
444 }
445
446 #[test]
447 fn a_served_snapshot_counts_the_layer_it_is_serving() {
448 let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
449 let ctx = repo.context().expect("a context");
450 let environment = snapshot(&ctx, EnvironmentInput::default()).expect("a snapshot");
451 assert_eq!(environment.layer.objects, Some(ctx.index.objects.len()));
452 assert_eq!(
453 environment.layer.capabilities,
454 Some(ctx.registry.summary().total)
455 );
456 }
457
458 #[test]
459 fn explaining_a_field_that_does_not_exist_says_so_rather_than_answering_emptily() {
460 let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
461 let ctx = repo.context().expect("a context");
462 match explain(
463 &ctx,
464 ExplainInput {
465 field: Some("nothing.like.this".into()),
466 },
467 ) {
468 Err(CapabilityError::NotFound(message)) => {
469 assert!(message.contains("nothing.like.this"))
470 }
471 other => panic!("{other:?}"),
472 }
473 }
474
475 #[test]
476 fn explaining_a_prefix_gives_every_field_under_it() {
477 let repo = crate::synthetic::SyntheticRepository::small().expect("a repository");
478 let ctx = repo.context().expect("a context");
479 let all = explain(&ctx, ExplainInput { field: None }).expect("every field");
480 let some = explain(
481 &ctx,
482 ExplainInput {
483 field: Some("project".into()),
484 },
485 )
486 .expect("the project fields");
487 assert!(!some.fields.is_empty());
488 assert!(some.fields.len() < all.fields.len());
489 assert!(some.fields.iter().all(|f| f.field.starts_with("project.")));
490 }
491}