hyper_agent_runtime/
instance.rs1use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::PathBuf;
10
11#[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#[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
32fn registry_dir() -> PathBuf {
34 PathBuf::from(".hyper-agent")
35}
36
37fn instance_path(name: &str) -> PathBuf {
39 registry_dir().join(format!("{}.json", name))
40}
41
42pub 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
60pub 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
69pub 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
98pub fn is_alive(pid: u32) -> bool {
100 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
102}
103
104pub 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 unsafe {
117 libc::kill(info.pid as libc::pid_t, libc::SIGTERM);
118 }
119 }
120
121 unregister_instance(name)?;
122 Ok(())
123}
124
125pub 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#[cfg(test)]
142mod tests {
143 use super::*;
144 use std::sync::Mutex;
145
146 static FS_LOCK: Mutex<()> = Mutex::new(());
148
149 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 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 assert!(is_alive(std::process::id()));
203 }
204
205 #[test]
206 fn test_is_alive_bogus_pid() {
207 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_instance("x", "paper", "x.toml").unwrap();
243 register_instance("y", "paper", "y.toml").unwrap();
244
245 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}