Skip to main content

git_paw/mcp/query/
conflicts.rs

1//! Conflict reads.
2//!
3//! Conflicts are not persisted in a queryable endpoint either. Rather than
4//! parse the conflict-detector's human-readable feedback text, we
5//! reconstruct the set of currently-active intents from the broker `/log`
6//! and re-run the real detection logic ([`crate::broker::conflict`]) over
7//! them. This yields the same structured forward-overlap data the live
8//! supervisor sees, and degrades to an empty list when no broker is up.
9
10use std::time::{Duration, Instant};
11
12use rmcp::schemars;
13use serde::Serialize;
14
15use crate::broker::conflict::{ConflictTracker, NormalizedFileIntent};
16use crate::broker::messages::{BrokerMessage, Region};
17use crate::broker::publish::fetch_log_entries_over_http;
18use crate::mcp::RepoContext;
19
20use super::now_unix;
21
22/// One detected conflict in the shape `get_conflicts` returns.
23#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
24pub struct Conflict {
25    /// Conflict shape (currently always `"forward"` — intent vs intent).
26    pub shape: String,
27    /// The two branch ids in conflict, sorted.
28    pub branches: [String; 2],
29    /// The conflicting file paths.
30    pub files: Vec<String>,
31    /// Unix seconds when the conflict was detected (now — detection is live).
32    pub detected_at: u64,
33}
34
35fn region_label(region: &Region) -> String {
36    match region {
37        Region::Function { name } => format!("fn:{name}"),
38        Region::Class { name } => format!("class:{name}"),
39        Region::Block { anchor } => format!("block:{anchor}"),
40        Region::Range {
41            start_line,
42            end_line,
43        } => format!("lines:{start_line}-{end_line}"),
44    }
45}
46
47/// Returns every active forward conflict between the repository session's
48/// agents, or an empty list when no broker is reachable.
49#[must_use]
50pub fn conflicts(ctx: &RepoContext) -> Vec<Conflict> {
51    let Some(url) = ctx.broker_url.as_deref() else {
52        return Vec::new();
53    };
54    let Ok(entries) = fetch_log_entries_over_http(url) else {
55        return Vec::new();
56    };
57
58    // Reconstruct the latest intent per agent into a tracker. We pre-filter to
59    // unexpired intents and insert them with a generous TTL so the detector's
60    // own expiry never drops them mid-reconstruction.
61    let now_secs = now_unix();
62    let now_instant = Instant::now();
63    let ttl = Duration::from_hours(1);
64
65    let mut latest: std::collections::HashMap<
66        String,
67        (Vec<NormalizedFileIntent>, String, u64, u64),
68    > = std::collections::HashMap::new();
69    for entry in entries {
70        if let BrokerMessage::Intent { agent_id, payload } = entry.message {
71            let files: Vec<NormalizedFileIntent> = payload
72                .files
73                .iter()
74                .cloned()
75                .map(NormalizedFileIntent::from)
76                .collect();
77            latest.insert(
78                agent_id,
79                (
80                    files,
81                    payload.summary,
82                    entry.timestamp_unix_secs,
83                    payload.valid_for_seconds,
84                ),
85            );
86        }
87    }
88
89    let mut tracker = ConflictTracker::new();
90    let mut agents: Vec<String> = Vec::new();
91    for (agent_id, (files, summary, published_at, valid_for)) in latest {
92        if published_at.saturating_add(valid_for) <= now_secs {
93            continue; // expired — exclude from detection
94        }
95        agents.push(agent_id.clone());
96        tracker.insert_intent(&agent_id, files, summary, ttl, now_instant);
97    }
98    agents.sort();
99
100    // Collect forward overlaps for every agent, deduping the symmetric pair.
101    let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
102    let mut out = Vec::new();
103    for agent in &agents {
104        for fc in tracker.forward_overlaps(agent) {
105            let mut pair = [agent.clone(), fc.other_agent.clone()];
106            pair.sort();
107            let key = (pair[0].clone(), pair[1].clone());
108            if !seen.insert(key) {
109                continue;
110            }
111            let mut files: Vec<String> = fc
112                .files
113                .iter()
114                .map(|f| {
115                    if f.regions.is_empty() {
116                        f.path.clone()
117                    } else {
118                        let regions: Vec<String> = f.regions.iter().map(region_label).collect();
119                        format!("{} [{}]", f.path, regions.join(", "))
120                    }
121                })
122                .collect();
123            files.sort();
124            out.push(Conflict {
125                shape: "forward".to_string(),
126                branches: pair,
127                files,
128                detected_at: now_secs,
129            });
130        }
131    }
132    out.sort_by(|a, b| a.branches.cmp(&b.branches));
133    out
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn no_broker_yields_empty() {
142        let ctx = RepoContext {
143            root: std::path::PathBuf::from("/tmp"),
144            git_paw_dir: None,
145            broker_url: None,
146            server_name: "git-paw".to_string(),
147        };
148        assert!(conflicts(&ctx).is_empty());
149    }
150}