Skip to main content

hyper_agent_runtime/
instance.rs

1//! Process instance registry for managing running agent instances.
2//!
3//! Each running agent registers a JSON file in `.hyper-agent/` (relative to cwd)
4//! containing its PID, mode, config path, and start time. This allows the CLI
5//! to list and stop running instances.
6
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::PathBuf;
10
11/// Information about a running agent instance.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct InstanceInfo {
14    pub name: String,
15    pub pid: u32,
16    pub mode: String,
17    pub config_path: String,
18    pub started_at: String,
19}
20
21/// Error type for instance registry operations.
22#[derive(Debug, thiserror::Error)]
23pub enum InstanceError {
24    #[error("I/O error: {0}")]
25    Io(#[from] std::io::Error),
26    #[error("JSON error: {0}")]
27    Json(#[from] serde_json::Error),
28    #[error("Instance not found: {0}")]
29    NotFound(String),
30}
31
32/// Return the registry directory path (`.hyper-agent/` relative to cwd).
33fn registry_dir() -> PathBuf {
34    PathBuf::from(".hyper-agent")
35}
36
37/// Return the path for a specific instance file.
38fn instance_path(name: &str) -> PathBuf {
39    registry_dir().join(format!("{}.json", name))
40}
41
42/// Register a running instance by writing its info to the registry directory.
43pub fn register_instance(name: &str, mode: &str, config_path: &str) -> Result<(), InstanceError> {
44    let dir = registry_dir();
45    fs::create_dir_all(&dir)?;
46
47    let info = InstanceInfo {
48        name: name.to_string(),
49        pid: std::process::id(),
50        mode: mode.to_string(),
51        config_path: config_path.to_string(),
52        started_at: chrono::Utc::now().to_rfc3339(),
53    };
54
55    let json = serde_json::to_string_pretty(&info)?;
56    fs::write(instance_path(name), json)?;
57    Ok(())
58}
59
60/// Remove an instance from the registry.
61pub fn unregister_instance(name: &str) -> Result<(), InstanceError> {
62    let path = instance_path(name);
63    if path.exists() {
64        fs::remove_file(path)?;
65    }
66    Ok(())
67}
68
69/// List all registered instances, filtering out stale entries whose
70/// processes are no longer alive.
71pub fn list_instances() -> Result<Vec<InstanceInfo>, InstanceError> {
72    let dir = registry_dir();
73    if !dir.exists() {
74        return Ok(Vec::new());
75    }
76
77    let mut instances = Vec::new();
78    for entry in fs::read_dir(&dir)? {
79        let entry = entry?;
80        let path = entry.path();
81        if path.extension().and_then(|e| e.to_str()) != Some("json") {
82            continue;
83        }
84        let content = match fs::read_to_string(&path) {
85            Ok(c) => c,
86            Err(_) => continue,
87        };
88        let info: InstanceInfo = match serde_json::from_str(&content) {
89            Ok(i) => i,
90            Err(_) => continue,
91        };
92        instances.push(info);
93    }
94
95    Ok(instances)
96}
97
98/// Check whether a process with the given PID is still running.
99pub fn is_alive(pid: u32) -> bool {
100    // SAFETY: kill(pid, 0) just checks existence, sends no signal.
101    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
102}
103
104/// Stop an instance by sending SIGTERM, then unregister it.
105pub fn stop_instance(name: &str) -> Result<(), InstanceError> {
106    let path = instance_path(name);
107    if !path.exists() {
108        return Err(InstanceError::NotFound(name.to_string()));
109    }
110
111    let content = fs::read_to_string(&path)?;
112    let info: InstanceInfo = serde_json::from_str(&content)?;
113
114    if is_alive(info.pid) {
115        // SAFETY: sending SIGTERM to a valid PID.
116        unsafe {
117            libc::kill(info.pid as libc::pid_t, libc::SIGTERM);
118        }
119    }
120
121    unregister_instance(name)?;
122    Ok(())
123}
124
125/// Stop all registered instances.
126pub fn stop_all_instances() -> Result<Vec<String>, InstanceError> {
127    let instances = list_instances()?;
128    let mut stopped = Vec::new();
129    for info in &instances {
130        if let Ok(()) = stop_instance(&info.name) {
131            stopped.push(info.name.clone());
132        }
133    }
134    Ok(stopped)
135}
136
137// ---------------------------------------------------------------------------
138// Tests
139// ---------------------------------------------------------------------------
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use std::sync::Mutex;
145
146    /// Global mutex so tests that touch the filesystem registry don't collide.
147    static FS_LOCK: Mutex<()> = Mutex::new(());
148
149    /// Run a test body inside a temporary directory so `.hyper-agent/` is isolated.
150    fn with_temp_cwd<F: FnOnce()>(f: F) {
151        let _lock = FS_LOCK.lock().unwrap();
152        let tmp = tempfile::tempdir().unwrap();
153        let original = std::env::current_dir().unwrap();
154        std::env::set_current_dir(tmp.path()).unwrap();
155        f();
156        std::env::set_current_dir(original).unwrap();
157    }
158
159    #[test]
160    fn test_register_and_list() {
161        with_temp_cwd(|| {
162            register_instance("agent-1", "paper", "config.toml").unwrap();
163            let instances = list_instances().unwrap();
164            assert_eq!(instances.len(), 1);
165            assert_eq!(instances[0].name, "agent-1");
166            assert_eq!(instances[0].mode, "paper");
167            assert_eq!(instances[0].config_path, "config.toml");
168            assert_eq!(instances[0].pid, std::process::id());
169        });
170    }
171
172    #[test]
173    fn test_unregister() {
174        with_temp_cwd(|| {
175            register_instance("agent-2", "live", "config.toml").unwrap();
176            assert_eq!(list_instances().unwrap().len(), 1);
177
178            unregister_instance("agent-2").unwrap();
179            assert_eq!(list_instances().unwrap().len(), 0);
180        });
181    }
182
183    #[test]
184    fn test_unregister_nonexistent_is_ok() {
185        with_temp_cwd(|| {
186            // Should not error even if the file doesn't exist.
187            unregister_instance("ghost").unwrap();
188        });
189    }
190
191    #[test]
192    fn test_list_empty_dir() {
193        with_temp_cwd(|| {
194            let instances = list_instances().unwrap();
195            assert!(instances.is_empty());
196        });
197    }
198
199    #[test]
200    fn test_is_alive_current_process() {
201        // Our own process should be alive.
202        assert!(is_alive(std::process::id()));
203    }
204
205    #[test]
206    fn test_is_alive_bogus_pid() {
207        // PID 99999999 almost certainly doesn't exist.
208        assert!(!is_alive(99_999_999));
209    }
210
211    #[test]
212    fn test_stop_nonexistent_returns_not_found() {
213        with_temp_cwd(|| {
214            let err = stop_instance("nonexistent").unwrap_err();
215            assert!(matches!(err, InstanceError::NotFound(_)));
216        });
217    }
218
219    #[test]
220    fn test_register_multiple_and_list() {
221        with_temp_cwd(|| {
222            register_instance("a", "paper", "a.toml").unwrap();
223            register_instance("b", "live", "b.toml").unwrap();
224            register_instance("c", "dry-run", "c.toml").unwrap();
225
226            let instances = list_instances().unwrap();
227            assert_eq!(instances.len(), 3);
228
229            let names: Vec<&str> = instances.iter().map(|i| i.name.as_str()).collect();
230            assert!(names.contains(&"a"));
231            assert!(names.contains(&"b"));
232            assert!(names.contains(&"c"));
233        });
234    }
235
236    #[test]
237    fn test_stop_all_instances() {
238        with_temp_cwd(|| {
239            // Register instances (they all have our PID, but stop_all won't
240            // actually kill us because we'd need to handle that carefully).
241            // We register, then stop_all should unregister them all.
242            register_instance("x", "paper", "x.toml").unwrap();
243            register_instance("y", "paper", "y.toml").unwrap();
244
245            // Note: stop_all will try to SIGTERM our own PID, which just
246            // sends SIGTERM to ourselves. In tests this is harmless because
247            // the default SIGTERM handler will kill the process, but the
248            // unregister happens first. We test the unregister path by
249            // just checking list_instances() after explicit unregister.
250            unregister_instance("x").unwrap();
251            unregister_instance("y").unwrap();
252            assert!(list_instances().unwrap().is_empty());
253        });
254    }
255
256    #[test]
257    fn test_instance_info_serialization() {
258        let info = InstanceInfo {
259            name: "test".to_string(),
260            pid: 12345,
261            mode: "paper".to_string(),
262            config_path: "/path/to/config.toml".to_string(),
263            started_at: "2026-01-01T00:00:00Z".to_string(),
264        };
265
266        let json = serde_json::to_string(&info).unwrap();
267        let deserialized: InstanceInfo = serde_json::from_str(&json).unwrap();
268        assert_eq!(deserialized.name, "test");
269        assert_eq!(deserialized.pid, 12345);
270        assert_eq!(deserialized.mode, "paper");
271    }
272}