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