Skip to main content

rust_supervisor/dashboard/
protocol.rs

1//! Target-side dashboard IPC protocol.
2//!
3//! The relay and target process exchange newline-delimited JSON objects. This
4//! module keeps the accepted methods explicit and rejects legacy aliases.
5
6use crate::dashboard::error::DashboardError;
7use crate::dashboard::model::{
8    ControlCommandRequest, ControlCommandResult, DashboardState, EventRecord, LogRecord,
9    TargetProcessRegistration,
10};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14/// Wire protocol version used by the dashboard IPC contract.
15pub const DASHBOARD_IPC_PROTOCOL_VERSION: &str = "dashboard-ipc.v1";
16
17/// IPC request accepted by the target process.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct IpcRequest {
20    /// Caller-provided request identifier.
21    pub request_id: String,
22    /// Method name as it appeared on the wire.
23    pub method: String,
24    /// Method parameters.
25    #[serde(default)]
26    pub params: Value,
27}
28
29/// Typed IPC method accepted by the target process.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum IpcMethod {
32    /// Protocol handshake.
33    Hello,
34    /// Full dashboard state request.
35    CurrentState,
36    /// Event subscription request.
37    EventsSubscribe,
38    /// Log tail subscription request.
39    LogsTail,
40    /// Restart child command.
41    CommandRestartChild,
42    /// Pause child command.
43    CommandPauseChild,
44    /// Resume child command.
45    CommandResumeChild,
46    /// Quarantine child command.
47    CommandQuarantineChild,
48    /// Remove child command.
49    CommandRemoveChild,
50    /// Add child command.
51    CommandAddChild,
52    /// Shutdown tree command.
53    CommandShutdownTree,
54}
55
56impl IpcMethod {
57    /// Parses a wire method and rejects unsupported aliases.
58    ///
59    /// # Arguments
60    ///
61    /// - `method`: Method name from the request.
62    ///
63    /// # Returns
64    ///
65    /// Returns a typed method or an unsupported-method error.
66    pub fn parse(method: &str) -> Result<Self, DashboardError> {
67        match method {
68            "hello" => Ok(Self::Hello),
69            "state" => Ok(Self::CurrentState),
70            "events.subscribe" => Ok(Self::EventsSubscribe),
71            "logs.tail" => Ok(Self::LogsTail),
72            "command.restart_child" => Ok(Self::CommandRestartChild),
73            "command.pause_child" => Ok(Self::CommandPauseChild),
74            "command.resume_child" => Ok(Self::CommandResumeChild),
75            "command.quarantine_child" => Ok(Self::CommandQuarantineChild),
76            "command.remove_child" => Ok(Self::CommandRemoveChild),
77            "command.add_child" => Ok(Self::CommandAddChild),
78            "command.shutdown_tree" => Ok(Self::CommandShutdownTree),
79            _ => Err(DashboardError::unsupported_method(method)),
80        }
81    }
82
83    /// Returns the canonical wire method name.
84    ///
85    /// # Arguments
86    ///
87    /// This function has no arguments.
88    ///
89    /// # Returns
90    ///
91    /// Returns the canonical method name.
92    pub fn as_str(&self) -> &'static str {
93        match self {
94            Self::Hello => "hello",
95            Self::CurrentState => "state",
96            Self::EventsSubscribe => "events.subscribe",
97            Self::LogsTail => "logs.tail",
98            Self::CommandRestartChild => "command.restart_child",
99            Self::CommandPauseChild => "command.pause_child",
100            Self::CommandResumeChild => "command.resume_child",
101            Self::CommandQuarantineChild => "command.quarantine_child",
102            Self::CommandRemoveChild => "command.remove_child",
103            Self::CommandAddChild => "command.add_child",
104            Self::CommandShutdownTree => "command.shutdown_tree",
105        }
106    }
107}
108
109/// Successful IPC result payload.
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111#[serde(tag = "type", rename_all = "snake_case")]
112pub enum IpcResult {
113    /// Handshake result.
114    Hello {
115        /// Protocol version.
116        protocol_version: String,
117        /// Registration payload advertised by the target.
118        registration: TargetProcessRegistration,
119    },
120    /// Full target dashboard state.
121    State {
122        /// Target process identifier.
123        target_id: String,
124        /// Dashboard state payload.
125        state: Box<DashboardState>,
126    },
127    /// Subscription acceptance.
128    Subscription {
129        /// Target process identifier.
130        target_id: String,
131        /// Subscription kind.
132        subscription: String,
133    },
134    /// Control command result.
135    CommandResult {
136        /// Target process identifier.
137        target_id: String,
138        /// Command result.
139        result: ControlCommandResult,
140    },
141}
142
143/// IPC response sent by the target process.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub struct IpcResponse {
146    /// Request identifier copied from the request.
147    pub request_id: String,
148    /// Whether the request succeeded.
149    pub ok: bool,
150    /// Optional successful result.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub result: Option<IpcResult>,
153    /// Optional structured error.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub error: Option<DashboardError>,
156}
157
158impl IpcResponse {
159    /// Creates a successful IPC response.
160    ///
161    /// # Arguments
162    ///
163    /// - `request_id`: Request identifier copied from the request.
164    /// - `result`: Successful result payload.
165    ///
166    /// # Returns
167    ///
168    /// Returns an [`IpcResponse`] with `ok=true`.
169    pub fn ok(request_id: impl Into<String>, result: IpcResult) -> Self {
170        Self {
171            request_id: request_id.into(),
172            ok: true,
173            result: Some(result),
174            error: None,
175        }
176    }
177
178    /// Creates an error IPC response.
179    ///
180    /// # Arguments
181    ///
182    /// - `request_id`: Request identifier copied from the request.
183    /// - `error`: Structured error payload.
184    ///
185    /// # Returns
186    ///
187    /// Returns an [`IpcResponse`] with `ok=false`.
188    pub fn error(request_id: impl Into<String>, error: DashboardError) -> Self {
189        Self {
190            request_id: request_id.into(),
191            ok: false,
192            result: None,
193            error: Some(error),
194        }
195    }
196}
197
198/// Server push message sent after a subscription is established.
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200#[serde(tag = "type", rename_all = "snake_case")]
201pub enum IpcServerPush {
202    /// Event push.
203    Event {
204        /// Target process identifier.
205        target_id: String,
206        /// Event record.
207        event: EventRecord,
208    },
209    /// Log push.
210    Log {
211        /// Target process identifier.
212        target_id: String,
213        /// Log record.
214        log: LogRecord,
215    },
216    /// State delta push.
217    StateDelta {
218        /// Target process identifier.
219        target_id: String,
220        /// State delta payload.
221        delta: Value,
222    },
223    /// Error push.
224    Error {
225        /// Structured error.
226        error: DashboardError,
227    },
228}
229
230/// Parses one newline-delimited JSON request line.
231///
232/// # Arguments
233///
234/// - `line`: One full JSON object line.
235///
236/// # Returns
237///
238/// Returns a typed request or a structured parser error.
239pub fn parse_request_line(line: &str) -> Result<IpcRequest, DashboardError> {
240    let request: IpcRequest = serde_json::from_str(line).map_err(|error| {
241        DashboardError::new(
242            "invalid_json",
243            "protocol_parse",
244            None,
245            format!("failed to parse IPC JSON request: {error}"),
246            false,
247        )
248    })?;
249    IpcMethod::parse(&request.method)?;
250    Ok(request)
251}
252
253/// Serializes a response as one newline-delimited JSON line.
254///
255/// # Arguments
256///
257/// - `response`: Response that should be serialized.
258///
259/// # Returns
260///
261/// Returns one JSON line ending with `\n`.
262pub fn response_to_line(response: &IpcResponse) -> Result<String, DashboardError> {
263    let mut line = serde_json::to_string(response).map_err(|error| {
264        DashboardError::new(
265            "serialization_failed",
266            "protocol_write",
267            response
268                .error
269                .as_ref()
270                .and_then(|error| error.target_id.clone()),
271            format!("failed to serialize IPC response: {error}"),
272            false,
273        )
274    })?;
275    line.push('\n');
276    Ok(line)
277}
278
279/// Decodes command parameters from an IPC request.
280///
281/// # Arguments
282///
283/// - `request`: Request carrying command parameters.
284///
285/// # Returns
286///
287/// Returns a typed command request.
288pub fn decode_command_params(
289    request: &IpcRequest,
290) -> Result<ControlCommandRequest, DashboardError> {
291    serde_json::from_value(request.params.clone()).map_err(|error| {
292        DashboardError::new(
293            "invalid_command_params",
294            "protocol_parse",
295            None,
296            format!("failed to parse command params: {error}"),
297            false,
298        )
299    })
300}