brainwires_network/remote/
manager.rs1use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Duration;
14
15use anyhow::Result;
16use tokio::sync::{RwLock, broadcast};
17use tokio::task::JoinHandle;
18
19use super::bridge::{BridgeConfig, RemoteBridge};
20use crate::traits::{AgentSpawner, BridgeConfigProvider};
21
22#[derive(Default)]
24struct ManagerState {
25 task_handle: Option<JoinHandle<()>>,
27 shutdown_tx: Option<broadcast::Sender<()>>,
29 running: bool,
31}
32
33pub struct RemoteBridgeManager {
39 state: Arc<RwLock<ManagerState>>,
40 config_provider: Box<dyn BridgeConfigProvider>,
42 agent_spawner: Arc<dyn AgentSpawner>,
44 sessions_dir: PathBuf,
46 version: String,
48 attachment_dir: PathBuf,
50}
51
52impl RemoteBridgeManager {
53 pub fn new(
62 config_provider: Box<dyn BridgeConfigProvider>,
63 agent_spawner: Arc<dyn AgentSpawner>,
64 sessions_dir: PathBuf,
65 version: String,
66 attachment_dir: PathBuf,
67 ) -> Self {
68 Self {
69 state: Arc::new(RwLock::new(ManagerState::default())),
70 config_provider,
71 agent_spawner,
72 sessions_dir,
73 version,
74 attachment_dir,
75 }
76 }
77
78 pub fn is_enabled(&self) -> Result<bool> {
80 match self.config_provider.get_remote_config()? {
81 Some(_) => Ok(true),
82 None => Ok(false),
83 }
84 }
85
86 fn build_bridge_config(&self) -> Result<Option<BridgeConfig>> {
88 let remote_config = match self.config_provider.get_remote_config()? {
89 Some(c) => c,
90 None => return Ok(None),
91 };
92
93 let api_key = if !remote_config.api_key.is_empty() {
95 remote_config.api_key
96 } else {
97 match self.config_provider.get_api_key()? {
98 Some(key) => key.to_string(),
99 None => {
100 tracing::warn!("Remote control enabled but no API key available");
101 return Ok(None);
102 }
103 }
104 };
105
106 Ok(Some(BridgeConfig {
107 backend_url: remote_config.backend_url,
108 api_key,
109 heartbeat_interval_secs: remote_config.heartbeat_interval_secs,
110 reconnect_delay_secs: remote_config.reconnect_delay_secs,
111 max_reconnect_attempts: remote_config.max_reconnect_attempts,
112 version: self.version.clone(),
113 sessions_dir: self.sessions_dir.clone(),
114 attachment_dir: self.attachment_dir.clone(),
115 }))
116 }
117
118 pub async fn start_with_config(&self, config: BridgeConfig) -> Result<Option<JoinHandle<()>>> {
122 let mut state = self.state.write().await;
123
124 if state.running {
125 tracing::debug!("Remote bridge already running");
126 return Ok(None);
127 }
128
129 if config.api_key.is_empty() {
130 anyhow::bail!("No API key configured");
131 }
132
133 tracing::info!("Starting remote control bridge to {}", config.backend_url);
134
135 let agent_spawner = Arc::clone(&self.agent_spawner);
136 let mut bridge = RemoteBridge::new(config, Some(agent_spawner));
137
138 let (shutdown_tx, _) = broadcast::channel(1);
140 bridge.set_shutdown_tx(shutdown_tx.clone());
141 state.shutdown_tx = Some(shutdown_tx);
142
143 let handle = tokio::spawn(async move {
145 if let Err(e) = bridge.run().await {
146 tracing::error!("Remote bridge error: {}", e);
147 }
148 });
149
150 tokio::time::sleep(Duration::from_millis(100)).await;
152
153 state.running = true;
154
155 Ok(Some(handle))
156 }
157
158 pub async fn start_from_config(&self) -> Result<Option<JoinHandle<()>>> {
160 let config = match self.build_bridge_config()? {
161 Some(c) => c,
162 None => {
163 tracing::debug!("Remote control not enabled or not configured");
164 return Ok(None);
165 }
166 };
167
168 self.start_with_config(config).await
169 }
170
171 pub async fn stop(&self) {
173 let mut state = self.state.write().await;
174
175 if !state.running {
176 return;
177 }
178
179 tracing::info!("Stopping remote control bridge");
180
181 if let Some(tx) = &state.shutdown_tx {
183 let _ = tx.send(());
184 tracing::info!("Sent graceful shutdown signal to bridge");
185
186 tokio::time::sleep(Duration::from_millis(500)).await;
188 }
189
190 if let Some(handle) = state.task_handle.take() {
192 handle.abort();
193 }
194
195 state.shutdown_tx = None;
196 state.running = false;
197 }
198
199 pub async fn is_running(&self) -> bool {
201 self.state.read().await.running
202 }
203
204 pub fn status(&self) -> RemoteBridgeStatus {
206 match self.state.try_read() {
207 Ok(state) => {
208 if !state.running {
209 RemoteBridgeStatus::Disconnected
210 } else {
211 RemoteBridgeStatus::Connected
212 }
213 }
214 Err(_) => RemoteBridgeStatus::Connecting,
215 }
216 }
217
218 pub async fn status_async(&self) -> RemoteBridgeStatus {
220 let state = self.state.read().await;
221
222 if !state.running {
223 RemoteBridgeStatus::Disconnected
224 } else {
225 RemoteBridgeStatus::Connected
226 }
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum RemoteBridgeStatus {
233 Disconnected,
235 Connecting,
237 Connected,
239 Authenticated,
241 Error(String),
243}
244
245impl std::fmt::Display for RemoteBridgeStatus {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 match self {
248 Self::Disconnected => write!(f, "Disconnected"),
249 Self::Connecting => write!(f, "Connecting"),
250 Self::Connected => write!(f, "Connected"),
251 Self::Authenticated => write!(f, "Authenticated"),
252 Self::Error(e) => write!(f, "Error: {}", e),
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::traits::{BridgeConfigProvider, RemoteBridgeConfig};
261 use zeroize::Zeroizing;
262
263 struct MockConfigProvider {
264 config: Option<RemoteBridgeConfig>,
265 }
266
267 impl MockConfigProvider {
268 fn disabled() -> Self {
269 Self { config: None }
270 }
271
272 fn enabled() -> Self {
273 Self {
274 config: Some(RemoteBridgeConfig {
275 backend_url: "https://test.example.com".to_string(),
276 api_key: "test-key".to_string(),
277 heartbeat_interval_secs: 5,
278 reconnect_delay_secs: 5,
279 max_reconnect_attempts: 3,
280 }),
281 }
282 }
283 }
284
285 impl BridgeConfigProvider for MockConfigProvider {
286 fn get_remote_config(&self) -> Result<Option<RemoteBridgeConfig>> {
287 Ok(self.config.clone())
288 }
289
290 fn get_api_key(&self) -> Result<Option<Zeroizing<String>>> {
291 Ok(Some(Zeroizing::new("test-api-key".to_string())))
292 }
293 }
294
295 struct MockSpawner;
296
297 #[async_trait::async_trait]
298 impl AgentSpawner for MockSpawner {
299 async fn spawn_agent(
300 &self,
301 _session_id: &str,
302 _model: Option<String>,
303 _working_directory: Option<PathBuf>,
304 ) -> Result<PathBuf> {
305 Ok(PathBuf::from("/tmp/test.sock"))
306 }
307 }
308
309 fn make_manager(config_provider: Box<dyn BridgeConfigProvider>) -> RemoteBridgeManager {
310 RemoteBridgeManager::new(
311 config_provider,
312 Arc::new(MockSpawner),
313 PathBuf::from("/tmp/test-sessions"),
314 "0.1.0-test".to_string(),
315 PathBuf::from("/tmp/test-attachments"),
316 )
317 }
318
319 #[test]
320 fn test_remote_bridge_status_display() {
321 assert_eq!(
322 format!("{}", RemoteBridgeStatus::Disconnected),
323 "Disconnected"
324 );
325 assert_eq!(format!("{}", RemoteBridgeStatus::Connected), "Connected");
326 assert_eq!(
327 format!("{}", RemoteBridgeStatus::Authenticated),
328 "Authenticated"
329 );
330 }
331
332 #[tokio::test]
333 async fn test_manager_not_running_by_default() {
334 let manager = make_manager(Box::new(MockConfigProvider::disabled()));
335 assert!(!manager.is_running().await);
336 }
337
338 #[test]
339 fn test_is_enabled_disabled() {
340 let manager = make_manager(Box::new(MockConfigProvider::disabled()));
341 assert!(!manager.is_enabled().unwrap());
342 }
343
344 #[test]
345 fn test_is_enabled_enabled() {
346 let manager = make_manager(Box::new(MockConfigProvider::enabled()));
347 assert!(manager.is_enabled().unwrap());
348 }
349
350 #[test]
351 fn test_build_bridge_config_disabled() {
352 let manager = make_manager(Box::new(MockConfigProvider::disabled()));
353 assert!(manager.build_bridge_config().unwrap().is_none());
354 }
355
356 #[test]
357 fn test_build_bridge_config_enabled() {
358 let manager = make_manager(Box::new(MockConfigProvider::enabled()));
359 let config = manager.build_bridge_config().unwrap();
360 assert!(config.is_some());
361 let config = config.unwrap();
362 assert_eq!(config.backend_url, "https://test.example.com");
363 assert_eq!(config.api_key, "test-key");
364 assert_eq!(config.version, "0.1.0-test");
365 }
366}