Skip to main content

brainwires_network/remote/
manager.rs

1//! Remote Bridge Manager
2//!
3//! Manages the lifecycle of the remote control bridge.
4//!
5//! Unlike the CLI version, this manager uses trait objects for configuration
6//! and agent spawning, making it reusable outside the CLI.
7//!
8//! CLI-specific functions (`should_auto_start()`, `try_auto_start()`) remain
9//! in the CLI's adapter module.
10
11use 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/// Internal manager state
23#[derive(Default)]
24struct ManagerState {
25    /// Handle to the bridge task
26    task_handle: Option<JoinHandle<()>>,
27    /// Shutdown signal sender
28    shutdown_tx: Option<broadcast::Sender<()>>,
29    /// Whether the manager is currently running
30    running: bool,
31}
32
33/// Remote Bridge Manager
34///
35/// Provides a high-level interface for managing the remote control bridge.
36/// Uses `BridgeConfigProvider` for configuration and `AgentSpawner` for
37/// process creation, decoupled from CLI-specific types.
38pub struct RemoteBridgeManager {
39    state: Arc<RwLock<ManagerState>>,
40    /// Configuration provider (replaces ConfigManager + SessionManager)
41    config_provider: Box<dyn BridgeConfigProvider>,
42    /// Agent spawner (for remote agent creation)
43    agent_spawner: Arc<dyn AgentSpawner>,
44    /// Sessions directory (injected platform path)
45    sessions_dir: PathBuf,
46    /// CLI version string (injected)
47    version: String,
48    /// Attachment storage directory (injected platform path)
49    attachment_dir: PathBuf,
50}
51
52impl RemoteBridgeManager {
53    /// Create a new remote bridge manager
54    ///
55    /// # Arguments
56    /// * `config_provider` - Provides remote bridge configuration and API keys
57    /// * `agent_spawner` - Creates new agent processes on demand
58    /// * `sessions_dir` - Directory containing agent session files
59    /// * `version` - CLI version string
60    /// * `attachment_dir` - Directory for storing received attachments
61    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    /// Check if remote control is enabled
79    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    /// Build a BridgeConfig from the provider's settings
87    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        // Get API key: prefer the one from config, fall back to key store
94        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    /// Start the remote bridge with an explicit BridgeConfig
119    ///
120    /// Returns `Ok(Some(handle))` if started, `Ok(None)` if already running.
121    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        // Create shutdown channel for graceful shutdown
139        let (shutdown_tx, _) = broadcast::channel(1);
140        bridge.set_shutdown_tx(shutdown_tx.clone());
141        state.shutdown_tx = Some(shutdown_tx);
142
143        // Spawn the bridge task
144        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        // Wait briefly for connection
151        tokio::time::sleep(Duration::from_millis(100)).await;
152
153        state.running = true;
154
155        Ok(Some(handle))
156    }
157
158    /// Start the remote bridge using configuration from the provider
159    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    /// Stop the remote bridge gracefully
172    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        // Send graceful shutdown signal
182        if let Some(tx) = &state.shutdown_tx {
183            let _ = tx.send(());
184            tracing::info!("Sent graceful shutdown signal to bridge");
185
186            // Wait briefly for the disconnect message to be sent
187            tokio::time::sleep(Duration::from_millis(500)).await;
188        }
189
190        // Abort if still running
191        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    /// Check if the bridge is running
200    pub async fn is_running(&self) -> bool {
201        self.state.read().await.running
202    }
203
204    /// Get bridge status for display (sync version)
205    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    /// Get bridge status for display (async version)
219    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/// Bridge status for display
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum RemoteBridgeStatus {
233    /// Bridge is disconnected
234    Disconnected,
235    /// Bridge is connecting
236    Connecting,
237    /// Bridge is connected
238    Connected,
239    /// Bridge is authenticated and ready
240    Authenticated,
241    /// Bridge encountered an error
242    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}