clawft_kernel/assessment/
mesh.rs1use std::collections::HashMap;
8use std::sync::Mutex;
9
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12
13use super::{AssessmentDiff, AssessmentReport, Finding};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
19pub enum AssessmentMessage {
20 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 RequestReport { requesting_node: String },
31 FullReport { report: AssessmentReport },
33 Gossip {
35 node_id: String,
36 project_name: String,
37 last_assessment: Option<String>,
38 finding_count: usize,
39 analyzer_count: usize,
40 },
41 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 finding_count: usize,
54 analyzer_count: usize,
55 },
56}
57
58#[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
71pub struct MeshCoordinator {
78 peer_states: Mutex<HashMap<String, PeerAssessmentState>>,
80 node_id: String,
82 project_name: String,
84 pending_broadcast: Mutex<Option<AssessmentMessage>>,
86}
87
88impl MeshCoordinator {
89 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 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 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 None
151 }
152 AssessmentMessage::FullReport { .. } => {
153 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 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 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 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 pub fn peer_states(&self) -> Vec<PeerAssessmentState> {
220 self.peer_states.lock().unwrap().values().cloned().collect()
221 }
222
223 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 pub fn set_pending_broadcast(&self, msg: AssessmentMessage) {
233 *self.pending_broadcast.lock().unwrap() = Some(msg);
234 }
235
236 pub fn take_pending_broadcast(&self) -> Option<AssessmentMessage> {
238 self.pending_broadcast.lock().unwrap().take()
239 }
240
241 pub fn node_id(&self) -> &str {
243 &self.node_id
244 }
245
246 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 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}