codex_memory/
manager.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::io::{BufRead, BufReader};
4use std::path::PathBuf;
5use std::process::{Command, Stdio};
6use tracing::{info, warn};
7
8/// Default paths for manager files
9pub struct ManagerPaths {
10    pub pid_file: PathBuf,
11    pub log_file: PathBuf,
12    pub config_dir: PathBuf,
13}
14
15impl Default for ManagerPaths {
16    fn default() -> Self {
17        let config_dir = dirs::config_dir()
18            .unwrap_or_else(|| PathBuf::from("."))
19            .join("codex-memory");
20
21        Self {
22            pid_file: config_dir.join("codex-memory.pid"),
23            log_file: config_dir.join("codex-memory.log"),
24            config_dir,
25        }
26    }
27}
28
29/// Server manager for handling daemon processes
30pub struct ServerManager {
31    paths: ManagerPaths,
32}
33
34impl ServerManager {
35    pub fn new() -> Self {
36        let paths = ManagerPaths::default();
37
38        // Ensure config directory exists
39        if !paths.config_dir.exists() {
40            fs::create_dir_all(&paths.config_dir).ok();
41        }
42
43        Self { paths }
44    }
45
46    pub fn with_paths(pid_file: Option<String>, log_file: Option<String>) -> Self {
47        let mut paths = ManagerPaths::default();
48
49        if let Some(pid) = pid_file {
50            paths.pid_file = PathBuf::from(pid);
51        }
52        if let Some(log) = log_file {
53            paths.log_file = PathBuf::from(log);
54        }
55
56        Self { paths }
57    }
58
59    /// Start the server as a daemon
60    pub async fn start_daemon(&self, daemon: bool) -> Result<()> {
61        // Check if already running
62        if let Some(pid) = self.get_running_pid()? {
63            return Err(anyhow::anyhow!("Server already running with PID: {}", pid));
64        }
65
66        info!("Starting Codex Memory server...");
67
68        if daemon {
69            // Start as daemon using fork or platform-specific method
70            self.start_background_process().await?;
71        } else {
72            // Start in foreground - this would need to be implemented differently
73            // For now, we'll just start in background
74            info!("Starting server in foreground mode...");
75            self.start_background_process().await?;
76        }
77
78        Ok(())
79    }
80
81    /// Start server in background
82    async fn start_background_process(&self) -> Result<()> {
83        let exe = std::env::current_exe().context("Failed to get current executable path")?;
84
85        let log_file = fs::OpenOptions::new()
86            .create(true)
87            .append(true)
88            .open(&self.paths.log_file)
89            .context("Failed to open log file")?;
90
91        let cmd = Command::new(exe)
92            .arg("start")
93            .arg("--skip-setup")
94            .stdout(Stdio::from(log_file.try_clone()?))
95            .stderr(Stdio::from(log_file))
96            .spawn()
97            .context("Failed to spawn server process")?;
98
99        let pid = cmd.id();
100
101        // Write PID file
102        fs::write(&self.paths.pid_file, pid.to_string()).context("Failed to write PID file")?;
103
104        info!("Server started with PID: {}", pid);
105        info!("Log file: {}", self.paths.log_file.display());
106
107        Ok(())
108    }
109
110    /// Stop the running server
111    pub async fn stop(&self) -> Result<()> {
112        let pid = self
113            .get_running_pid()?
114            .ok_or_else(|| anyhow::anyhow!("Server is not running"))?;
115
116        info!("Stopping server with PID: {}", pid);
117
118        #[cfg(unix)]
119        {
120            use nix::sys::signal::{self, Signal};
121            use nix::unistd::Pid;
122
123            // Send SIGTERM for graceful shutdown
124            signal::kill(Pid::from_raw(pid as i32), Signal::SIGTERM)
125                .context("Failed to send SIGTERM")?;
126
127            // Wait for process to exit (max 10 seconds)
128            for _ in 0..10 {
129                if !self.is_process_running(pid)? {
130                    break;
131                }
132                tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
133            }
134
135            // Force kill if still running
136            if self.is_process_running(pid)? {
137                warn!("Process didn't stop gracefully, forcing kill");
138                signal::kill(Pid::from_raw(pid as i32), Signal::SIGKILL)
139                    .context("Failed to send SIGKILL")?;
140            }
141        }
142
143        #[cfg(windows)]
144        {
145            // Windows process termination
146            let output = Command::new("taskkill")
147                .args(&["/PID", &pid.to_string(), "/F"])
148                .output()
149                .context("Failed to kill process")?;
150
151            if !output.status.success() {
152                return Err(anyhow::anyhow!("Failed to stop server"));
153            }
154        }
155
156        // Remove PID file
157        fs::remove_file(&self.paths.pid_file).ok();
158        info!("Server stopped");
159
160        Ok(())
161    }
162
163    /// Restart the server
164    pub async fn restart(&self) -> Result<()> {
165        info!("Restarting server...");
166
167        // Stop if running
168        if self.get_running_pid()?.is_some() {
169            self.stop().await?;
170            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
171        }
172
173        // Start again
174        self.start_daemon(true).await?;
175
176        Ok(())
177    }
178
179    /// Get server status
180    pub async fn status(&self, detailed: bool) -> Result<()> {
181        match self.get_running_pid()? {
182            Some(pid) => {
183                println!("● Server is running");
184                println!("  PID: {}", pid);
185
186                if detailed {
187                    println!("  PID file: {}", self.paths.pid_file.display());
188                    println!("  Log file: {}", self.paths.log_file.display());
189
190                    // Show recent logs
191                    if self.paths.log_file.exists() {
192                        println!("\nRecent logs:");
193                        self.show_logs(5, false).await?;
194                    }
195                }
196            }
197            None => {
198                println!("○ Server is not running");
199
200                if detailed {
201                    println!("  PID file: {}", self.paths.pid_file.display());
202                    println!("  Log file: {}", self.paths.log_file.display());
203                }
204            }
205        }
206
207        Ok(())
208    }
209
210    /// Show server logs
211    pub async fn show_logs(&self, lines: usize, follow: bool) -> Result<()> {
212        if !self.paths.log_file.exists() {
213            return Err(anyhow::anyhow!("Log file not found"));
214        }
215
216        if follow {
217            // Follow logs (like tail -f)
218            let file = fs::File::open(&self.paths.log_file)?;
219            let reader = BufReader::new(file);
220
221            println!("Following logs (Ctrl+C to stop)...");
222            for line in reader.lines() {
223                println!("{}", line?);
224            }
225        } else {
226            // Show last N lines
227            let content = fs::read_to_string(&self.paths.log_file)?;
228            let all_lines: Vec<&str> = content.lines().collect();
229            let start = if all_lines.len() > lines {
230                all_lines.len() - lines
231            } else {
232                0
233            };
234
235            for line in &all_lines[start..] {
236                println!("{}", line);
237            }
238        }
239
240        Ok(())
241    }
242
243    /// Install as system service
244    pub async fn install_service(&self, service_type: Option<String>) -> Result<()> {
245        let service_type = service_type.unwrap_or_else(|| {
246            #[cfg(target_os = "linux")]
247            return "systemd".to_string();
248            #[cfg(target_os = "macos")]
249            return "launchd".to_string();
250            #[cfg(target_os = "windows")]
251            return "windows".to_string();
252            #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
253            return "none".to_string();
254        });
255
256        match service_type.as_str() {
257            "systemd" => self.install_systemd_service().await,
258            "launchd" => self.install_launchd_service().await,
259            "windows" => self.install_windows_service().await,
260            _ => Err(anyhow::anyhow!(
261                "Unsupported service type: {}",
262                service_type
263            )),
264        }
265    }
266
267    /// Install systemd service (Linux)
268    async fn install_systemd_service(&self) -> Result<()> {
269        #[cfg(target_os = "linux")]
270        {
271            let service_content = format!(
272                r#"[Unit]
273Description=Codex Memory System
274After=network.target postgresql.service
275
276[Service]
277Type=simple
278ExecStart={} start
279ExecStop={} manager stop
280Restart=on-failure
281RestartSec=10
282StandardOutput=append:{}
283StandardError=append:{}
284
285[Install]
286WantedBy=multi-user.target
287"#,
288                std::env::current_exe()?.display(),
289                std::env::current_exe()?.display(),
290                self.paths.log_file.display(),
291                self.paths.log_file.display(),
292            );
293
294            let service_path = PathBuf::from("/etc/systemd/system/codex-memory.service");
295
296            // Write service file (requires sudo)
297            fs::write(&service_path, service_content)
298                .context("Failed to write service file (need sudo?)")?;
299
300            // Reload systemd
301            Command::new("systemctl")
302                .args(&["daemon-reload"])
303                .status()
304                .context("Failed to reload systemd")?;
305
306            // Enable service
307            Command::new("systemctl")
308                .args(&["enable", "codex-memory.service"])
309                .status()
310                .context("Failed to enable service")?;
311
312            info!("Systemd service installed successfully");
313            info!("Start with: systemctl start codex-memory");
314            Ok(())
315        }
316
317        #[cfg(not(target_os = "linux"))]
318        Err(anyhow::anyhow!("Systemd is only available on Linux"))
319    }
320
321    /// Install launchd service (macOS)
322    async fn install_launchd_service(&self) -> Result<()> {
323        #[cfg(target_os = "macos")]
324        {
325            let plist_content = format!(
326                r#"<?xml version="1.0" encoding="UTF-8"?>
327<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
328<plist version="1.0">
329<dict>
330    <key>Label</key>
331    <string>com.codex.memory</string>
332    <key>ProgramArguments</key>
333    <array>
334        <string>{}</string>
335        <string>start</string>
336    </array>
337    <key>StandardOutPath</key>
338    <string>{}</string>
339    <key>StandardErrorPath</key>
340    <string>{}</string>
341    <key>RunAtLoad</key>
342    <false/>
343    <key>KeepAlive</key>
344    <dict>
345        <key>SuccessfulExit</key>
346        <false/>
347    </dict>
348</dict>
349</plist>
350"#,
351                std::env::current_exe()?.display(),
352                self.paths.log_file.display(),
353                self.paths.log_file.display(),
354            );
355
356            let plist_path = dirs::home_dir()
357                .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?
358                .join("Library/LaunchAgents/com.codex.memory.plist");
359
360            // Write plist file
361            fs::write(&plist_path, plist_content).context("Failed to write plist file")?;
362
363            // Load the service
364            Command::new("launchctl")
365                .args(&["load", plist_path.to_str().unwrap()])
366                .status()
367                .context("Failed to load launchd service")?;
368
369            info!("Launchd service installed successfully");
370            info!("Start with: launchctl start com.codex.memory");
371            Ok(())
372        }
373
374        #[cfg(not(target_os = "macos"))]
375        Err(anyhow::anyhow!("Launchd is only available on macOS"))
376    }
377
378    /// Install Windows service
379    async fn install_windows_service(&self) -> Result<()> {
380        #[cfg(target_os = "windows")]
381        {
382            // Windows service installation using sc.exe
383            let exe_path = std::env::current_exe()?;
384
385            Command::new("sc")
386                .args(&[
387                    "create",
388                    "CodexMemory",
389                    &format!("binPath= \"{}\" start", exe_path.display()),
390                    "DisplayName= \"Codex Memory System\"",
391                    "start= auto",
392                ])
393                .status()
394                .context("Failed to create Windows service")?;
395
396            info!("Windows service installed successfully");
397            info!("Start with: sc start CodexMemory");
398            Ok(())
399        }
400
401        #[cfg(not(target_os = "windows"))]
402        Err(anyhow::anyhow!(
403            "Windows service is only available on Windows"
404        ))
405    }
406
407    /// Uninstall system service
408    pub async fn uninstall_service(&self) -> Result<()> {
409        #[cfg(target_os = "linux")]
410        {
411            Command::new("systemctl")
412                .args(&["disable", "codex-memory.service"])
413                .status()?;
414
415            fs::remove_file("/etc/systemd/system/codex-memory.service")?;
416
417            Command::new("systemctl")
418                .args(&["daemon-reload"])
419                .status()?;
420
421            info!("Systemd service uninstalled");
422        }
423
424        #[cfg(target_os = "macos")]
425        {
426            let plist_path = dirs::home_dir()
427                .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?
428                .join("Library/LaunchAgents/com.codex.memory.plist");
429
430            Command::new("launchctl")
431                .args(&["unload", plist_path.to_str().unwrap()])
432                .status()?;
433
434            fs::remove_file(plist_path)?;
435
436            info!("Launchd service uninstalled");
437        }
438
439        #[cfg(target_os = "windows")]
440        {
441            Command::new("sc")
442                .args(&["delete", "CodexMemory"])
443                .status()?;
444
445            info!("Windows service uninstalled");
446        }
447
448        Ok(())
449    }
450
451    /// Get PID of running server
452    fn get_running_pid(&self) -> Result<Option<u32>> {
453        if !self.paths.pid_file.exists() {
454            return Ok(None);
455        }
456
457        let pid_str = fs::read_to_string(&self.paths.pid_file)?;
458        let pid: u32 = pid_str.trim().parse().context("Invalid PID in file")?;
459
460        // Check if process is actually running
461        if self.is_process_running(pid)? {
462            Ok(Some(pid))
463        } else {
464            // Clean up stale PID file
465            fs::remove_file(&self.paths.pid_file).ok();
466            Ok(None)
467        }
468    }
469
470    /// Check if a process is running
471    fn is_process_running(&self, pid: u32) -> Result<bool> {
472        #[cfg(unix)]
473        {
474            use nix::sys::signal::{self, Signal};
475            use nix::unistd::Pid;
476
477            // Send signal 0 to check if process exists
478            match signal::kill(Pid::from_raw(pid as i32), Signal::SIGCONT) {
479                Ok(_) => Ok(true),
480                Err(nix::errno::Errno::ESRCH) => Ok(false),
481                Err(e) => Err(anyhow::anyhow!("Failed to check process: {}", e)),
482            }
483        }
484
485        #[cfg(windows)]
486        {
487            use std::process::Command;
488
489            let output = Command::new("tasklist")
490                .args(&["/FI", &format!("PID eq {}", pid)])
491                .output()?;
492
493            Ok(String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
494        }
495    }
496}