1use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use super::{RemoteExecutor, RemoteOutput};
10
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct RelayConfig {
14 pub relay_url: String,
16 pub auth_token: String,
18 pub channel_id: String,
20}
21
22impl RelayConfig {
23 pub fn default_relay(auth_token: String, channel_id: String) -> Self {
25 Self {
26 relay_url: "wss://relay.mur.run/v1/ws".to_string(),
27 auth_token,
28 channel_id,
29 }
30 }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35pub struct RelayCommand {
36 pub target_machine: String,
37 pub command: String,
38 pub working_dir: Option<String>,
39 pub timeout_secs: u64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
44pub struct RelayResponse {
45 pub machine_id: String,
46 pub stdout: String,
47 pub stderr: String,
48 pub exit_code: i32,
49 pub duration_ms: u64,
50}
51
52impl From<RelayResponse> for RemoteOutput {
53 fn from(resp: RelayResponse) -> Self {
54 RemoteOutput {
55 stdout: resp.stdout,
56 stderr: resp.stderr,
57 exit_code: resp.exit_code,
58 duration_ms: resp.duration_ms,
59 machine_id: resp.machine_id,
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct RelayExecutor {
67 config: RelayConfig,
68 default_timeout_secs: u64,
70}
71
72impl RelayExecutor {
73 pub fn new(config: RelayConfig) -> Self {
75 Self {
76 config,
77 default_timeout_secs: 300,
78 }
79 }
80
81 pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
83 self.default_timeout_secs = timeout_secs;
84 self
85 }
86
87 pub fn relay_url(&self) -> &str {
89 &self.config.relay_url
90 }
91
92 pub fn channel_id(&self) -> &str {
94 &self.config.channel_id
95 }
96
97 pub fn build_command(
99 &self,
100 machine_id: &str,
101 command: &str,
102 working_dir: Option<&str>,
103 ) -> RelayCommand {
104 RelayCommand {
105 target_machine: machine_id.to_string(),
106 command: command.to_string(),
107 working_dir: working_dir.map(String::from),
108 timeout_secs: self.default_timeout_secs,
109 }
110 }
111
112 pub fn serialize_command(&self, cmd: &RelayCommand) -> anyhow::Result<String> {
114 serde_json::to_string(cmd).map_err(|e| anyhow::anyhow!("failed to serialize command: {e}"))
115 }
116
117 pub fn parse_response(&self, json: &str) -> anyhow::Result<RelayResponse> {
119 serde_json::from_str(json)
120 .map_err(|e| anyhow::anyhow!("failed to parse relay response: {e}"))
121 }
122}
123
124impl RemoteExecutor for RelayExecutor {
125 fn execute(
126 &self,
127 machine_id: &str,
128 command: &str,
129 working_dir: Option<&str>,
130 ) -> anyhow::Result<RemoteOutput> {
131 let relay_cmd = self.build_command(machine_id, command, working_dir);
132 tracing::info!(
133 machine = machine_id,
134 relay = %self.config.relay_url,
135 "executing command via relay"
136 );
137
138 let _serialized = self.serialize_command(&relay_cmd)?;
144
145 anyhow::bail!(
146 "relay execution requires async runtime — use async execute method or tokio::block_in_place"
147 )
148 }
149
150 fn ping(&self, machine_id: &str) -> anyhow::Result<bool> {
151 let cmd = self.build_command(machine_id, "echo pong", None);
152 let _serialized = self.serialize_command(&cmd)?;
153
154 tracing::debug!(machine = machine_id, "relay ping (stub)");
156 Ok(false)
157 }
158
159 fn executor_type(&self) -> &str {
160 "relay"
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn make_config() -> RelayConfig {
169 RelayConfig {
170 relay_url: "wss://relay.mur.run/v1/ws".into(),
171 auth_token: "test-token".into(),
172 channel_id: "team-123".into(),
173 }
174 }
175
176 #[test]
177 fn test_relay_config_default() {
178 let config = RelayConfig::default_relay("token".into(), "channel".into());
179 assert_eq!(config.relay_url, "wss://relay.mur.run/v1/ws");
180 assert_eq!(config.auth_token, "token");
181 assert_eq!(config.channel_id, "channel");
182 }
183
184 #[test]
185 fn test_relay_executor_new() {
186 let executor = RelayExecutor::new(make_config());
187 assert_eq!(executor.relay_url(), "wss://relay.mur.run/v1/ws");
188 assert_eq!(executor.channel_id(), "team-123");
189 assert_eq!(executor.default_timeout_secs, 300);
190 }
191
192 #[test]
193 fn test_relay_executor_with_timeout() {
194 let executor = RelayExecutor::new(make_config()).with_timeout(60);
195 assert_eq!(executor.default_timeout_secs, 60);
196 }
197
198 #[test]
199 fn test_build_command() {
200 let executor = RelayExecutor::new(make_config());
201 let cmd = executor.build_command("m1", "cargo build", Some("/app"));
202 assert_eq!(cmd.target_machine, "m1");
203 assert_eq!(cmd.command, "cargo build");
204 assert_eq!(cmd.working_dir.as_deref(), Some("/app"));
205 assert_eq!(cmd.timeout_secs, 300);
206 }
207
208 #[test]
209 fn test_serialize_and_parse_command() {
210 let executor = RelayExecutor::new(make_config());
211 let cmd = executor.build_command("m1", "ls", None);
212 let json = executor.serialize_command(&cmd).unwrap();
213 assert!(json.contains("m1"));
214 assert!(json.contains("ls"));
215 }
216
217 #[test]
218 fn test_parse_response() {
219 let executor = RelayExecutor::new(make_config());
220 let json = r#"{"machine_id":"m1","stdout":"ok\n","stderr":"","exit_code":0,"duration_ms":42}"#;
221 let resp = executor.parse_response(json).unwrap();
222 assert_eq!(resp.machine_id, "m1");
223 assert_eq!(resp.exit_code, 0);
224 assert_eq!(resp.duration_ms, 42);
225 }
226
227 #[test]
228 fn test_relay_response_into_remote_output() {
229 let resp = RelayResponse {
230 machine_id: "m1".into(),
231 stdout: "output".into(),
232 stderr: "warn".into(),
233 exit_code: 0,
234 duration_ms: 100,
235 };
236 let output: RemoteOutput = resp.into();
237 assert_eq!(output.machine_id, "m1");
238 assert!(output.success());
239 }
240
241 #[test]
242 fn test_executor_type() {
243 let executor = RelayExecutor::new(make_config());
244 assert_eq!(executor.executor_type(), "relay");
245 }
246
247 #[test]
248 fn test_relay_command_serialization() {
249 let cmd = RelayCommand {
250 target_machine: "m1".into(),
251 command: "echo hello".into(),
252 working_dir: None,
253 timeout_secs: 30,
254 };
255 let json = serde_json::to_string(&cmd).unwrap();
256 let back: RelayCommand = serde_json::from_str(&json).unwrap();
257 assert_eq!(cmd.target_machine, back.target_machine);
258 assert_eq!(cmd.timeout_secs, back.timeout_secs);
259 }
260
261 #[test]
262 fn test_relay_config_serialization() {
263 let config = make_config();
264 let json = serde_json::to_string(&config).unwrap();
265 let back: RelayConfig = serde_json::from_str(&json).unwrap();
266 assert_eq!(config.relay_url, back.relay_url);
267 assert_eq!(config.channel_id, back.channel_id);
268 }
269}