1use std::collections::BTreeMap;
42use std::path::{Path, PathBuf};
43
44use schemars::JsonSchema;
45use serde::{Deserialize, Serialize};
46
47use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
48use crate::capability::handler::{CapabilityError, Context};
49use crate::capability::model::{CachePolicy, Exposure, McpExposure, McpResource, Stability};
50use crate::capability::module::ModuleDescriptor;
51use crate::gates::{self, judge, model, Completion};
52use crate::{capability, module};
53
54use super::get;
55use super::obligations::Obligation;
56use super::views::Empty;
57
58pub const GATES_URI: &str = "majordomus://gates";
60
61pub const COMPLETION_URI: &str = "majordomus://gates/completion";
63
64#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
67pub struct GateModelReport {
104 pub version: u32,
106 pub source: String,
108 pub count: usize,
110 pub on_demand: Vec<String>,
113 pub gates: Vec<GateModelEntry>,
115 pub classes: Vec<model::GateClass>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub unreadable: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
142pub struct GateModelEntry {
144 #[serde(flatten)]
145 pub declared: model::GateDecl,
147 pub inputs: Vec<String>,
150}
151
152#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
169#[serde(deny_unknown_fields)]
170pub struct CompletionInput {
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub changed: Option<Vec<String>>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub base: Option<String>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub on_demand: Option<bool>,
183}
184
185impl BenchmarkCases for CompletionInput {
186 fn benchmark_cases(_: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
187 vec![
188 NamedCase::new("this-task", CompletionInput::default()),
189 NamedCase::new(
190 "one-source-file",
191 CompletionInput {
192 changed: Some(vec!["apps/majordomus-cli/src/lib.rs".into()]),
193 ..Default::default()
194 },
195 ),
196 NamedCase::new(
197 "a-document",
198 CompletionInput {
199 changed: Some(vec!["docs/CLI.md".into()]),
200 ..Default::default()
201 },
202 ),
203 NamedCase::new(
204 "everything-on-demand",
205 CompletionInput {
206 changed: Some(vec![".ai/repo/ci/gates.yaml".into()]),
207 on_demand: Some(true),
208 ..Default::default()
209 },
210 ),
211 ]
212 }
213}
214
215fn vocabulary(ctx: &Context) -> Result<Vec<Obligation>, String> {
219 let root = PathBuf::from(&ctx.index.repository.root);
220 let share = crate::share::Share::locate(ctx.index.share.as_deref(), &root)
221 .map_err(|e| format!("no distribution to read the obligation vocabulary from: {e}"))?;
222 let path = share.dir().join("obligations.yaml");
223 let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
224 #[derive(Deserialize)]
225 struct File {
226 obligations: Vec<Obligation>,
227 }
228 let file: File =
229 crate::metadata::yaml::parse_into(&text).map_err(|e| format!("{}: {e}", path.display()))?;
230 Ok(file.obligations)
231}
232
233fn load_model(root: &Path) -> (model::GateModel, Option<String>) {
241 match model::GateModel::load(root) {
242 Ok(m) => (m, None),
243 Err(reason) => (
244 model::GateModel {
245 version: 0,
246 gates: Vec::new(),
247 classes: Vec::new(),
248 },
249 Some(reason),
250 ),
251 }
252}
253
254fn gates_model(ctx: &Context, _: Empty) -> Result<GateModelReport, CapabilityError> {
255 let root = PathBuf::from(&ctx.index.repository.root);
256 let (m, unreadable) = load_model(&root);
257 Ok(GateModelReport {
258 version: m.version,
259 source: model::MODEL_PATH.to_string(),
260 count: m.gates.len(),
261 on_demand: m
262 .gates
263 .iter()
264 .filter(|g| g.on_demand)
265 .map(|g| g.id.clone())
266 .collect(),
267 gates: m
268 .gates
269 .iter()
270 .map(|g| GateModelEntry {
271 inputs: m.inputs_of(&g.id),
272 declared: g.clone(),
273 })
274 .collect(),
275 classes: m.classes.clone(),
276 unreadable,
277 })
278}
279
280fn gates_completion(ctx: &Context, input: CompletionInput) -> Result<Completion, CapabilityError> {
281 let root = PathBuf::from(&ctx.index.repository.root);
282 let mut findings: Vec<String> = Vec::new();
283
284 let (m, unreadable) = load_model(&root);
285 if let Some(reason) = unreadable {
286 findings.push(format!(
289 "this repository declares no readable CI model at {}, so no gate can be judged here — unknown, never a pass: {reason}",
290 model::MODEL_PATH
291 ));
292 }
293 let task = super::continuity::read_task(&gates::task_path(&root));
294 if task.is_none() {
295 findings.push(format!(
296 "no active task in this checkout ({}); the gates are reported against the \
297 working tree and nothing is attributed to a task",
298 gates::task_path(&root)
299 .strip_prefix(&root)
300 .unwrap_or(&gates::task_path(&root))
301 .display()
302 ));
303 }
304
305 let changed = match input.changed {
308 Some(list) => list,
309 None => {
310 let base = input
311 .base
312 .clone()
313 .or_else(|| task.as_ref().map(|t| t.head.clone()));
314 match gates::changed_paths(&root, base.as_deref()) {
315 Ok(paths) => paths,
316 Err(reason) => {
317 findings.push(format!(
318 "the change set could not be read from git, so applicability is \
319 computed over nothing and every gate is reported as it stands: {reason}"
320 ));
321 Vec::new()
322 }
323 }
324 }
325 };
326
327 let vocab = match vocabulary(ctx) {
328 Ok(v) => v,
329 Err(reason) => {
330 findings.push(format!(
331 "the obligation vocabulary could not be read, so no obligation can be \
332 implied from the change: {reason}"
333 ));
334 Vec::new()
335 }
336 };
337
338 let (runs, skipped) = match &task {
339 Some(t) => judge::runs_for(&gates::ledger_path(&root), &t.id),
340 None => (BTreeMap::new(), 0),
341 };
342 if skipped > 0 {
343 findings.push(format!(
344 "{skipped} ledger line(s) could not be read and were skipped; run `majordomus doctor`"
345 ));
346 }
347
348 let mut hashes: BTreeMap<String, Option<String>> = BTreeMap::new();
352 let mut digests = super::obligations::FileHashes::default();
353 for gate in &m.gates {
354 let specs = m.inputs_of(&gate.id);
355 let hash =
356 super::obligations::inputs_hash_with(&root, &specs, &mut digests).map(|(h, _)| h);
357 hashes.insert(
360 gate.id.clone(),
361 if specs.is_empty() {
362 Some(String::new())
363 } else {
364 hash
365 },
366 );
367 }
368
369 let mut standing: BTreeMap<String, gates::ObligationStanding> = BTreeMap::new();
374 let mut closure_reachable = false;
375 match ctx.execute("obligations.closure", serde_json::json!({})) {
376 Ok(value) => match serde_json::from_value::<super::obligations::Closure>(value) {
377 Ok(closure) => {
378 closure_reachable = true;
379 for o in closure.obligations {
380 standing.insert(
381 o.id.clone(),
382 gates::ObligationStanding {
383 state: o.state.as_str().to_string(),
384 detail: o.detail,
385 reproduce: o.reproduce,
386 },
387 );
388 }
389 }
390 Err(e) => findings.push(format!(
391 "obligations.closure answered something this report cannot read, so every \
392 obligation question is unknown rather than passing: {e}"
393 )),
394 },
395 Err(e) => findings.push(format!(
396 "obligations.closure could not be executed, so every obligation question is \
397 unknown rather than passing: {e}"
398 )),
399 }
400
401 let convergence = match ctx.execute("convergence.report", serde_json::json!({})) {
405 Ok(value) => match serde_json::from_value::<crate::convergence::ConvergenceReport>(value) {
406 Ok(report) => Some(report),
407 Err(e) => {
408 findings.push(format!(
409 "convergence.report answered something this report cannot read, so whether \
410 work is left behind is unknown rather than passing: {e}"
411 ));
412 None
413 }
414 },
415 Err(e) => {
416 findings.push(format!(
417 "convergence.report could not be executed, so whether work is left behind is \
418 unknown rather than passing: {e}"
419 ));
420 None
421 }
422 };
423
424 let closing = task
429 .as_ref()
430 .map(|t| gates::closing::read(&root, &t.head, &t.started_at));
431
432 Ok(gates::complete(
433 &m,
434 &changed,
435 task.as_ref(),
436 &vocab,
437 &runs,
438 &hashes,
439 &standing,
440 closure_reachable,
441 convergence.as_ref(),
442 closing.as_ref(),
443 input.on_demand.unwrap_or(false),
444 &crate::peers::rfc3339(std::time::SystemTime::now()),
445 findings,
446 ))
447}
448
449pub fn module() -> ModuleDescriptor {
472 module! {
473 id: "gates",
474 title: "Completion gates",
475 description: "Completion as a state the repository decides rather than a claim a worker makes: the validation gates this repository declares in its CI model, which of them a task's own change set selects, what each one last reported, whether that verdict still describes the tree it was taken over, and therefore whether the task may be called finished. A gate that has never reported is `queued` and not `pass`, a run whose files have changed since is `stale` and refuses exactly as a failure does, and a gate the change cannot affect is `exempt` rather than unknown.",
476 stability: Stability::BehaviorallyVerified,
477 capabilities: [
478 capability! {
479 id: "gates.model",
480 title: "The CI model",
481 description: "Every validation gate this repository declares — the job that runs it, the command it runs, what it proves, whether every plan selects it and whether its runner can be had on demand at all — with the path classes that decide which change selects which gate, and, derived from those classes, the pathspecs each gate's evidence is taken over.",
482 input: Empty,
483 output: GateModelReport,
484 stability: Stability::BehaviorallyVerified,
485 exposure: Exposure {
486 mcp: Some(McpExposure {
487 tool: Some("majordomus_gates".into()),
488 resource: Some(McpResource { uri: GATES_URI.into(), name: "gates".into() }),
489 }),
490 http: get("/api/v1/gates"),
491 cli: None,
492 },
493 tags: ["gates", "ci", "completion", "introspection"],
494 cache: CachePolicy::Process { max_entries: 4, ttl_seconds: Some(5) },
495 handler: gates_model,
496 },
497 capability! {
498 id: "gates.completion",
499 title: "Whether this task may be called finished",
500 description: "The active task's own change set — from the commit it started at to the working tree, uncommitted files included — put through the CI model: which gates it selects and why, what each one last reported and over which files, which verdicts have gone stale because the tree moved underneath them, which required gates have never reported at all, and which obligations the change implies whether or not the task declared them. `finishable` is false only when a required gate is known to be failing, stale or blocked by one that is; absence of a verdict is reported as unverified rather than silently accepted.",
501 input: CompletionInput,
502 output: Completion,
503 stability: Stability::BehaviorallyVerified,
504 exposure: Exposure {
505 mcp: Some(McpExposure {
506 tool: Some("majordomus_completion".into()),
507 resource: Some(McpResource { uri: COMPLETION_URI.into(), name: "completion".into() }),
508 }),
509 http: get("/api/v1/gates/completion"),
510 cli: None,
511 },
512 tags: ["gates", "completion", "tasks", "evidence"],
513 cache: CachePolicy::Disabled,
514 handler: gates_completion,
515 },
516 ],
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[test]
525 fn the_module_declares_what_it_claims() {
526 let m = module();
527 assert_eq!(m.id.as_str(), "gates");
528 assert_eq!(m.capabilities.len(), 2);
529 for e in &m.capabilities {
530 assert_eq!(e.capability.id.namespace(), "gates");
531 assert!(
532 e.capability.exposure.mcp.is_some() && e.capability.exposure.http.is_some(),
533 "{} must be readable on every surface without one recomputing it",
534 e.capability.id
535 );
536 }
537 }
538
539 #[test]
540 fn the_completion_answer_is_never_cached() {
541 let m = module();
544 let c = m
545 .capabilities
546 .iter()
547 .find(|e| e.capability.id.as_str() == "gates.completion")
548 .expect("declared");
549 assert!(matches!(c.capability.cache, CachePolicy::Disabled));
550 }
551
552 #[derive(Debug, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
553 struct NotAVerdict {
554 converged: String,
555 }
556
557 fn not_a_verdict(_: &Context, _: super::super::Empty) -> Result<NotAVerdict, CapabilityError> {
558 Ok(NotAVerdict {
559 converged: "perhaps".into(),
560 })
561 }
562
563 fn completion_with(
565 modules: Vec<crate::capability::module::ModuleDescriptor>,
566 ) -> crate::gates::Completion {
567 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
568 let index = repo.index().unwrap();
569 let registry = crate::capability::CapabilityRegistry::builder()
570 .with_modules(modules)
571 .with_index(&index)
572 .build()
573 .expect("the registry builds");
574 let ctx = Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry));
575 let value = ctx
576 .execute("gates.completion", serde_json::json!({ "changed": [] }))
577 .expect("the completion report answers");
578 serde_json::from_value(value).expect("a completion report")
579 }
580
581 #[test]
585 fn a_task_brings_the_closing_readings_into_the_report() {
586 let debt_of = |c: &crate::gates::Completion| {
587 c.questions
588 .iter()
589 .find(|q| q.id == "no-new-debt")
590 .expect("the invariant asks it")
591 .clone()
592 };
593 let without = debt_of(&completion_with(super::super::modules()));
594 assert_eq!(without.status, crate::gates::GateStatus::Unknown);
595 assert!(
596 without.evidence.contains("no task record"),
597 "{}",
598 without.evidence
599 );
600
601 let repo = crate::synthetic::SyntheticRepository::small().unwrap();
602 let record = crate::gates::task_path(repo.root());
603 std::fs::create_dir_all(record.parent().unwrap()).unwrap();
604 std::fs::write(
605 &record,
606 "id: t-1\ntask: \"closing\"\nprofile: implementation\noutcome: active\n\
607 started_at: 2026-10-10T12:00:00Z\nhead: 0123456789012345678901234567890123456789\n",
608 )
609 .unwrap();
610 let index = repo.index().unwrap();
611 let registry = crate::capability::CapabilityRegistry::builder()
612 .with_modules(super::super::modules())
613 .with_index(&index)
614 .build()
615 .expect("the registry builds");
616 let ctx = Context::new(std::sync::Arc::new(index), std::sync::Arc::new(registry));
617 let value = ctx
618 .execute("gates.completion", serde_json::json!({ "changed": [] }))
619 .expect("the completion report answers");
620 let with: crate::gates::Completion = serde_json::from_value(value).unwrap();
621 assert!(with.present, "the task record was read");
622 let debt = debt_of(&with);
623 assert_eq!(
624 debt.status,
625 crate::gates::GateStatus::Unknown,
626 "unread is never a pass"
627 );
628 assert!(
629 debt.evidence.contains("could not be compared"),
630 "{}",
631 debt.evidence
632 );
633 }
634
635 #[test]
639 fn a_convergence_answer_that_cannot_be_had_or_read_is_unknown_and_says_why() {
640 let stale = |c: &crate::gates::Completion| {
641 c.questions
642 .iter()
643 .find(|q| q.id == "no-stale-topology")
644 .expect("the invariant asks it")
645 .status
646 };
647
648 let refused = completion_with(super::super::modules());
650 assert!(
651 refused
652 .findings
653 .iter()
654 .any(|f| f.starts_with("convergence.report could not be executed")),
655 "{:?}",
656 refused.findings
657 );
658 assert_eq!(stale(&refused), crate::gates::GateStatus::Unknown);
659
660 let impostor = crate::module! {
661 id: "convergence",
662 title: "Convergence",
663 description: "Answers in a shape the completion report cannot read.",
664 stability: Stability::Experimental,
665 capabilities: [
666 crate::capability! {
667 id: "convergence.report", title: "Not a verdict",
668 description: "Something else.",
669 input: super::super::Empty, output: NotAVerdict,
670 stability: Stability::Experimental,
671 exposure: crate::capability::model::Exposure::default(), tags: [],
672 handler: not_a_verdict,
673 },
674 ],
675 };
676 let mut modules: Vec<_> = super::super::modules()
677 .into_iter()
678 .filter(|m| m.id.as_str() != "convergence")
679 .collect();
680 modules.push(impostor);
681 let unreadable = completion_with(modules);
682 assert!(
683 unreadable
684 .findings
685 .iter()
686 .any(|f| f
687 .starts_with("convergence.report answered something this report cannot read")),
688 "{:?}",
689 unreadable.findings
690 );
691 assert_eq!(stale(&unreadable), crate::gates::GateStatus::Unknown);
692 }
693}