Skip to main content

clawft_kernel/
ipc.rs

1//! Kernel IPC subsystem.
2//!
3//! [`KernelIpc`] wraps the existing [`MessageBus`] from `clawft-core`,
4//! adding typed [`KernelMessage`] envelopes and PID-based routing.
5//! The underlying message bus channels are reused (no new channels).
6
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use tracing::debug;
13
14use clawft_core::bus::MessageBus;
15
16use crate::error::KernelError;
17use crate::process::Pid;
18
19/// Maximum serialized size of a single kernel IPC message in bytes (16 MiB).
20///
21/// Frames larger than this are rejected by [`KernelIpc::send`] with
22/// [`KernelError::MessageTooLarge`] before the payload is published on
23/// the bus. This prevents a single misbehaving sender (or a corrupt
24/// inbound frame) from exhausting kernel memory or stalling the bus.
25pub const KERNEL_IPC_MAX_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
26
27/// Global atomic counter for generating internal IPC message IDs.
28///
29/// Using an atomic counter instead of `uuid::Uuid::new_v4()` eliminates
30/// the crypto-random generation overhead (~50-100ns per message) in hot
31/// IPC paths. The counter is monotonically increasing and unique within
32/// a single process lifetime, which is sufficient for internal correlation.
33static IPC_MSG_COUNTER: AtomicU64 = AtomicU64::new(1);
34
35/// Generate a fast, unique internal message ID using an atomic counter.
36///
37/// Format: `"ipc-{counter}"` -- lightweight string, no crypto-random overhead.
38#[inline]
39fn next_ipc_msg_id() -> String {
40    let id = IPC_MSG_COUNTER.fetch_add(1, Ordering::Relaxed);
41    format!("ipc-{id}")
42}
43
44/// Target for a kernel message.
45#[non_exhaustive]
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub enum MessageTarget {
48    /// Send to a specific process by PID.
49    Process(Pid),
50    /// Publish to a named topic (all subscribers receive).
51    Topic(String),
52    /// Broadcast to all processes.
53    Broadcast,
54    /// Send to a named service (routed via ServiceRegistry).
55    Service(String),
56    /// Send to a specific method on a named service (D19, K2.1).
57    ///
58    /// The router resolves the service via ServiceRegistry and wraps
59    /// the payload with method metadata for the receiving agent.
60    ServiceMethod {
61        /// Service name to resolve.
62        service: String,
63        /// Method to invoke on the service.
64        method: String,
65    },
66    /// Send to the kernel itself.
67    Kernel,
68    /// Route to a specific process on a remote node (K6).
69    /// The inner target is resolved on the destination node.
70    RemoteNode {
71        /// Remote node identifier.
72        node_id: String,
73        /// Target to resolve on the remote node.
74        target: Box<MessageTarget>,
75    },
76}
77
78/// Payload types for kernel messages.
79#[non_exhaustive]
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub enum MessagePayload {
82    /// Plain text message.
83    Text(String),
84    /// Structured JSON data.
85    Json(serde_json::Value),
86    /// Tool call delegation from one agent to another.
87    ToolCall {
88        /// Name of the tool to call.
89        name: String,
90        /// Tool arguments.
91        args: serde_json::Value,
92    },
93    /// Result of a delegated tool call.
94    ToolResult {
95        /// Correlation ID linking to the original request.
96        call_id: String,
97        /// Tool execution result.
98        result: serde_json::Value,
99    },
100    /// System control signal.
101    Signal(KernelSignal),
102    /// Raw binary payload.
103    ///
104    /// For mesh transport, sensor data, file transfer, or any opaque
105    /// byte stream that doesn't need JSON/text interpretation.
106    Binary(Vec<u8>),
107    /// RVF-typed payload with segment type hint.
108    ///
109    /// Agents can exchange RVF-typed messages. The segment type tells
110    /// the receiver what format the data is in (using rvf-types
111    /// discriminants, e.g. 0x40 = ExochainEvent).
112    Rvf {
113        /// RVF segment type discriminant.
114        segment_type: u8,
115        /// Payload data (CBOR, JSON, or raw bytes).
116        data: Vec<u8>,
117    },
118}
119
120impl MessagePayload {
121    /// Return the payload type name (for logging/chain events).
122    pub fn type_name(&self) -> &'static str {
123        match self {
124            MessagePayload::Text(_) => "text",
125            MessagePayload::Json(_) => "json",
126            MessagePayload::ToolCall { .. } => "tool_call",
127            MessagePayload::ToolResult { .. } => "tool_result",
128            MessagePayload::Signal(_) => "signal",
129            MessagePayload::Binary(_) => "binary",
130            MessagePayload::Rvf { .. } => "rvf",
131        }
132    }
133}
134
135/// Reason a process exited (used in link/monitor notifications).
136#[non_exhaustive]
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub enum ExitReason {
139    /// Normal exit (process completed successfully).
140    Normal,
141    /// Process crashed with an error.
142    Crash(String),
143    /// Process was killed.
144    Killed,
145    /// Process timed out.
146    Timeout,
147}
148
149/// Notification that a monitored process went down.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ProcessDown {
152    /// PID of the process that went down.
153    pub pid: crate::process::Pid,
154    /// Why the process exited.
155    pub reason: ExitReason,
156}
157
158/// Kernel control signals.
159#[non_exhaustive]
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub enum KernelSignal {
162    /// Request a process to shut down gracefully.
163    Shutdown,
164    /// Request a process to suspend.
165    Suspend,
166    /// Request a process to resume from suspension.
167    Resume,
168    /// Heartbeat / keep-alive ping.
169    Ping,
170    /// Response to a heartbeat ping.
171    Pong,
172    /// Reload configuration (K2-G5).
173    ReloadConfig,
174    /// Dump internal state for debugging (K2-G5).
175    DumpState,
176    /// User-defined signal with a discriminant (K2-G5).
177    UserDefined(u8),
178    /// Immediate kill -- no cleanup, no graceful shutdown (K2-G5).
179    Kill,
180    /// Crash notification from a linked process (K1-G2).
181    LinkExit {
182        /// PID of the linked process that exited.
183        pid: crate::process::Pid,
184        /// Reason the process exited.
185        reason: ExitReason,
186    },
187    /// Monitor DOWN notification (K1-G2).
188    MonitorDown(ProcessDown),
189    /// Resource usage warning at 80% of limit (K1-G3).
190    ResourceWarning {
191        /// Name of the resource (e.g. "memory", "cpu_time").
192        resource: String,
193        /// Current usage value.
194        current: u64,
195        /// Configured limit.
196        limit: u64,
197    },
198}
199
200/// A typed message envelope for kernel IPC.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct KernelMessage {
203    /// Unique message identifier.
204    pub id: String,
205    /// Sender PID (0 = kernel).
206    pub from: Pid,
207    /// Target for delivery.
208    pub target: MessageTarget,
209    /// Message payload.
210    pub payload: MessagePayload,
211    /// Creation timestamp.
212    pub timestamp: DateTime<Utc>,
213    /// Optional correlation ID for request-response patterns.
214    ///
215    /// When set, this links a response message back to the original
216    /// request that triggered it. Used by the A2A protocol's
217    /// request-response tracking.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub correlation_id: Option<String>,
220    /// Distributed trace ID for end-to-end request tracing (K2-G4).
221    ///
222    /// External messages entering the kernel receive a new UUID v4
223    /// trace_id. Internal messages inherit the parent's trace_id
224    /// via correlation linkage.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub trace_id: Option<String>,
227}
228
229impl KernelMessage {
230    /// Create a new kernel message.
231    ///
232    /// Uses an atomic counter for the message ID instead of UUID v4,
233    /// eliminating crypto-random generation overhead in hot IPC paths.
234    pub fn new(from: Pid, target: MessageTarget, payload: MessagePayload) -> Self {
235        Self {
236            id: next_ipc_msg_id(),
237            from,
238            target,
239            payload,
240            timestamp: Utc::now(),
241            correlation_id: None,
242            trace_id: None,
243        }
244    }
245
246    /// Create a new kernel message with a correlation ID.
247    pub fn with_correlation(
248        from: Pid,
249        target: MessageTarget,
250        payload: MessagePayload,
251        correlation_id: String,
252    ) -> Self {
253        Self {
254            id: next_ipc_msg_id(),
255            from,
256            target,
257            payload,
258            timestamp: Utc::now(),
259            correlation_id: Some(correlation_id),
260            trace_id: None,
261        }
262    }
263
264    /// Create a new kernel message with a trace ID (for external entry points).
265    pub fn with_trace(
266        from: Pid,
267        target: MessageTarget,
268        payload: MessagePayload,
269        trace_id: String,
270    ) -> Self {
271        Self {
272            id: next_ipc_msg_id(),
273            from,
274            target,
275            payload,
276            timestamp: Utc::now(),
277            correlation_id: None,
278            trace_id: Some(trace_id),
279        }
280    }
281
282    /// Set the trace ID on this message (builder pattern).
283    pub fn set_trace_id(mut self, trace_id: impl Into<String>) -> Self {
284        self.trace_id = Some(trace_id.into());
285        self
286    }
287
288    /// Ensure this message has a trace ID, generating one if missing.
289    pub fn ensure_trace_id(mut self) -> Self {
290        if self.trace_id.is_none() {
291            self.trace_id = Some(uuid::Uuid::new_v4().to_string());
292        }
293        self
294    }
295
296    /// Create a text message.
297    pub fn text(from: Pid, target: MessageTarget, text: impl Into<String>) -> Self {
298        Self::new(from, target, MessagePayload::Text(text.into()))
299    }
300
301    /// Create a signal message.
302    pub fn signal(from: Pid, target: MessageTarget, signal: KernelSignal) -> Self {
303        Self::new(from, target, MessagePayload::Signal(signal))
304    }
305
306    /// Create a tool call message.
307    pub fn tool_call(
308        from: Pid,
309        target: MessageTarget,
310        name: impl Into<String>,
311        args: serde_json::Value,
312    ) -> Self {
313        Self::new(
314            from,
315            target,
316            MessagePayload::ToolCall {
317                name: name.into(),
318                args,
319            },
320        )
321    }
322
323    /// Create a tool result message (response to a tool call).
324    pub fn tool_result(
325        from: Pid,
326        target: MessageTarget,
327        call_id: impl Into<String>,
328        result: serde_json::Value,
329    ) -> Self {
330        Self::new(
331            from,
332            target,
333            MessagePayload::ToolResult {
334                call_id: call_id.into(),
335                result,
336            },
337        )
338    }
339}
340
341/// Mesh / cluster node identifier (string DID or stable node name).
342///
343/// This is the **NodeId** half of the composite process identity required
344/// for cross-node uniqueness (WEFT-142). Paired with a local [`Pid`] it
345/// forms a [`GlobalPid`].
346pub type MeshNodeId = String;
347
348/// Globally unique process identifier: (`node_id`, local `pid`).
349///
350/// Used for cross-node process addressing in K6 mesh networking.
351///
352/// ## WEFT-142
353///
354/// Audit gap "Pid: u64 has no node component" is closed by this type:
355/// - Composite identity at IPC boundaries (`GlobalPid` / `MessageTarget::RemoteNode`)
356/// - Remote inbox bridge: [`crate::mesh_runtime::MeshRuntime::handle_incoming`]
357///   unwraps `RemoteNode` and injects into the local A2A router
358///   (`deliver_to_inbox`)
359///
360/// Prefer `GlobalPid` (or the [`CompositePid`] alias) over bare [`Pid`]
361/// whenever a message may leave the local process table.
362#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
363pub struct GlobalPid {
364    /// Node that owns this process ([`MeshNodeId`]).
365    pub node_id: MeshNodeId,
366    /// Local PID on that node.
367    pub pid: Pid,
368}
369
370/// Alias emphasising the `(NodeId, Pid)` composite (WEFT-142).
371pub type CompositePid = GlobalPid;
372
373impl GlobalPid {
374    /// Create a GlobalPid for a local process.
375    pub fn local(pid: Pid, node_id: &str) -> Self {
376        Self {
377            node_id: node_id.to_string(),
378            pid,
379        }
380    }
381
382    /// Construct from explicit mesh node id + local pid.
383    pub fn new(node_id: impl Into<MeshNodeId>, pid: Pid) -> Self {
384        Self {
385            node_id: node_id.into(),
386            pid,
387        }
388    }
389
390    /// Check if this PID belongs to the given node.
391    pub fn is_local(&self, my_node_id: &str) -> bool {
392        self.node_id == my_node_id
393    }
394
395    /// Build a [`MessageTarget::RemoteNode`] that addresses this process.
396    pub fn as_remote_target(&self) -> MessageTarget {
397        MessageTarget::RemoteNode {
398            node_id: self.node_id.clone(),
399            target: Box::new(MessageTarget::Process(self.pid)),
400        }
401    }
402}
403
404impl std::fmt::Display for GlobalPid {
405    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406        write!(f, "{}:{}", self.node_id, self.pid)
407    }
408}
409
410/// Kernel IPC subsystem wrapping the core MessageBus.
411///
412/// Adds kernel-level message envelope (type, routing, timestamps)
413/// on top of the existing broadcast channel infrastructure.
414pub struct KernelIpc {
415    bus: Arc<MessageBus>,
416}
417
418impl KernelIpc {
419    /// Create a new KernelIpc wrapping the given MessageBus.
420    pub fn new(bus: Arc<MessageBus>) -> Self {
421        Self { bus }
422    }
423
424    /// Get a reference to the underlying MessageBus.
425    pub fn bus(&self) -> &Arc<MessageBus> {
426        &self.bus
427    }
428
429    /// Send a kernel message with RBAC enforcement and chain logging.
430    ///
431    /// 1. If the target is `Process(to_pid)`, checks IPC capability
432    ///    via the `CapabilityChecker`.
433    /// 2. Logs the send event to the chain (if provided).
434    /// 3. Publishes via the bus.
435    #[cfg(feature = "exochain")]
436    pub fn send_checked(
437        &self,
438        msg: &KernelMessage,
439        checker: &crate::capability::CapabilityChecker,
440        chain: Option<&crate::chain::ChainManager>,
441    ) -> Result<(), KernelError> {
442        // 1. Check IPC capability
443        if let MessageTarget::Process(to_pid) = &msg.target {
444            checker.check_ipc_target(msg.from, *to_pid)?;
445        }
446
447        // 2. Log to chain
448        if let Some(cm) = chain {
449            cm.append(
450                "ipc",
451                "ipc.send",
452                Some(serde_json::json!({
453                    "from": msg.from,
454                    "target": format!("{:?}", msg.target),
455                    "payload_type": msg.payload.type_name(),
456                    "msg_id": msg.id,
457                })),
458            );
459        }
460
461        // 3. Send via bus
462        self.send(msg)
463    }
464
465    /// Send a kernel message.
466    ///
467    /// Currently serializes the message to JSON and publishes it
468    /// as an inbound message on the bus. Future versions (K2) will
469    /// implement PID-based routing and topic subscriptions.
470    ///
471    /// Frames whose serialized size exceeds
472    /// [`KERNEL_IPC_MAX_MESSAGE_BYTES`] (16 MiB) are rejected with
473    /// [`KernelError::MessageTooLarge`] before any publish occurs.
474    /// This bounds peak kernel memory under hostile or misbehaving
475    /// senders (WEFT-143).
476    pub fn send(&self, msg: &KernelMessage) -> Result<(), KernelError> {
477        debug!(
478            id = %msg.id,
479            from = msg.from,
480            "sending kernel message"
481        );
482
483        let json = serde_json::to_string(msg)
484            .map_err(|e| KernelError::Ipc(format!("failed to serialize message: {e}")))?;
485
486        // Enforce the 16 MiB cap on serialized message size before
487        // touching the bus. The cap is applied to the canonical
488        // JSON payload because that is what we publish downstream.
489        if json.len() > KERNEL_IPC_MAX_MESSAGE_BYTES {
490            return Err(KernelError::MessageTooLarge {
491                size: json.len(),
492                limit: KERNEL_IPC_MAX_MESSAGE_BYTES,
493            });
494        }
495
496        // For now, publish as an inbound message. The A2A routing (K2)
497        // will replace this with proper PID-based delivery.
498        let inbound = clawft_types::event::InboundMessage {
499            channel: "kernel-ipc".to_owned(),
500            sender_id: format!("pid-{}", msg.from),
501            chat_id: msg.id.clone(),
502            content: json,
503            timestamp: msg.timestamp,
504            media: vec![],
505            metadata: std::collections::HashMap::new(),
506        };
507
508        self.bus
509            .publish_inbound(inbound)
510            .map_err(|e| KernelError::Ipc(format!("bus publish failed: {e}")))
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn kernel_message_text() {
520        let msg = KernelMessage::text(0, MessageTarget::Process(1), "hello");
521        assert_eq!(msg.from, 0);
522        assert!(matches!(msg.target, MessageTarget::Process(1)));
523        assert!(matches!(msg.payload, MessagePayload::Text(ref t) if t == "hello"));
524    }
525
526    #[test]
527    fn kernel_message_signal() {
528        let msg = KernelMessage::signal(0, MessageTarget::Broadcast, KernelSignal::Shutdown);
529        assert!(matches!(msg.target, MessageTarget::Broadcast));
530        assert!(matches!(
531            msg.payload,
532            MessagePayload::Signal(KernelSignal::Shutdown)
533        ));
534    }
535
536    #[test]
537    fn kernel_message_json_payload() {
538        let payload = MessagePayload::Json(serde_json::json!({"key": "value"}));
539        let msg = KernelMessage::new(1, MessageTarget::Kernel, payload);
540        assert!(matches!(msg.payload, MessagePayload::Json(_)));
541    }
542
543    #[test]
544    fn message_serde_roundtrip() {
545        let msg = KernelMessage::text(5, MessageTarget::Service("health".into()), "check");
546        let json = serde_json::to_string(&msg).unwrap();
547        let restored: KernelMessage = serde_json::from_str(&json).unwrap();
548        assert_eq!(restored.id, msg.id);
549        assert_eq!(restored.from, 5);
550    }
551
552    #[tokio::test]
553    async fn ipc_send() {
554        let bus = Arc::new(MessageBus::new());
555        let ipc = KernelIpc::new(bus.clone());
556
557        let msg = KernelMessage::text(0, MessageTarget::Process(1), "test");
558        ipc.send(&msg).unwrap();
559
560        // Should be consumable from the bus
561        let received = bus.consume_inbound().await.unwrap();
562        assert_eq!(received.channel, "kernel-ipc");
563        assert_eq!(received.sender_id, "pid-0");
564    }
565
566    #[test]
567    fn ipc_bus_ref() {
568        let bus = Arc::new(MessageBus::new());
569        let ipc = KernelIpc::new(bus.clone());
570        assert!(Arc::ptr_eq(ipc.bus(), &bus));
571    }
572
573    #[test]
574    fn ipc_rejects_oversize_message() {
575        // WEFT-143: a frame whose serialized size exceeds 16 MiB must
576        // be rejected with `KernelError::MessageTooLarge` and must
577        // not be published on the bus.
578        let bus = Arc::new(MessageBus::new());
579        let ipc = KernelIpc::new(bus.clone());
580
581        // Construct a Binary payload large enough that even after
582        // JSON encoding (which slightly inflates Vec<u8> -> array of
583        // numbers) the serialized envelope clears the 16 MiB cap by
584        // a wide margin.
585        let huge = vec![0u8; KERNEL_IPC_MAX_MESSAGE_BYTES + 1];
586        let msg = KernelMessage::new(0, MessageTarget::Process(1), MessagePayload::Binary(huge));
587
588        let err = ipc.send(&msg).unwrap_err();
589        match err {
590            KernelError::MessageTooLarge { size, limit } => {
591                assert_eq!(limit, KERNEL_IPC_MAX_MESSAGE_BYTES);
592                assert!(
593                    size > KERNEL_IPC_MAX_MESSAGE_BYTES,
594                    "expected size > limit, got size={size} limit={limit}"
595                );
596            }
597            other => panic!("expected MessageTooLarge, got {other:?}"),
598        }
599    }
600
601    #[tokio::test]
602    async fn ipc_accepts_message_at_limit_boundary() {
603        // A small Binary payload must round-trip cleanly to confirm
604        // the cap does not regress the happy path.
605        let bus = Arc::new(MessageBus::new());
606        let ipc = KernelIpc::new(bus.clone());
607
608        let msg = KernelMessage::new(
609            0,
610            MessageTarget::Process(1),
611            MessagePayload::Binary(vec![0u8; 1024]),
612        );
613
614        ipc.send(&msg).unwrap();
615        let received = bus.consume_inbound().await.unwrap();
616        assert_eq!(received.channel, "kernel-ipc");
617    }
618
619    #[test]
620    fn message_target_variants() {
621        let targets = vec![
622            MessageTarget::Process(1),
623            MessageTarget::Broadcast,
624            MessageTarget::Service("test".into()),
625            MessageTarget::ServiceMethod {
626                service: "auth".into(),
627                method: "validate_token".into(),
628            },
629            MessageTarget::Kernel,
630        ];
631        for target in targets {
632            let json = serde_json::to_string(&target).unwrap();
633            let _: MessageTarget = serde_json::from_str(&json).unwrap();
634        }
635    }
636
637    #[test]
638    fn kernel_signal_variants() {
639        let signals = vec![
640            KernelSignal::Shutdown,
641            KernelSignal::Suspend,
642            KernelSignal::Resume,
643            KernelSignal::Ping,
644            KernelSignal::Pong,
645        ];
646        for signal in signals {
647            let json = serde_json::to_string(&signal).unwrap();
648            let _: KernelSignal = serde_json::from_str(&json).unwrap();
649        }
650    }
651
652    #[test]
653    fn expanded_signal_variants_serde() {
654        let signals = vec![
655            KernelSignal::ReloadConfig,
656            KernelSignal::DumpState,
657            KernelSignal::UserDefined(42),
658            KernelSignal::Kill,
659            KernelSignal::LinkExit {
660                pid: 7,
661                reason: ExitReason::Crash("boom".into()),
662            },
663            KernelSignal::MonitorDown(ProcessDown {
664                pid: 9,
665                reason: ExitReason::Normal,
666            }),
667            KernelSignal::ResourceWarning {
668                resource: "memory".into(),
669                current: 800,
670                limit: 1000,
671            },
672        ];
673        for signal in signals {
674            let json = serde_json::to_string(&signal).unwrap();
675            let restored: KernelSignal = serde_json::from_str(&json).unwrap();
676            // Verify roundtrip by re-serializing
677            let json2 = serde_json::to_string(&restored).unwrap();
678            assert_eq!(json, json2);
679        }
680    }
681
682    #[test]
683    fn exit_reason_variants() {
684        let reasons = vec![
685            ExitReason::Normal,
686            ExitReason::Crash("error".into()),
687            ExitReason::Killed,
688            ExitReason::Timeout,
689        ];
690        for reason in reasons {
691            let json = serde_json::to_string(&reason).unwrap();
692            let _: ExitReason = serde_json::from_str(&json).unwrap();
693        }
694    }
695
696    #[test]
697    fn process_down_serde() {
698        let pd = ProcessDown {
699            pid: 42,
700            reason: ExitReason::Crash("segfault".into()),
701        };
702        let json = serde_json::to_string(&pd).unwrap();
703        let restored: ProcessDown = serde_json::from_str(&json).unwrap();
704        assert_eq!(restored.pid, 42);
705        assert!(matches!(restored.reason, ExitReason::Crash(ref s) if s == "segfault"));
706    }
707
708    #[test]
709    fn message_with_correlation_id() {
710        let msg = KernelMessage::with_correlation(
711            1,
712            MessageTarget::Process(2),
713            MessagePayload::Text("request".into()),
714            "req-123".into(),
715        );
716        assert_eq!(msg.correlation_id, Some("req-123".into()));
717        assert_eq!(msg.from, 1);
718    }
719
720    #[test]
721    fn message_without_correlation_id() {
722        let msg = KernelMessage::text(1, MessageTarget::Process(2), "hello");
723        assert!(msg.correlation_id.is_none());
724    }
725
726    #[test]
727    fn tool_call_message() {
728        let msg = KernelMessage::tool_call(
729            1,
730            MessageTarget::Process(2),
731            "read_file",
732            serde_json::json!({"path": "/src/main.rs"}),
733        );
734        match &msg.payload {
735            MessagePayload::ToolCall { name, args } => {
736                assert_eq!(name, "read_file");
737                assert_eq!(args["path"], "/src/main.rs");
738            }
739            other => panic!("expected ToolCall, got: {other:?}"),
740        }
741    }
742
743    #[test]
744    fn tool_result_message() {
745        let msg = KernelMessage::tool_result(
746            2,
747            MessageTarget::Process(1),
748            "call-123",
749            serde_json::json!({"content": "file contents"}),
750        );
751        match &msg.payload {
752            MessagePayload::ToolResult { call_id, result } => {
753                assert_eq!(call_id, "call-123");
754                assert_eq!(result["content"], "file contents");
755            }
756            other => panic!("expected ToolResult, got: {other:?}"),
757        }
758    }
759
760    #[test]
761    fn topic_target() {
762        let msg = KernelMessage::text(1, MessageTarget::Topic("build-status".into()), "done");
763        assert!(matches!(msg.target, MessageTarget::Topic(ref t) if t == "build-status"));
764    }
765
766    #[test]
767    fn tool_call_serde_roundtrip() {
768        let msg = KernelMessage::tool_call(
769            1,
770            MessageTarget::Process(2),
771            "search",
772            serde_json::json!({"query": "test"}),
773        );
774        let json = serde_json::to_string(&msg).unwrap();
775        let restored: KernelMessage = serde_json::from_str(&json).unwrap();
776        assert!(matches!(
777            restored.payload,
778            MessagePayload::ToolCall { ref name, .. } if name == "search"
779        ));
780    }
781
782    #[test]
783    fn correlation_id_serde_roundtrip() {
784        let msg = KernelMessage::with_correlation(
785            1,
786            MessageTarget::Process(2),
787            MessagePayload::Text("req".into()),
788            "corr-456".into(),
789        );
790        let json = serde_json::to_string(&msg).unwrap();
791        let restored: KernelMessage = serde_json::from_str(&json).unwrap();
792        assert_eq!(restored.correlation_id, Some("corr-456".into()));
793    }
794
795    #[test]
796    fn rvf_payload_variant() {
797        let payload = MessagePayload::Rvf {
798            segment_type: 0x40,
799            data: vec![0xCA, 0xFE],
800        };
801        let msg = KernelMessage::new(1, MessageTarget::Process(2), payload);
802        assert_eq!(msg.payload.type_name(), "rvf");
803
804        // serde roundtrip
805        let json = serde_json::to_string(&msg).unwrap();
806        let restored: KernelMessage = serde_json::from_str(&json).unwrap();
807        match &restored.payload {
808            MessagePayload::Rvf { segment_type, data } => {
809                assert_eq!(*segment_type, 0x40);
810                assert_eq!(data, &[0xCA, 0xFE]);
811            }
812            other => panic!("expected Rvf, got: {other:?}"),
813        }
814    }
815
816    #[test]
817    fn payload_type_names() {
818        assert_eq!(MessagePayload::Text("hi".into()).type_name(), "text");
819        assert_eq!(
820            MessagePayload::Json(serde_json::json!(1)).type_name(),
821            "json"
822        );
823        assert_eq!(
824            MessagePayload::Signal(KernelSignal::Ping).type_name(),
825            "signal"
826        );
827        assert_eq!(
828            MessagePayload::Binary(vec![0xDE, 0xAD]).type_name(),
829            "binary"
830        );
831    }
832
833    #[test]
834    fn binary_payload_serde_roundtrip() {
835        let payload = MessagePayload::Binary(vec![0x01, 0x02, 0x03, 0xFF]);
836        let json = serde_json::to_string(&payload).unwrap();
837        let restored: MessagePayload = serde_json::from_str(&json).unwrap();
838        match restored {
839            MessagePayload::Binary(data) => {
840                assert_eq!(data, vec![0x01, 0x02, 0x03, 0xFF]);
841            }
842            other => panic!("expected Binary, got {:?}", other),
843        }
844    }
845
846    #[test]
847    fn remote_node_serde_roundtrip() {
848        let target = MessageTarget::RemoteNode {
849            node_id: "node-42".into(),
850            target: Box::new(MessageTarget::Process(7)),
851        };
852        let json = serde_json::to_string(&target).unwrap();
853        let restored: MessageTarget = serde_json::from_str(&json).unwrap();
854        match restored {
855            MessageTarget::RemoteNode { node_id, target } => {
856                assert_eq!(node_id, "node-42");
857                assert!(matches!(*target, MessageTarget::Process(7)));
858            }
859            other => panic!("expected RemoteNode, got: {other:?}"),
860        }
861    }
862
863    #[test]
864    fn global_pid_equality() {
865        let a = GlobalPid::local(1, "node-a");
866        let b = GlobalPid::local(1, "node-b");
867        let c = GlobalPid::local(1, "node-a");
868        assert_ne!(a, b, "same pid on different nodes must not be equal");
869        assert_eq!(a, c, "same pid on same node must be equal");
870    }
871
872    #[test]
873    fn global_pid_is_local() {
874        let gpid = GlobalPid::local(5, "my-node");
875        assert!(gpid.is_local("my-node"));
876        assert!(!gpid.is_local("other-node"));
877    }
878
879    #[test]
880    fn global_pid_display() {
881        let gpid = GlobalPid::local(42, "alpha");
882        assert_eq!(gpid.to_string(), "alpha:42");
883    }
884
885    #[test]
886    fn global_pid_as_remote_target_and_composite_alias() {
887        let gpid = GlobalPid::new("node-x", 7);
888        let target = gpid.as_remote_target();
889        match target {
890            MessageTarget::RemoteNode { node_id, target } => {
891                assert_eq!(node_id, "node-x");
892                assert!(matches!(*target, MessageTarget::Process(7)));
893            }
894            other => panic!("expected RemoteNode, got {other:?}"),
895        }
896        let alias: CompositePid = gpid.clone();
897        assert_eq!(alias.pid, 7);
898        assert_eq!(alias.node_id, "node-x");
899    }
900
901    #[test]
902    fn correlation_id_absent_in_json_when_none() {
903        let msg = KernelMessage::text(1, MessageTarget::Process(2), "hello");
904        let json = serde_json::to_string(&msg).unwrap();
905        assert!(!json.contains("correlation_id"));
906    }
907
908    // ── K2-G4: Trace ID tests ──────────────────────────────────
909
910    #[test]
911    fn trace_id_absent_by_default() {
912        let msg = KernelMessage::text(1, MessageTarget::Process(2), "hello");
913        assert!(msg.trace_id.is_none());
914    }
915
916    #[test]
917    fn trace_id_with_trace() {
918        let msg = KernelMessage::with_trace(
919            1,
920            MessageTarget::Process(2),
921            MessagePayload::Text("traced".into()),
922            "trace-abc-123".into(),
923        );
924        assert_eq!(msg.trace_id, Some("trace-abc-123".into()));
925    }
926
927    #[test]
928    fn trace_id_set_builder() {
929        let msg =
930            KernelMessage::text(1, MessageTarget::Process(2), "hello").set_trace_id("my-trace");
931        assert_eq!(msg.trace_id, Some("my-trace".into()));
932    }
933
934    #[test]
935    fn trace_id_ensure_generates() {
936        let msg = KernelMessage::text(1, MessageTarget::Process(2), "hello").ensure_trace_id();
937        assert!(msg.trace_id.is_some());
938        assert!(!msg.trace_id.as_ref().unwrap().is_empty());
939    }
940
941    #[test]
942    fn trace_id_ensure_preserves_existing() {
943        let msg = KernelMessage::text(1, MessageTarget::Process(2), "hello")
944            .set_trace_id("existing-trace")
945            .ensure_trace_id();
946        assert_eq!(msg.trace_id, Some("existing-trace".into()));
947    }
948
949    #[test]
950    fn trace_id_serde_roundtrip() {
951        let msg = KernelMessage::with_trace(
952            1,
953            MessageTarget::Process(2),
954            MessagePayload::Text("traced".into()),
955            "trace-roundtrip".into(),
956        );
957        let json = serde_json::to_string(&msg).unwrap();
958        let restored: KernelMessage = serde_json::from_str(&json).unwrap();
959        assert_eq!(restored.trace_id, Some("trace-roundtrip".into()));
960    }
961
962    #[test]
963    fn trace_id_absent_in_json_when_none() {
964        let msg = KernelMessage::text(1, MessageTarget::Process(2), "hello");
965        let json = serde_json::to_string(&msg).unwrap();
966        assert!(!json.contains("trace_id"));
967    }
968
969    #[test]
970    fn trace_id_backward_compat_deserialization() {
971        // Simulate a message serialized without trace_id field
972        let json = r#"{"id":"test-id","from":1,"target":{"Process":2},"payload":{"Text":"hello"},"timestamp":"2024-01-01T00:00:00Z"}"#;
973        let msg: KernelMessage = serde_json::from_str(json).unwrap();
974        assert!(msg.trace_id.is_none());
975        assert!(msg.correlation_id.is_none());
976    }
977}