Skip to main content

clawft_kernel/assessment/
mesh.rs

1//! Mesh coordination for cross-project assessment exchange (K6.6).
2//!
3//! Defines the protocol messages exchanged between assessment services
4//! running on different WeftOS kernel instances, and the coordinator
5//! that tracks peer assessment state via gossip.
6
7use std::collections::HashMap;
8use std::sync::Mutex;
9
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12
13use super::{AssessmentDiff, AssessmentReport, Finding};
14
15// ── Protocol messages ─────────────────────────────────────────────
16
17/// Messages exchanged between assessment services across the mesh.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub enum AssessmentMessage {
20    /// Broadcast: a new assessment completed on this node.
21    ReportAvailable {
22        node_id: String,
23        project_name: String,
24        timestamp: String,
25        files_scanned: usize,
26        finding_count: usize,
27        coherence_score: f64,
28    },
29    /// Request: fetch the full latest report from a peer.
30    RequestReport { requesting_node: String },
31    /// Response: the full assessment report.
32    FullReport { report: AssessmentReport },
33    /// Gossip: lightweight status exchange (sent periodically).
34    Gossip {
35        node_id: String,
36        project_name: String,
37        last_assessment: Option<String>,
38        finding_count: usize,
39        analyzer_count: usize,
40    },
41    /// Diff push: only findings that changed since the previous assessment
42    /// (WEFT-117). Prefer this over full-report flood when a prior report
43    /// exists so peers receive deltas only.
44    FindingDiff {
45        node_id: String,
46        project_name: String,
47        timestamp: String,
48        findings_new: Vec<Finding>,
49        findings_resolved: Vec<Finding>,
50        complexity_delta: i64,
51        coherence_delta: f64,
52        /// Total finding count after applying the diff (for peer state).
53        finding_count: usize,
54        analyzer_count: usize,
55    },
56}
57
58// ── Peer state ────────────────────────────────────────────────────
59
60/// Snapshot of a peer's assessment state, learned via gossip.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PeerAssessmentState {
63    pub node_id: String,
64    pub project_name: String,
65    pub last_assessment: Option<String>,
66    pub finding_count: usize,
67    pub analyzer_count: usize,
68    pub last_gossip: String,
69}
70
71// ── Coordinator ───────────────────────────────────────────────────
72
73/// Tracks peer assessment states and handles mesh protocol messages.
74///
75/// Lives inside `AssessmentService` (behind `Option`) and is only
76/// active when `[mesh] enabled = true` in the project config.
77pub struct MeshCoordinator {
78    /// Known peer assessment states (from gossip).
79    peer_states: Mutex<HashMap<String, PeerAssessmentState>>,
80    /// This node's ID.
81    node_id: String,
82    /// Project name from weave.toml.
83    project_name: String,
84    /// Pending outbound message produced after an assessment run.
85    pending_broadcast: Mutex<Option<AssessmentMessage>>,
86}
87
88impl MeshCoordinator {
89    /// Create a new coordinator for the given node and project.
90    pub fn new(node_id: String, project_name: String) -> Self {
91        Self {
92            peer_states: Mutex::new(HashMap::new()),
93            node_id,
94            project_name,
95            pending_broadcast: Mutex::new(None),
96        }
97    }
98
99    /// Process an incoming mesh message, optionally returning a response.
100    ///
101    /// - `Gossip` updates peer state, no response.
102    /// - `ReportAvailable` updates peer state, no response.
103    /// - `RequestReport` returns nothing here (caller should check
104    ///   `AssessmentService::get_latest()` and build a `FullReport`).
105    /// - `FullReport` is handled by the caller (store the report).
106    pub fn handle_message(&self, msg: AssessmentMessage) -> Option<AssessmentMessage> {
107        match msg {
108            AssessmentMessage::Gossip {
109                ref node_id,
110                ref project_name,
111                ref last_assessment,
112                finding_count,
113                analyzer_count,
114            } => {
115                self.update_peer(PeerAssessmentState {
116                    node_id: node_id.clone(),
117                    project_name: project_name.clone(),
118                    last_assessment: last_assessment.clone(),
119                    finding_count,
120                    analyzer_count,
121                    last_gossip: Utc::now().to_rfc3339(),
122                });
123                None
124            }
125            AssessmentMessage::ReportAvailable {
126                ref node_id,
127                ref project_name,
128                finding_count,
129                ..
130            } => {
131                // Update peer state with the broadcast info.
132                let mut peers = self.peer_states.lock().unwrap();
133                let entry = peers
134                    .entry(node_id.clone())
135                    .or_insert_with(|| PeerAssessmentState {
136                        node_id: node_id.clone(),
137                        project_name: project_name.clone(),
138                        last_assessment: None,
139                        finding_count: 0,
140                        analyzer_count: 0,
141                        last_gossip: Utc::now().to_rfc3339(),
142                    });
143                entry.finding_count = finding_count;
144                entry.last_gossip = Utc::now().to_rfc3339();
145                None
146            }
147            AssessmentMessage::RequestReport { .. } => {
148                // Caller should check AssessmentService::get_latest()
149                // and wrap it in FullReport if available.
150                None
151            }
152            AssessmentMessage::FullReport { .. } => {
153                // Caller handles storing the received report.
154                None
155            }
156            AssessmentMessage::FindingDiff {
157                ref node_id,
158                ref project_name,
159                finding_count,
160                analyzer_count,
161                ..
162            } => {
163                self.update_peer(PeerAssessmentState {
164                    node_id: node_id.clone(),
165                    project_name: project_name.clone(),
166                    last_assessment: Some(Utc::now().to_rfc3339()),
167                    finding_count,
168                    analyzer_count,
169                    last_gossip: Utc::now().to_rfc3339(),
170                });
171                None
172            }
173        }
174    }
175
176    /// Build a gossip message from the latest assessment report.
177    pub fn build_gossip(&self, report: &AssessmentReport) -> AssessmentMessage {
178        AssessmentMessage::Gossip {
179            node_id: self.node_id.clone(),
180            project_name: self.project_name.clone(),
181            last_assessment: Some(report.timestamp.to_rfc3339()),
182            finding_count: report.findings.len(),
183            analyzer_count: report.analyzers_run.len(),
184        }
185    }
186
187    /// Build a `ReportAvailable` broadcast from the latest report.
188    pub fn build_broadcast(&self, report: &AssessmentReport) -> AssessmentMessage {
189        AssessmentMessage::ReportAvailable {
190            node_id: self.node_id.clone(),
191            project_name: self.project_name.clone(),
192            timestamp: report.timestamp.to_rfc3339(),
193            files_scanned: report.files_scanned,
194            finding_count: report.findings.len(),
195            coherence_score: report.summary.coherence_score,
196        }
197    }
198
199    /// Build a `FindingDiff` push from an [`AssessmentDiff`] (changed findings only).
200    pub fn build_finding_diff(
201        &self,
202        report: &AssessmentReport,
203        diff: &AssessmentDiff,
204    ) -> AssessmentMessage {
205        AssessmentMessage::FindingDiff {
206            node_id: self.node_id.clone(),
207            project_name: self.project_name.clone(),
208            timestamp: report.timestamp.to_rfc3339(),
209            findings_new: diff.findings_new.clone(),
210            findings_resolved: diff.findings_resolved.clone(),
211            complexity_delta: diff.complexity_delta,
212            coherence_delta: diff.coherence_delta,
213            finding_count: report.findings.len(),
214            analyzer_count: report.analyzers_run.len(),
215        }
216    }
217
218    /// Return a snapshot of all known peer assessment states.
219    pub fn peer_states(&self) -> Vec<PeerAssessmentState> {
220        self.peer_states.lock().unwrap().values().cloned().collect()
221    }
222
223    /// Store or update a peer's assessment state.
224    pub fn update_peer(&self, state: PeerAssessmentState) {
225        self.peer_states
226            .lock()
227            .unwrap()
228            .insert(state.node_id.clone(), state);
229    }
230
231    /// Store a pending broadcast message for the daemon to pick up.
232    pub fn set_pending_broadcast(&self, msg: AssessmentMessage) {
233        *self.pending_broadcast.lock().unwrap() = Some(msg);
234    }
235
236    /// Take the pending broadcast (returns `None` if already consumed).
237    pub fn take_pending_broadcast(&self) -> Option<AssessmentMessage> {
238        self.pending_broadcast.lock().unwrap().take()
239    }
240
241    /// This node's ID.
242    pub fn node_id(&self) -> &str {
243        &self.node_id
244    }
245
246    /// This node's project name.
247    pub fn project_name(&self) -> &str {
248        &self.project_name
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::assessment::{AssessmentReport, AssessmentSummary};
256    use chrono::Utc;
257
258    fn make_report() -> AssessmentReport {
259        AssessmentReport {
260            timestamp: Utc::now(),
261            scope: "full".into(),
262            project: "/tmp/test".into(),
263            files_scanned: 42,
264            summary: AssessmentSummary {
265                total_files: 42,
266                coherence_score: 0.85,
267                ..Default::default()
268            },
269            findings: vec![],
270            analyzers_run: vec!["complexity".into(), "security".into()],
271        }
272    }
273
274    #[test]
275    fn gossip_roundtrip() {
276        let coord = MeshCoordinator::new("node-1".into(), "my-project".into());
277        let report = make_report();
278        let gossip = coord.build_gossip(&report);
279
280        // Simulate receiving our own gossip on another node
281        let coord2 = MeshCoordinator::new("node-2".into(), "other-project".into());
282        let response = coord2.handle_message(gossip);
283        assert!(response.is_none());
284
285        let peers = coord2.peer_states();
286        assert_eq!(peers.len(), 1);
287        assert_eq!(peers[0].node_id, "node-1");
288        assert_eq!(peers[0].project_name, "my-project");
289        assert_eq!(peers[0].analyzer_count, 2);
290    }
291
292    #[test]
293    fn broadcast_updates_peer_state() {
294        let coord = MeshCoordinator::new("node-1".into(), "proj-a".into());
295        let report = make_report();
296        let broadcast = coord.build_broadcast(&report);
297
298        let coord2 = MeshCoordinator::new("node-2".into(), "proj-b".into());
299        coord2.handle_message(broadcast);
300
301        let peers = coord2.peer_states();
302        assert_eq!(peers.len(), 1);
303        assert_eq!(peers[0].finding_count, 0);
304    }
305
306    #[test]
307    fn pending_broadcast_lifecycle() {
308        let coord = MeshCoordinator::new("node-1".into(), "proj".into());
309        assert!(coord.take_pending_broadcast().is_none());
310
311        let report = make_report();
312        let gossip = coord.build_gossip(&report);
313        coord.set_pending_broadcast(gossip);
314
315        assert!(coord.take_pending_broadcast().is_some());
316        assert!(coord.take_pending_broadcast().is_none());
317    }
318
319    #[test]
320    fn update_peer_overwrites() {
321        let coord = MeshCoordinator::new("node-1".into(), "proj".into());
322        coord.update_peer(PeerAssessmentState {
323            node_id: "peer-1".into(),
324            project_name: "proj-x".into(),
325            last_assessment: None,
326            finding_count: 5,
327            analyzer_count: 2,
328            last_gossip: Utc::now().to_rfc3339(),
329        });
330        coord.update_peer(PeerAssessmentState {
331            node_id: "peer-1".into(),
332            project_name: "proj-x".into(),
333            last_assessment: Some("2026-01-01T00:00:00Z".into()),
334            finding_count: 10,
335            analyzer_count: 3,
336            last_gossip: Utc::now().to_rfc3339(),
337        });
338
339        let peers = coord.peer_states();
340        assert_eq!(peers.len(), 1);
341        assert_eq!(peers[0].finding_count, 10);
342    }
343}