Skip to main content

clawft_kernel/
mesh_assess.rs

1//! Real-time cross-project assessment mesh transport (K6.6 / SOP 3).
2//!
3//! Bridges the [`MeshCoordinator`](crate::assessment::mesh::MeshCoordinator)
4//! with the mesh networking stack so that assessment summaries, gossip,
5//! and full reports are exchanged over the wire in real-time rather than
6//! through artifact files on disk.
7//!
8//! # Architecture
9//!
10//! ```text
11//! ┌─────────────────┐        FrameType::AssessmentSync        ┌─────────────────┐
12//! │  Node A          │  ────────────────────────────────────>  │  Node B          │
13//! │  AssessmentSvc   │                                         │  AssessmentSvc   │
14//! │  MeshCoordinator │  <────────────────────────────────────  │  MeshCoordinator │
15//! └─────────────────┘        gossip / broadcast / report       └─────────────────┘
16//! ```
17//!
18//! Messages are serialized as JSON inside [`MeshFrame`] payloads with
19//! `FrameType::AssessmentSync`. The [`AssessmentTransport`] holds a
20//! reference to both the [`MeshCoordinator`] and the [`MeshRuntime`]'s
21//! peer connection map, providing:
22//!
23//! - **`broadcast_to_peers`** -- push a gossip or `ReportAvailable`
24//!   message to all connected peers.
25//! - **`handle_incoming`** -- decode an incoming assessment frame and
26//!   feed it into the coordinator's `handle_message`.
27//! - **`request_report`** -- ask a specific peer for its latest full
28//!   assessment report.
29//! - **`gossip_tick`** -- periodic gossip loop driver (called by the
30//!   mesh heartbeat tick or a dedicated timer).
31
32use std::sync::{Arc, Mutex};
33
34use serde::{Deserialize, Serialize};
35use tracing::{debug, warn};
36
37use crate::assessment::{AssessmentDiff, AssessmentReport};
38use crate::assessment::mesh::{AssessmentMessage, MeshCoordinator, PeerAssessmentState};
39use crate::error::{KernelError, KernelResult};
40use crate::mesh_framing::{FrameType, MeshFrame};
41
42// ── Wire envelope ────────────────────────────────────────────────
43
44/// Thin envelope wrapping [`AssessmentMessage`] with routing metadata.
45///
46/// This is the JSON payload inside a `FrameType::AssessmentSync` frame.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct AssessmentEnvelope {
49    /// Source node identifier.
50    pub source_node: String,
51    /// Monotonic sequence number for dedup.
52    pub sequence: u64,
53    /// The inner protocol message.
54    pub message: AssessmentMessage,
55}
56
57impl AssessmentEnvelope {
58    /// Serialize to JSON bytes.
59    pub fn to_bytes(&self) -> KernelResult<Vec<u8>> {
60        serde_json::to_vec(self)
61            .map_err(|e| KernelError::Mesh(format!("assessment envelope serialize: {e}")))
62    }
63
64    /// Deserialize from JSON bytes.
65    pub fn from_bytes(data: &[u8]) -> KernelResult<Self> {
66        serde_json::from_slice(data)
67            .map_err(|e| KernelError::Mesh(format!("assessment envelope deserialize: {e}")))
68    }
69
70    /// Wrap this envelope in a [`MeshFrame`] for wire transmission.
71    pub fn to_frame(&self) -> KernelResult<MeshFrame> {
72        let payload = self.to_bytes()?;
73        Ok(MeshFrame {
74            frame_type: FrameType::AssessmentSync,
75            payload,
76        })
77    }
78
79    /// Decode an [`AssessmentEnvelope`] from a [`MeshFrame`].
80    ///
81    /// Returns `Err` if the frame type is not `AssessmentSync` or the
82    /// payload cannot be deserialized.
83    pub fn from_frame(frame: &MeshFrame) -> KernelResult<Self> {
84        if frame.frame_type != FrameType::AssessmentSync {
85            return Err(KernelError::Mesh(format!(
86                "expected AssessmentSync frame, got {:?}",
87                frame.frame_type
88            )));
89        }
90        Self::from_bytes(&frame.payload)
91    }
92}
93
94// ── Transport ────────────────────────────────────────────────────
95
96/// Real-time assessment mesh transport.
97///
98/// Connects the assessment layer's [`MeshCoordinator`] with the K6
99/// mesh networking stack. Holds an `Arc` reference to the coordinator
100/// so the same coordinator is shared with [`AssessmentService`].
101///
102/// The transport does not own any network connections itself. Instead
103/// it produces serialized [`MeshFrame`] bytes and accepts incoming
104/// bytes, delegating actual send/recv to the caller (typically the
105/// [`MeshRuntime`] or a dedicated assessment sync loop).
106pub struct AssessmentTransport {
107    /// Shared assessment mesh coordinator.
108    coordinator: Arc<MeshCoordinator>,
109    /// Next outbound sequence number (monotonic).
110    next_seq: std::sync::atomic::AtomicU64,
111    /// Latest local report (for answering `RequestReport` and gossip ticks).
112    latest_report: Mutex<Option<AssessmentReport>>,
113}
114
115impl AssessmentTransport {
116    /// Create a new transport wrapping the given coordinator.
117    pub fn new(coordinator: Arc<MeshCoordinator>) -> Self {
118        Self {
119            coordinator,
120            next_seq: std::sync::atomic::AtomicU64::new(1),
121            latest_report: Mutex::new(None),
122        }
123    }
124
125    /// Return a reference to the inner coordinator.
126    pub fn coordinator(&self) -> &MeshCoordinator {
127        &self.coordinator
128    }
129
130    /// Shared coordinator handle (for status RPC / service wiring).
131    pub fn coordinator_arc(&self) -> Arc<MeshCoordinator> {
132        Arc::clone(&self.coordinator)
133    }
134
135    /// Cache the latest local report (enables RequestReport responses + ticks).
136    pub fn publish_report(&self, report: AssessmentReport) {
137        *self.latest_report.lock().unwrap() = Some(report);
138    }
139
140    /// Snapshot of the cached latest report.
141    pub fn latest_report(&self) -> Option<AssessmentReport> {
142        self.latest_report.lock().unwrap().clone()
143    }
144
145    // ── Outbound ─────────────────────────────────────────────────
146
147    /// Build a gossip frame from the latest assessment report.
148    ///
149    /// Returns `None` if there is nothing to gossip (no report).
150    pub fn build_gossip_frame(&self, report: &AssessmentReport) -> KernelResult<Vec<u8>> {
151        let msg = self.coordinator.build_gossip(report);
152        self.wrap_and_encode(msg)
153    }
154
155    /// Build a `ReportAvailable` broadcast frame.
156    pub fn build_broadcast_frame(&self, report: &AssessmentReport) -> KernelResult<Vec<u8>> {
157        let msg = self.coordinator.build_broadcast(report);
158        self.wrap_and_encode(msg)
159    }
160
161    /// Build a `FindingDiff` frame that carries only changed findings.
162    pub fn build_diff_frame(
163        &self,
164        report: &AssessmentReport,
165        diff: &AssessmentDiff,
166    ) -> KernelResult<Vec<u8>> {
167        let msg = self.coordinator.build_finding_diff(report, diff);
168        self.wrap_and_encode(msg)
169    }
170
171    /// Build a `RequestReport` frame addressed to a peer.
172    pub fn build_request_frame(&self) -> KernelResult<Vec<u8>> {
173        let msg = AssessmentMessage::RequestReport {
174            requesting_node: self.coordinator.node_id().to_string(),
175        };
176        self.wrap_and_encode(msg)
177    }
178
179    /// Build a `FullReport` response frame.
180    pub fn build_full_report_frame(&self, report: &AssessmentReport) -> KernelResult<Vec<u8>> {
181        let msg = AssessmentMessage::FullReport {
182            report: report.clone(),
183        };
184        self.wrap_and_encode(msg)
185    }
186
187    /// Drain the pending broadcast (if any) from the coordinator and
188    /// return it as encoded frame bytes ready for the wire.
189    pub fn drain_pending(&self) -> Option<Vec<u8>> {
190        let msg = self.coordinator.take_pending_broadcast()?;
191        match self.wrap_and_encode(msg) {
192            Ok(bytes) => Some(bytes),
193            Err(e) => {
194                warn!(error = %e, "failed to encode pending assessment broadcast");
195                None
196            }
197        }
198    }
199
200    // ── Inbound ──────────────────────────────────────────────────
201
202    /// Try to decode an `AssessmentSync` payload from raw peer bytes.
203    ///
204    /// Accepts either a full [`MeshFrame::encode`] buffer
205    /// (`[4-byte len][type][payload]`) or a type-prefixed buffer
206    /// (`[type][payload]` as produced after transport length stripping).
207    /// Returns `Ok(None)` when the bytes are not an AssessmentSync frame
208    /// (caller should fall through to IPC envelope handling).
209    pub fn try_extract_payload(data: &[u8]) -> KernelResult<Option<Vec<u8>>> {
210        if data.is_empty() {
211            return Ok(None);
212        }
213        // JSON MeshIpcEnvelope starts with '{' — not a framed message.
214        if data[0] == b'{' {
215            return Ok(None);
216        }
217        // Full encode: [4-len][type][payload]
218        if data.len() >= 5 {
219            let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
220            if len + 4 == data.len() && data[4] == FrameType::AssessmentSync as u8 {
221                let frame = MeshFrame::decode(&data[4..])
222                    .map_err(|e| KernelError::Mesh(format!("assessment frame decode: {e}")))?;
223                return Ok(Some(frame.payload));
224            }
225        }
226        // Type-prefixed only: [type][payload]
227        if data[0] == FrameType::AssessmentSync as u8 {
228            let frame = MeshFrame::decode(data)
229                .map_err(|e| KernelError::Mesh(format!("assessment frame decode: {e}")))?;
230            return Ok(Some(frame.payload));
231        }
232        Ok(None)
233    }
234
235    /// Handle raw peer bytes if they carry AssessmentSync; otherwise
236    /// return `Ok(None)` so the mesh runtime can treat them as IPC.
237    ///
238    /// On AssessmentSync, returns `Ok(Some(response_bytes))` when a
239    /// reply should be sent, or `Ok(Some(empty))` is not used — `Ok(Some(None))`
240    /// isn't valid. Convention: `Ok(Some(opt_reply))` where outer Some
241    /// means "this was assessment traffic".
242    pub fn try_handle_raw(&self, data: &[u8]) -> KernelResult<Option<Option<Vec<u8>>>> {
243        match Self::try_extract_payload(data)? {
244            Some(payload) => {
245                let reply = self.handle_incoming(&payload)?;
246                Ok(Some(reply))
247            }
248            None => Ok(None),
249        }
250    }
251
252    /// Handle an incoming `AssessmentSync` frame payload.
253    ///
254    /// Decodes the envelope, feeds the inner message to the coordinator,
255    /// and returns an optional response message (encoded as frame bytes)
256    /// that should be sent back to the source peer. `RequestReport` is
257    /// answered with a `FullReport` when a local report has been published.
258    pub fn handle_incoming(&self, frame_payload: &[u8]) -> KernelResult<Option<Vec<u8>>> {
259        let envelope = AssessmentEnvelope::from_bytes(frame_payload)?;
260        debug!(
261            from = %envelope.source_node,
262            seq = envelope.sequence,
263            "received assessment sync message"
264        );
265
266        // Answer RequestReport from the published local report before
267        // coordinator handling (which returns None for this variant).
268        if matches!(
269            &envelope.message,
270            AssessmentMessage::RequestReport { .. }
271        ) {
272            if let Some(report) = self.latest_report() {
273                return Ok(Some(self.build_full_report_frame(&report)?));
274            }
275            return Ok(None);
276        }
277
278        let response = self.coordinator.handle_message(envelope.message);
279        match response {
280            Some(resp_msg) => {
281                let bytes = self.wrap_and_encode(resp_msg)?;
282                Ok(Some(bytes))
283            }
284            None => Ok(None),
285        }
286    }
287
288    // ── Gossip tick ──────────────────────────────────────────────
289
290    /// Drive one gossip tick.
291    ///
292    /// If the assessment service has a latest report, this builds a
293    /// gossip message and returns it as encoded frame bytes for
294    /// broadcast to all peers. The caller is responsible for sending
295    /// the bytes to each connected peer.
296    ///
297    /// Returns `None` if there is no report to gossip about.
298    pub fn gossip_tick(&self, latest_report: Option<&AssessmentReport>) -> Option<Vec<u8>> {
299        let report = latest_report?;
300        match self.build_gossip_frame(report) {
301            Ok(bytes) => Some(bytes),
302            Err(e) => {
303                warn!(error = %e, "failed to build gossip frame");
304                None
305            }
306        }
307    }
308
309    // ── Peer state access ────────────────────────────────────────
310
311    /// Return a snapshot of all known peer assessment states.
312    pub fn peer_states(&self) -> Vec<PeerAssessmentState> {
313        self.coordinator.peer_states()
314    }
315
316    // ── Internal ─────────────────────────────────────────────────
317
318    fn next_sequence(&self) -> u64 {
319        self.next_seq
320            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
321    }
322
323    fn wrap_and_encode(&self, message: AssessmentMessage) -> KernelResult<Vec<u8>> {
324        let envelope = AssessmentEnvelope {
325            source_node: self.coordinator.node_id().to_string(),
326            sequence: self.next_sequence(),
327            message,
328        };
329        let frame = envelope.to_frame()?;
330        frame
331            .encode()
332            .map_err(|e| KernelError::Mesh(format!("frame encode: {e}")))
333    }
334}
335
336// ── Tests ────────────────────────────────────────────────────────
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::assessment::{AssessmentReport, AssessmentSummary};
342    use chrono::Utc;
343
344    fn make_coordinator(node_id: &str, project: &str) -> Arc<MeshCoordinator> {
345        Arc::new(MeshCoordinator::new(node_id.into(), project.into()))
346    }
347
348    fn make_report() -> AssessmentReport {
349        AssessmentReport {
350            timestamp: Utc::now(),
351            scope: "full".into(),
352            project: "/tmp/test-project".into(),
353            files_scanned: 100,
354            summary: AssessmentSummary {
355                total_files: 100,
356                coherence_score: 0.92,
357                rust_files: 60,
358                lines_of_code: 15_000,
359                ..Default::default()
360            },
361            findings: vec![],
362            analyzers_run: vec!["complexity".into(), "security".into(), "topology".into()],
363        }
364    }
365
366    #[test]
367    fn envelope_serde_roundtrip() {
368        let env = AssessmentEnvelope {
369            source_node: "node-a".into(),
370            sequence: 42,
371            message: AssessmentMessage::Gossip {
372                node_id: "node-a".into(),
373                project_name: "proj-a".into(),
374                last_assessment: Some("2026-04-04T00:00:00Z".into()),
375                finding_count: 5,
376                analyzer_count: 3,
377            },
378        };
379        let bytes = env.to_bytes().unwrap();
380        let restored = AssessmentEnvelope::from_bytes(&bytes).unwrap();
381        assert_eq!(restored.source_node, "node-a");
382        assert_eq!(restored.sequence, 42);
383    }
384
385    #[test]
386    fn envelope_to_frame_uses_correct_type() {
387        let env = AssessmentEnvelope {
388            source_node: "n".into(),
389            sequence: 1,
390            message: AssessmentMessage::RequestReport {
391                requesting_node: "n".into(),
392            },
393        };
394        let frame = env.to_frame().unwrap();
395        assert_eq!(frame.frame_type, FrameType::AssessmentSync);
396    }
397
398    #[test]
399    fn envelope_from_frame_rejects_wrong_type() {
400        let frame = MeshFrame {
401            frame_type: FrameType::Heartbeat,
402            payload: vec![],
403        };
404        let err = AssessmentEnvelope::from_frame(&frame).unwrap_err();
405        assert!(err.to_string().contains("expected AssessmentSync"));
406    }
407
408    #[test]
409    fn build_gossip_frame_roundtrip() {
410        let coord = make_coordinator("node-a", "proj-a");
411        let transport = AssessmentTransport::new(coord.clone());
412        let report = make_report();
413
414        let bytes = transport.build_gossip_frame(&report).unwrap();
415        // Decode: first 4 bytes are length prefix, then type + payload.
416        let len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
417        let frame = MeshFrame::decode(&bytes[4..4 + len]).unwrap();
418        assert_eq!(frame.frame_type, FrameType::AssessmentSync);
419
420        let envelope = AssessmentEnvelope::from_bytes(&frame.payload).unwrap();
421        assert_eq!(envelope.source_node, "node-a");
422        match &envelope.message {
423            AssessmentMessage::Gossip {
424                node_id,
425                project_name,
426                analyzer_count,
427                ..
428            } => {
429                assert_eq!(node_id, "node-a");
430                assert_eq!(project_name, "proj-a");
431                assert_eq!(*analyzer_count, 3);
432            }
433            other => panic!("expected Gossip, got: {other:?}"),
434        }
435    }
436
437    #[test]
438    fn build_broadcast_frame_roundtrip() {
439        let coord = make_coordinator("node-b", "proj-b");
440        let transport = AssessmentTransport::new(coord);
441        let report = make_report();
442
443        let bytes = transport.build_broadcast_frame(&report).unwrap();
444        let len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
445        let frame = MeshFrame::decode(&bytes[4..4 + len]).unwrap();
446        let envelope = AssessmentEnvelope::from_bytes(&frame.payload).unwrap();
447
448        match &envelope.message {
449            AssessmentMessage::ReportAvailable {
450                node_id,
451                files_scanned,
452                coherence_score,
453                ..
454            } => {
455                assert_eq!(node_id, "node-b");
456                assert_eq!(*files_scanned, 100);
457                assert!(*coherence_score > 0.9);
458            }
459            other => panic!("expected ReportAvailable, got: {other:?}"),
460        }
461    }
462
463    #[test]
464    fn build_request_frame_roundtrip() {
465        let coord = make_coordinator("requester", "proj");
466        let transport = AssessmentTransport::new(coord);
467
468        let bytes = transport.build_request_frame().unwrap();
469        let len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
470        let frame = MeshFrame::decode(&bytes[4..4 + len]).unwrap();
471        let envelope = AssessmentEnvelope::from_bytes(&frame.payload).unwrap();
472
473        match &envelope.message {
474            AssessmentMessage::RequestReport { requesting_node } => {
475                assert_eq!(requesting_node, "requester");
476            }
477            other => panic!("expected RequestReport, got: {other:?}"),
478        }
479    }
480
481    #[test]
482    fn build_full_report_frame_roundtrip() {
483        let coord = make_coordinator("responder", "proj");
484        let transport = AssessmentTransport::new(coord);
485        let report = make_report();
486
487        let bytes = transport.build_full_report_frame(&report).unwrap();
488        let len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
489        let frame = MeshFrame::decode(&bytes[4..4 + len]).unwrap();
490        let envelope = AssessmentEnvelope::from_bytes(&frame.payload).unwrap();
491
492        match &envelope.message {
493            AssessmentMessage::FullReport { report: r } => {
494                assert_eq!(r.files_scanned, 100);
495                assert_eq!(r.analyzers_run.len(), 3);
496            }
497            other => panic!("expected FullReport, got: {other:?}"),
498        }
499    }
500
501    #[test]
502    fn handle_incoming_gossip_updates_coordinator() {
503        let coord_a = make_coordinator("node-a", "proj-a");
504        let transport_a = AssessmentTransport::new(coord_a.clone());
505        let report = make_report();
506
507        // Build gossip bytes on node A.
508        let gossip_bytes = transport_a.build_gossip_frame(&report).unwrap();
509
510        // Decode the frame payload (skip the 4-byte length + 1-byte type).
511        let len = u32::from_be_bytes([
512            gossip_bytes[0],
513            gossip_bytes[1],
514            gossip_bytes[2],
515            gossip_bytes[3],
516        ]) as usize;
517        let frame = MeshFrame::decode(&gossip_bytes[4..4 + len]).unwrap();
518
519        // Feed it into node B's transport.
520        let coord_b = make_coordinator("node-b", "proj-b");
521        let transport_b = AssessmentTransport::new(coord_b.clone());
522        let response = transport_b.handle_incoming(&frame.payload).unwrap();
523        assert!(response.is_none(), "gossip should not produce a response");
524
525        // Coordinator B should now know about node A.
526        let peers = coord_b.peer_states();
527        assert_eq!(peers.len(), 1);
528        assert_eq!(peers[0].node_id, "node-a");
529        assert_eq!(peers[0].project_name, "proj-a");
530        assert_eq!(peers[0].analyzer_count, 3);
531    }
532
533    #[test]
534    fn handle_incoming_broadcast_updates_coordinator() {
535        let coord_a = make_coordinator("node-a", "proj-a");
536        let transport_a = AssessmentTransport::new(coord_a);
537        let report = make_report();
538
539        let broadcast_bytes = transport_a.build_broadcast_frame(&report).unwrap();
540        let len = u32::from_be_bytes([
541            broadcast_bytes[0],
542            broadcast_bytes[1],
543            broadcast_bytes[2],
544            broadcast_bytes[3],
545        ]) as usize;
546        let frame = MeshFrame::decode(&broadcast_bytes[4..4 + len]).unwrap();
547
548        let coord_b = make_coordinator("node-b", "proj-b");
549        let transport_b = AssessmentTransport::new(coord_b.clone());
550        transport_b.handle_incoming(&frame.payload).unwrap();
551
552        let peers = coord_b.peer_states();
553        assert_eq!(peers.len(), 1);
554        assert_eq!(peers[0].finding_count, 0); // report has 0 findings
555    }
556
557    #[test]
558    fn sequence_numbers_increment() {
559        let coord = make_coordinator("n", "p");
560        let transport = AssessmentTransport::new(coord);
561
562        let s1 = transport.next_sequence();
563        let s2 = transport.next_sequence();
564        let s3 = transport.next_sequence();
565
566        assert_eq!(s1, 1);
567        assert_eq!(s2, 2);
568        assert_eq!(s3, 3);
569    }
570
571    #[test]
572    fn drain_pending_returns_none_when_empty() {
573        let coord = make_coordinator("n", "p");
574        let transport = AssessmentTransport::new(coord);
575        assert!(transport.drain_pending().is_none());
576    }
577
578    #[test]
579    fn drain_pending_returns_bytes_when_set() {
580        let coord = make_coordinator("n", "p");
581        let report = make_report();
582        let gossip = coord.build_gossip(&report);
583        coord.set_pending_broadcast(gossip);
584
585        let transport = AssessmentTransport::new(coord);
586        let bytes = transport.drain_pending();
587        assert!(bytes.is_some());
588        // Second drain should be empty.
589        assert!(transport.drain_pending().is_none());
590    }
591
592    #[test]
593    fn gossip_tick_with_report_produces_frame() {
594        let coord = make_coordinator("n", "p");
595        let transport = AssessmentTransport::new(coord);
596        let report = make_report();
597
598        let bytes = transport.gossip_tick(Some(&report));
599        assert!(bytes.is_some());
600    }
601
602    #[test]
603    fn gossip_tick_without_report_returns_none() {
604        let coord = make_coordinator("n", "p");
605        let transport = AssessmentTransport::new(coord);
606        assert!(transport.gossip_tick(None).is_none());
607    }
608
609    #[test]
610    fn peer_states_empty_initially() {
611        let coord = make_coordinator("n", "p");
612        let transport = AssessmentTransport::new(coord);
613        assert!(transport.peer_states().is_empty());
614    }
615
616    #[test]
617    fn two_node_assessment_exchange() {
618        // Simulate a full assessment exchange between two nodes.
619        let coord_a = make_coordinator("node-a", "proj-a");
620        let coord_b = make_coordinator("node-b", "proj-b");
621        let transport_a = AssessmentTransport::new(coord_a.clone());
622        let transport_b = AssessmentTransport::new(coord_b.clone());
623
624        let report_a = make_report();
625
626        // 1. Node A completes an assessment and broadcasts.
627        let broadcast = transport_a.build_broadcast_frame(&report_a).unwrap();
628        let len =
629            u32::from_be_bytes([broadcast[0], broadcast[1], broadcast[2], broadcast[3]]) as usize;
630        let frame = MeshFrame::decode(&broadcast[4..4 + len]).unwrap();
631        transport_b.handle_incoming(&frame.payload).unwrap();
632
633        // 2. Node A also gossips.
634        let gossip = transport_a.build_gossip_frame(&report_a).unwrap();
635        let len = u32::from_be_bytes([gossip[0], gossip[1], gossip[2], gossip[3]]) as usize;
636        let frame = MeshFrame::decode(&gossip[4..4 + len]).unwrap();
637        transport_b.handle_incoming(&frame.payload).unwrap();
638
639        // 3. Node B now knows about Node A.
640        let peers = transport_b.peer_states();
641        assert_eq!(peers.len(), 1);
642        assert_eq!(peers[0].node_id, "node-a");
643        assert_eq!(peers[0].project_name, "proj-a");
644        assert_eq!(peers[0].analyzer_count, 3);
645
646        // 4. Node B requests the full report.
647        let req = transport_b.build_request_frame().unwrap();
648        let len = u32::from_be_bytes([req[0], req[1], req[2], req[3]]) as usize;
649        let frame = MeshFrame::decode(&req[4..4 + len]).unwrap();
650        // Node A receives the request -- coordinator returns None
651        // (caller is responsible for fetching the report).
652        let response = transport_a.handle_incoming(&frame.payload).unwrap();
653        assert!(response.is_none());
654
655        // 5. Node A sends the full report back.
656        let full = transport_a.build_full_report_frame(&report_a).unwrap();
657        let len = u32::from_be_bytes([full[0], full[1], full[2], full[3]]) as usize;
658        let frame = MeshFrame::decode(&full[4..4 + len]).unwrap();
659        let resp = transport_b.handle_incoming(&frame.payload).unwrap();
660        assert!(resp.is_none()); // FullReport is handled by caller
661    }
662
663    fn make_finding(file: &str, msg: &str) -> crate::assessment::Finding {
664        crate::assessment::Finding {
665            severity: "warning".into(),
666            category: "size".into(),
667            file: file.into(),
668            line: Some(1),
669            message: msg.into(),
670        }
671    }
672
673    #[test]
674    fn build_diff_frame_only_changed_findings() {
675        use crate::assessment::analyzer::diff_reports;
676
677        let coord = make_coordinator("node-a", "proj-a");
678        let transport = AssessmentTransport::new(coord);
679        let mut prev = make_report();
680        prev.findings = vec![make_finding("a.rs", "old")];
681        let mut curr = make_report();
682        curr.findings = vec![
683            make_finding("a.rs", "old"),
684            make_finding("b.rs", "new issue"),
685        ];
686        let diff = diff_reports(&curr, &prev);
687        assert_eq!(diff.findings_new.len(), 1);
688        assert!(diff.findings_resolved.is_empty());
689
690        let bytes = transport.build_diff_frame(&curr, &diff).unwrap();
691        let len = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize;
692        let frame = MeshFrame::decode(&bytes[4..4 + len]).unwrap();
693        let envelope = AssessmentEnvelope::from_bytes(&frame.payload).unwrap();
694        match envelope.message {
695            AssessmentMessage::FindingDiff {
696                findings_new,
697                findings_resolved,
698                finding_count,
699                ..
700            } => {
701                assert_eq!(findings_new.len(), 1);
702                assert_eq!(findings_new[0].file, "b.rs");
703                assert!(findings_resolved.is_empty());
704                assert_eq!(finding_count, 2);
705            }
706            other => panic!("expected FindingDiff, got {other:?}"),
707        }
708    }
709
710    #[test]
711    fn try_extract_payload_from_full_encode_and_type_prefix() {
712        let coord = make_coordinator("n", "p");
713        let transport = AssessmentTransport::new(coord);
714        let report = make_report();
715        let full = transport.build_gossip_frame(&report).unwrap();
716        let payload = AssessmentTransport::try_extract_payload(&full)
717            .unwrap()
718            .expect("full encode should extract");
719        assert!(!payload.is_empty());
720
721        // type-prefix only form
722        let len = u32::from_be_bytes([full[0], full[1], full[2], full[3]]) as usize;
723        let type_prefixed = &full[4..4 + len];
724        let payload2 = AssessmentTransport::try_extract_payload(type_prefixed)
725            .unwrap()
726            .expect("type-prefix should extract");
727        assert_eq!(payload, payload2);
728
729        // JSON IPC must not be mistaken for assessment frames
730        assert!(
731            AssessmentTransport::try_extract_payload(br#"{"source_node":"x"}"#)
732                .unwrap()
733                .is_none()
734        );
735    }
736
737    #[test]
738    fn queue_mesh_propagation_prefers_finding_diff() {
739        use crate::assessment::AssessmentService;
740        use std::io::Write;
741
742        let coord = make_coordinator("node-a", "proj-a");
743        let svc = AssessmentService::with_mesh_coordinator(coord.clone());
744
745        let mut prev = make_report();
746        prev.findings = vec![make_finding("a.rs", "old")];
747        let dir = tempfile::tempdir().unwrap();
748        let prev_path = dir.path().join("prev.json");
749        let mut f = std::fs::File::create(&prev_path).unwrap();
750        f.write_all(serde_json::to_string(&prev).unwrap().as_bytes())
751            .unwrap();
752        svc.set_previous_report_path(prev_path);
753
754        let mut curr = make_report();
755        curr.findings = vec![
756            make_finding("a.rs", "old"),
757            make_finding("b.rs", "new"),
758        ];
759        svc.queue_mesh_propagation(&curr);
760
761        let pending = coord.take_pending_broadcast().expect("pending set");
762        match pending {
763            AssessmentMessage::FindingDiff {
764                findings_new,
765                findings_resolved,
766                ..
767            } => {
768                assert_eq!(findings_new.len(), 1);
769                assert!(findings_resolved.is_empty());
770            }
771            other => panic!("expected FindingDiff, got {other:?}"),
772        }
773    }
774
775    // ── TCP integration test ─────────────────────────────────────
776
777    #[tokio::test]
778    async fn tcp_assessment_sync_between_two_nodes() {
779        use crate::mesh::MeshTransport;
780        use crate::mesh_tcp::TcpTransport;
781
782        let transport_tcp = TcpTransport;
783        let mut listener = transport_tcp.listen("127.0.0.1:0").await.unwrap();
784        let addr = listener.local_addr().unwrap();
785
786        let coord_a = make_coordinator("node-a", "proj-a");
787        let assess_a = AssessmentTransport::new(coord_a);
788        let report_a = make_report();
789
790        let coord_b = make_coordinator("node-b", "proj-b");
791
792        // Node A connects and sends a gossip frame.
793        let gossip_bytes = assess_a.build_gossip_frame(&report_a).unwrap();
794        let node_a_task = tokio::spawn(async move {
795            let mut stream = TcpTransport.connect(&addr.to_string()).await.unwrap();
796            // The gossip_bytes include the 4-byte length prefix from
797            // MeshFrame::encode. The TCP transport adds its own length
798            // prefix, so we send the full encoded frame.
799            stream.send(&gossip_bytes).await.unwrap();
800            stream.close().await.unwrap();
801        });
802
803        // Node B accepts and reads the gossip.
804        let (mut stream, _) = listener.accept().await.unwrap();
805        let data = stream.recv().await.unwrap();
806
807        // Parse the MeshFrame from the received data.
808        let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
809        let frame = MeshFrame::decode(&data[4..4 + len]).unwrap();
810        assert_eq!(frame.frame_type, FrameType::AssessmentSync);
811
812        // Feed into node B's transport.
813        let assess_b = AssessmentTransport::new(coord_b.clone());
814        let resp = assess_b.handle_incoming(&frame.payload).unwrap();
815        assert!(resp.is_none());
816
817        // Verify node B learned about node A.
818        let peers = coord_b.peer_states();
819        assert_eq!(peers.len(), 1);
820        assert_eq!(peers[0].node_id, "node-a");
821
822        node_a_task.await.unwrap();
823    }
824}