Skip to main content

hermes_agent_cli_core/
gateway.rs

1// Gateway management - PID file, status, lifecycle, Windows service
2
3use crate::config::Config;
4use anyhow::{Context, Result};
5use std::fs;
6use std::path::PathBuf;
7use tracing::info;
8
9// =============================================================================
10// PID & State File Management
11// =============================================================================
12
13/// Gateway PID file path
14pub fn gateway_pid_path() -> PathBuf {
15    Config::hermes_home().join("gateway.pid")
16}
17
18/// Gateway state file path
19pub fn gateway_state_path() -> PathBuf {
20    Config::hermes_home().join("gateway_state.json")
21}
22
23/// Check if gateway is currently running
24pub fn is_gateway_running() -> bool {
25    get_running_pid().is_some()
26}
27
28/// Get the PID of running gateway instance, if any
29pub fn get_running_pid() -> Option<u32> {
30    let pid_path = gateway_pid_path();
31    if !pid_path.exists() {
32        return None;
33    }
34
35    let content = fs::read_to_string(&pid_path).ok()?;
36    let content = content.trim();
37
38    // Try JSON format first (newer versions store {pid, kind, argv, ...})
39    if content.starts_with('{') {
40        if let Ok(data) = serde_json::from_str::<serde_json::Value>(content) {
41            if let Some(pid) = data.get("pid").and_then(|p| p.as_u64()) {
42                let pid = pid as u32;
43                if is_process_alive(pid) {
44                    return Some(pid);
45                } else {
46                    // Stale PID file - clean it up
47                    let _ = fs::remove_file(&pid_path);
48                    return None;
49                }
50            }
51        }
52        return None;
53    }
54
55    // Plain number format
56    let pid: u32 = content.parse().ok()?;
57    if is_process_alive(pid) {
58        Some(pid)
59    } else {
60        // Stale PID file - clean it up
61        let _ = fs::remove_file(&pid_path);
62        None
63    }
64}
65
66/// Check if a process with the given PID is alive
67fn is_process_alive(pid: u32) -> bool {
68    // On Windows, use tasklist to check if process exists
69    #[cfg(target_os = "windows")]
70    {
71        std::process::Command::new("tasklist")
72            .args(["/FI", &format!("PID eq {}", pid), "/NH"])
73            .output()
74            .map(|output| {
75                let stdout = String::from_utf8_lossy(&output.stdout);
76                stdout.contains(&pid.to_string())
77            })
78            .unwrap_or(false)
79    }
80
81    #[cfg(not(target_os = "windows"))]
82    {
83        // On Unix, send signal 0 to check existence
84        unsafe { libc::kill(pid as i32, 0) == 0 }
85    }
86}
87
88/// Write PID file for gateway process with metadata
89pub fn write_pid_file() -> Result<()> {
90    let pid_path = gateway_pid_path();
91    if let Some(parent) = pid_path.parent() {
92        fs::create_dir_all(parent).context("failed to create hermes home directory")?;
93    }
94    let pid = std::process::id();
95    let data = serde_json::json!({
96        "pid": pid,
97        "kind": "hermes-gateway",
98        "argv": std::env::args().collect::<Vec<_>>(),
99        "start_time": chrono::Utc::now().to_rfc3339(),
100    });
101    let content = serde_json::to_string_pretty(&data).context("failed to serialize PID data")?;
102    fs::write(&pid_path, content).context("failed to write gateway PID file")?;
103    info!("wrote gateway PID file with pid={}", pid);
104    Ok(())
105}
106
107/// Remove PID file
108pub fn remove_pid_file() -> Result<()> {
109    let pid_path = gateway_pid_path();
110    if pid_path.exists() {
111        fs::remove_file(&pid_path).context("failed to remove gateway PID file")?;
112    }
113    Ok(())
114}
115
116// =============================================================================
117// Gateway State
118// =============================================================================
119
120/// Gateway state structure
121#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
122pub struct GatewayState {
123    pub gateway_state: String,
124    pub pid: u32,
125    pub platform: Option<String>,
126    pub platform_state: Option<String>,
127    pub restart_requested: bool,
128    pub active_agents: u32,
129    pub updated_at: String,
130}
131
132impl Default for GatewayState {
133    fn default() -> Self {
134        Self {
135            gateway_state: "stopped".to_string(),
136            pid: std::process::id(),
137            platform: None,
138            platform_state: None,
139            restart_requested: false,
140            active_agents: 0,
141            updated_at: chrono::Utc::now().to_rfc3339(),
142        }
143    }
144}
145
146/// Read gateway state from file
147pub fn read_gateway_state() -> Option<GatewayState> {
148    let state_path = gateway_state_path();
149    if !state_path.exists() {
150        return None;
151    }
152    let content = fs::read_to_string(&state_path).ok()?;
153    serde_json::from_str(&content).ok()
154}
155
156/// Write gateway state to file
157pub fn write_gateway_state(state: &GatewayState) -> Result<()> {
158    let state_path = gateway_state_path();
159    if let Some(parent) = state_path.parent() {
160        fs::create_dir_all(parent).context("failed to create hermes home directory")?;
161    }
162    let content =
163        serde_json::to_string_pretty(state).context("failed to serialize gateway state")?;
164    fs::write(&state_path, content).context("failed to write gateway state file")?;
165    Ok(())
166}
167
168// =============================================================================
169// Platform
170// =============================================================================
171
172/// Platform enum for gateway
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum Platform {
175    Cli,
176    Telegram,
177    Discord,
178    Slack,
179    WhatsApp,
180    Webhook,
181}
182
183impl Platform {
184    pub fn parse(s: &str) -> Option<Self> {
185        match s.to_lowercase().as_str() {
186            "cli" | "local" => Some(Platform::Cli),
187            "telegram" | "tg" => Some(Platform::Telegram),
188            "discord" | "dc" => Some(Platform::Discord),
189            "slack" | "sl" => Some(Platform::Slack),
190            "whatsapp" | "wa" => Some(Platform::WhatsApp),
191            "webhook" | "http" => Some(Platform::Webhook),
192            _ => None,
193        }
194    }
195}
196
197impl std::str::FromStr for Platform {
198    type Err = String;
199    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
200        Self::parse(s).ok_or_else(|| format!("Invalid platform: {}", s))
201    }
202}
203
204impl Platform {
205    pub fn as_str(&self) -> &'static str {
206        match self {
207            Platform::Cli => "cli",
208            Platform::Telegram => "telegram",
209            Platform::Discord => "discord",
210            Platform::Slack => "slack",
211            Platform::WhatsApp => "whatsapp",
212            Platform::Webhook => "webhook",
213        }
214    }
215
216    /// Get all supported platforms
217    pub fn all() -> &'static [Platform] {
218        &[
219            Platform::Cli,
220            Platform::Telegram,
221            Platform::Discord,
222            Platform::Slack,
223            Platform::WhatsApp,
224            Platform::Webhook,
225        ]
226    }
227}
228
229impl std::fmt::Display for Platform {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        write!(f, "{}", self.as_str())
232    }
233}
234
235// =============================================================================
236// Windows Service Management
237// =============================================================================
238
239/// Windows service name for the Hermes gateway
240const SERVICE_NAME: &str = "HermesGateway";
241const SERVICE_DISPLAY_NAME: &str = "Hermes Agent Gateway";
242
243/// Check if the gateway is installed as a Windows service
244pub fn is_service_installed() -> bool {
245    #[cfg(target_os = "windows")]
246    {
247        let output = std::process::Command::new("sc").args(["query", SERVICE_NAME]).output();
248        match output {
249            Ok(out) => {
250                let stdout = String::from_utf8_lossy(&out.stdout);
251                stdout.contains("RUNNING")
252                    || stdout.contains("STOPPED")
253                    || stdout.contains("PAUSED")
254            }
255            Err(_) => false,
256        }
257    }
258    #[cfg(not(target_os = "windows"))]
259    {
260        false
261    }
262}
263
264/// Get the status of the Hermes Windows service
265pub fn get_service_status() -> ServiceStatus {
266    #[cfg(target_os = "windows")]
267    {
268        let output = std::process::Command::new("sc").args(["query", SERVICE_NAME]).output();
269
270        match output {
271            Ok(out) => {
272                let stdout = String::from_utf8_lossy(&out.stdout);
273                if stdout.contains("RUNNING") {
274                    ServiceStatus::Running
275                } else if stdout.contains("STOPPED") {
276                    ServiceStatus::Stopped
277                } else if stdout.contains("PAUSED") {
278                    ServiceStatus::Paused
279                } else if stdout.contains("START_PENDING") {
280                    ServiceStatus::StartPending
281                } else if stdout.contains("STOP_PENDING") {
282                    ServiceStatus::StopPending
283                } else {
284                    ServiceStatus::NotFound
285                }
286            }
287            Err(_) => ServiceStatus::NotFound,
288        }
289    }
290    #[cfg(not(target_os = "windows"))]
291    {
292        ServiceStatus::NotApplicable
293    }
294}
295
296/// Install the gateway as a Windows service using sc.exe
297pub fn install_service() -> Result<()> {
298    let exe_path = std::env::current_exe().context("failed to get current executable path")?;
299    let exe_str = exe_path.to_string_lossy();
300
301    // Create the service bin path with the "gateway run" arguments
302    let bin_path = format!("{} gateway run", exe_str);
303
304    #[cfg(target_os = "windows")]
305    {
306        let output = std::process::Command::new("sc")
307            .args([
308                "create",
309                SERVICE_NAME,
310                "binPath=",
311                &bin_path,
312                "start=",
313                "auto",
314                "DisplayName=",
315                SERVICE_DISPLAY_NAME,
316            ])
317            .output()
318            .context("failed to run sc.exe to create service")?;
319
320        if !output.status.success() {
321            let stderr = String::from_utf8_lossy(&output.stderr);
322            anyhow::bail!("Failed to create service: {}", stderr);
323        }
324
325        // Configure failure recovery (restart on failure)
326        let _ = std::process::Command::new("sc")
327            .args([
328                "failure",
329                SERVICE_NAME,
330                "reset=",
331                "86400", // Reset failure count after 24 hours
332                "actions=",
333                "restart/30000/restart/60000/restart/120000", // Restart with increasing delays
334            ])
335            .output();
336
337        // Configure service description
338        let desc = "Hermes Agent Gateway - Messaging platform integration service";
339        let _ = std::process::Command::new("sc").args(["description", SERVICE_NAME, desc]).output();
340
341        info!("installed Hermes gateway service");
342        Ok(())
343    }
344
345    #[cfg(not(target_os = "windows"))]
346    {
347        let _ = bin_path; // suppress unused warning
348        anyhow::bail!("Windows service installation is only available on Windows");
349    }
350}
351
352/// Uninstall the gateway Windows service
353pub fn uninstall_service() -> Result<()> {
354    #[cfg(target_os = "windows")]
355    {
356        // Stop the service first if running
357        let _ = stop_service();
358
359        // Delete the service
360        let output = std::process::Command::new("sc")
361            .args(["delete", SERVICE_NAME])
362            .output()
363            .context("failed to run sc.exe to delete service")?;
364
365        if !output.status.success() {
366            let stderr = String::from_utf8_lossy(&output.stderr);
367            // If service doesn't exist, that's fine
368            if !stderr.contains("does not exist") && !stderr.contains("1060") {
369                anyhow::bail!("Failed to delete service: {}", stderr);
370            }
371        }
372
373        info!("uninstalled Hermes gateway service");
374        Ok(())
375    }
376
377    #[cfg(not(target_os = "windows"))]
378    {
379        anyhow::bail!("Windows service uninstallation is only available on Windows");
380    }
381}
382
383/// Start the gateway Windows service
384pub fn start_service() -> Result<()> {
385    #[cfg(target_os = "windows")]
386    {
387        let output = std::process::Command::new("sc")
388            .args(["start", SERVICE_NAME])
389            .output()
390            .context("failed to run sc.exe to start service")?;
391
392        if !output.status.success() {
393            let stderr = String::from_utf8_lossy(&output.stderr);
394            anyhow::bail!("Failed to start service: {}", stderr);
395        }
396        info!("started Hermes gateway service");
397        Ok(())
398    }
399
400    #[cfg(not(target_os = "windows"))]
401    {
402        anyhow::bail!("Windows service start is only available on Windows");
403    }
404}
405
406/// Stop the gateway Windows service
407pub fn stop_service() -> Result<()> {
408    #[cfg(target_os = "windows")]
409    {
410        let output = std::process::Command::new("sc")
411            .args(["stop", SERVICE_NAME])
412            .output()
413            .context("failed to run sc.exe to stop service")?;
414
415        // SC returns error 1062 if service is already stopped - that's OK
416        let stderr = String::from_utf8_lossy(&output.stderr);
417        if !output.status.success() && !stderr.contains("1062") && !stderr.contains("not started") {
418            let stdout = String::from_utf8_lossy(&output.stdout);
419            if !stdout.contains("not started") {
420                // Don't fail if service is already stopped
421            }
422        }
423        info!("stopped Hermes gateway service");
424        Ok(())
425    }
426
427    #[cfg(not(target_os = "windows"))]
428    {
429        anyhow::bail!("Windows service stop is only available on Windows");
430    }
431}
432
433/// Service status enum
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435pub enum ServiceStatus {
436    Running,
437    Stopped,
438    Paused,
439    StartPending,
440    StopPending,
441    NotFound,
442    NotApplicable,
443}
444
445impl std::fmt::Display for ServiceStatus {
446    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447        match self {
448            ServiceStatus::Running => write!(f, "running"),
449            ServiceStatus::Stopped => write!(f, "stopped"),
450            ServiceStatus::Paused => write!(f, "paused"),
451            ServiceStatus::StartPending => write!(f, "start_pending"),
452            ServiceStatus::StopPending => write!(f, "stop_pending"),
453            ServiceStatus::NotFound => write!(f, "not_found"),
454            ServiceStatus::NotApplicable => write!(f, "not_applicable"),
455        }
456    }
457}
458
459// =============================================================================
460// Tests
461// =============================================================================
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use std::str::FromStr;
467
468    #[test]
469    fn test_platform_parse() {
470        assert_eq!(Platform::parse("cli"), Some(Platform::Cli));
471        assert_eq!(Platform::parse("telegram"), Some(Platform::Telegram));
472        assert_eq!(Platform::parse("TG"), Some(Platform::Telegram));
473        assert_eq!(Platform::parse("discord"), Some(Platform::Discord));
474        assert_eq!(Platform::parse("slack"), Some(Platform::Slack));
475        assert_eq!(Platform::parse("whatsapp"), Some(Platform::WhatsApp));
476        assert_eq!(Platform::parse("webhook"), Some(Platform::Webhook));
477        assert_eq!(Platform::parse("unknown"), None);
478    }
479
480    #[test]
481    fn test_platform_from_str() {
482        assert!(Platform::from_str("cli").is_ok());
483        assert!(Platform::from_str("unknown").is_err());
484    }
485
486    #[test]
487    fn test_platform_as_str() {
488        assert_eq!(Platform::Telegram.as_str(), "telegram");
489        assert_eq!(Platform::Discord.as_str(), "discord");
490        assert_eq!(Platform::WhatsApp.as_str(), "whatsapp");
491        assert_eq!(Platform::Webhook.as_str(), "webhook");
492    }
493
494    #[test]
495    fn test_platform_display() {
496        assert_eq!(format!("{}", Platform::Cli), "cli");
497        assert_eq!(format!("{}", Platform::Telegram), "telegram");
498    }
499
500    #[test]
501    fn test_platform_all() {
502        let platforms = Platform::all();
503        assert!(platforms.len() >= 5);
504        assert!(platforms.contains(&Platform::Cli));
505        assert!(platforms.contains(&Platform::Telegram));
506    }
507
508    #[test]
509    fn test_gateway_state_default() {
510        let state = GatewayState::default();
511        assert_eq!(state.gateway_state, "stopped");
512        assert_eq!(state.active_agents, 0);
513        assert!(!state.restart_requested);
514    }
515
516    #[test]
517    fn test_gateway_state_serialization() {
518        let state = GatewayState::default();
519        let json = serde_json::to_string_pretty(&state).unwrap();
520        let parsed: GatewayState = serde_json::from_str(&json).unwrap();
521        assert_eq!(parsed.gateway_state, "stopped");
522        assert_eq!(parsed.active_agents, 0);
523    }
524
525    #[test]
526    fn test_service_status_display() {
527        assert_eq!(format!("{}", ServiceStatus::Running), "running");
528        assert_eq!(format!("{}", ServiceStatus::Stopped), "stopped");
529        assert_eq!(format!("{}", ServiceStatus::NotFound), "not_found");
530        assert_eq!(format!("{}", ServiceStatus::NotApplicable), "not_applicable");
531    }
532
533    #[test]
534    fn test_pid_path() {
535        let path = gateway_pid_path();
536        assert!(path.to_string_lossy().contains("gateway.pid"));
537    }
538
539    #[test]
540    fn test_state_path() {
541        let path = gateway_state_path();
542        assert!(path.to_string_lossy().contains("gateway_state.json"));
543    }
544}