1use 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
17pub const DEFAULT_PORT: u16 = 4096;
19
20const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
22
23const HEALTH_CHECK_INTERVAL: Duration = Duration::from_millis(500);
25
26#[derive(Debug, Clone)]
28pub struct ServerConfig {
29 pub port: u16,
31 pub working_dir: Option<PathBuf>,
33 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
47pub struct OpenCodeManager {
49 config: ServerConfig,
50 client: OpenCodeClient,
51 server_process: Arc<RwLock<Option<Child>>>,
52}
53
54impl OpenCodeManager {
55 pub fn new() -> Self {
57 Self::with_config(ServerConfig::default())
58 }
59
60 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 pub fn client(&self) -> &OpenCodeClient {
72 &self.client
73 }
74
75 pub async fn is_running(&self) -> bool {
77 self.client.health_check().await.unwrap_or(false)
78 }
79
80 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 pub async fn start_server(&self) -> Result<()> {
91 if self.is_running().await {
93 return Ok(());
94 }
95
96 let binary = self.find_binary()?;
98
99 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 cmd.stdout(Stdio::null());
110 cmd.stderr(Stdio::null());
111
112 let child = cmd
114 .spawn()
115 .with_context(|| format!("Failed to start opencode server: {}", binary.display()))?;
116
117 {
119 let mut process = self.server_process.write().await;
120 *process = Some(child);
121 }
122
123 self.wait_for_ready().await?;
125
126 Ok(())
127 }
128
129 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 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 let _ = child.kill().await;
153 }
154
155 Ok(())
156 }
157
158 fn find_binary(&self) -> Result<PathBuf> {
160 if let Some(ref path) = self.config.binary_path {
162 if path.exists() {
163 return Ok(path.clone());
164 }
165 }
166
167 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 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 pub fn port(&self) -> u16 {
183 self.config.port
184 }
185
186 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 }
204}
205
206static GLOBAL_MANAGER: std::sync::OnceLock<Arc<OpenCodeManager>> = std::sync::OnceLock::new();
208
209pub fn global_manager() -> Arc<OpenCodeManager> {
211 GLOBAL_MANAGER
212 .get_or_init(|| Arc::new(OpenCodeManager::new()))
213 .clone()
214}
215
216pub 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}