Skip to main content

scud/opencode/
manager.rs

1//! OpenCode Server lifecycle management
2//!
3//! Manages the OpenCode server process lifecycle, including automatic startup,
4//! health checking, and graceful shutdown.
5
6use anyhow::{Context, Result};
7use std::path::PathBuf;
8use std::process::Stdio;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::process::{Child, Command};
12use tokio::sync::RwLock;
13
14use super::client::OpenCodeClient;
15use super::events::EventStream;
16
17/// Default port for OpenCode server
18pub const DEFAULT_PORT: u16 = 4096;
19
20/// Server startup timeout
21const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
22
23/// Health check interval during startup
24const HEALTH_CHECK_INTERVAL: Duration = Duration::from_millis(500);
25
26/// Configuration for OpenCode server
27#[derive(Debug, Clone)]
28pub struct ServerConfig {
29    /// Port to run on
30    pub port: u16,
31    /// Working directory for the server
32    pub working_dir: Option<PathBuf>,
33    /// Custom opencode binary path
34    pub binary_path: Option<PathBuf>,
35}
36
37impl Default for ServerConfig {
38    fn default() -> Self {
39        Self {
40            port: DEFAULT_PORT,
41            working_dir: None,
42            binary_path: None,
43        }
44    }
45}
46
47/// Manager for OpenCode server lifecycle
48pub struct OpenCodeManager {
49    config: ServerConfig,
50    client: OpenCodeClient,
51    server_process: Arc<RwLock<Option<Child>>>,
52}
53
54impl OpenCodeManager {
55    /// Create a new manager with default config
56    pub fn new() -> Self {
57        Self::with_config(ServerConfig::default())
58    }
59
60    /// Create with custom config
61    pub fn with_config(config: ServerConfig) -> Self {
62        let client = OpenCodeClient::localhost(config.port);
63        Self {
64            config,
65            client,
66            server_process: Arc::new(RwLock::new(None)),
67        }
68    }
69
70    /// Get the HTTP client
71    pub fn client(&self) -> &OpenCodeClient {
72        &self.client
73    }
74
75    /// Check if server is running
76    pub async fn is_running(&self) -> bool {
77        self.client.health_check().await.unwrap_or(false)
78    }
79
80    /// Ensure server is running, starting it if needed
81    pub async fn ensure_running(&self) -> Result<()> {
82        if self.is_running().await {
83            return Ok(());
84        }
85
86        self.start_server().await
87    }
88
89    /// Start the OpenCode server
90    pub async fn start_server(&self) -> Result<()> {
91        // Check if already running
92        if self.is_running().await {
93            return Ok(());
94        }
95
96        // Find opencode binary
97        let binary = self.find_binary()?;
98
99        // Build command
100        let mut cmd = Command::new(&binary);
101        cmd.arg("serve");
102        cmd.arg("--port").arg(self.config.port.to_string());
103
104        if let Some(ref dir) = self.config.working_dir {
105            cmd.current_dir(dir);
106        }
107
108        // Suppress output (server runs in background)
109        cmd.stdout(Stdio::null());
110        cmd.stderr(Stdio::null());
111
112        // Spawn server process
113        let child = cmd
114            .spawn()
115            .with_context(|| format!("Failed to start opencode server: {}", binary.display()))?;
116
117        // Store process handle
118        {
119            let mut process = self.server_process.write().await;
120            *process = Some(child);
121        }
122
123        // Wait for server to become ready
124        self.wait_for_ready().await?;
125
126        Ok(())
127    }
128
129    /// Wait for server to become ready
130    async fn wait_for_ready(&self) -> Result<()> {
131        let start = std::time::Instant::now();
132
133        while start.elapsed() < STARTUP_TIMEOUT {
134            if self.client.health_check().await.unwrap_or(false) {
135                return Ok(());
136            }
137            tokio::time::sleep(HEALTH_CHECK_INTERVAL).await;
138        }
139
140        anyhow::bail!(
141            "OpenCode server failed to start within {:?}",
142            STARTUP_TIMEOUT
143        );
144    }
145
146    /// Stop the server
147    pub async fn stop_server(&self) -> Result<()> {
148        let mut process = self.server_process.write().await;
149
150        if let Some(mut child) = process.take() {
151            // Try graceful shutdown first
152            let _ = child.kill().await;
153        }
154
155        Ok(())
156    }
157
158    /// Find the opencode binary
159    fn find_binary(&self) -> Result<PathBuf> {
160        // Check custom path first
161        if let Some(ref path) = self.config.binary_path {
162            if path.exists() {
163                return Ok(path.clone());
164            }
165        }
166
167        // Use find_harness_binary from terminal module
168        use crate::commands::spawn::terminal::{find_harness_binary, Harness};
169
170        find_harness_binary(Harness::OpenCode)
171            .map(PathBuf::from)
172            .context("Could not find opencode binary. Install with: npm install -g @anthropics/opencode")
173    }
174
175    /// Connect to the event stream
176    pub async fn event_stream(&self) -> Result<EventStream> {
177        self.ensure_running().await?;
178        EventStream::connect(&self.client.event_stream_url()).await
179    }
180
181    /// Get the server port
182    pub fn port(&self) -> u16 {
183        self.config.port
184    }
185
186    /// Get the configuration
187    pub fn config(&self) -> &ServerConfig {
188        &self.config
189    }
190}
191
192impl Default for OpenCodeManager {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198impl Drop for OpenCodeManager {
199    fn drop(&mut self) {
200        // Note: Can't do async cleanup in Drop
201        // Server process will be orphaned but that's OK -
202        // it can be reused by next SCUD invocation
203    }
204}
205
206/// Global manager instance for sharing across swarm execution
207static GLOBAL_MANAGER: std::sync::OnceLock<Arc<OpenCodeManager>> = std::sync::OnceLock::new();
208
209/// Get or create the global manager instance
210pub fn global_manager() -> Arc<OpenCodeManager> {
211    GLOBAL_MANAGER
212        .get_or_init(|| Arc::new(OpenCodeManager::new()))
213        .clone()
214}
215
216/// Get or create manager with custom config (only works on first call)
217pub fn init_global_manager(config: ServerConfig) -> Arc<OpenCodeManager> {
218    GLOBAL_MANAGER
219        .get_or_init(|| Arc::new(OpenCodeManager::with_config(config)))
220        .clone()
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_default_config() {
229        let config = ServerConfig::default();
230        assert_eq!(config.port, DEFAULT_PORT);
231        assert!(config.working_dir.is_none());
232        assert!(config.binary_path.is_none());
233    }
234
235    #[test]
236    fn test_custom_config() {
237        let config = ServerConfig {
238            port: 8080,
239            working_dir: Some(PathBuf::from("/tmp")),
240            binary_path: Some(PathBuf::from("/usr/bin/opencode")),
241        };
242        assert_eq!(config.port, 8080);
243        assert_eq!(config.working_dir, Some(PathBuf::from("/tmp")));
244    }
245
246    #[test]
247    fn test_manager_creation() {
248        let manager = OpenCodeManager::new();
249        assert_eq!(manager.port(), DEFAULT_PORT);
250    }
251
252    #[test]
253    fn test_manager_with_config() {
254        let config = ServerConfig {
255            port: 9000,
256            ..Default::default()
257        };
258        let manager = OpenCodeManager::with_config(config);
259        assert_eq!(manager.port(), 9000);
260    }
261
262    #[test]
263    fn test_client_access() {
264        let manager = OpenCodeManager::new();
265        let client = manager.client();
266        assert_eq!(client.base_url(), "http://127.0.0.1:4096");
267    }
268}