synaps_cli/events/
registry.rs1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use chrono::{DateTime, Utc};
4
5use crate::core::config::base_dir;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SessionRegistration {
9 pub session_id: String,
10 pub name: Option<String>,
11 pub socket_path: String,
12 pub pid: u32,
13 pub started_at: DateTime<Utc>,
14}
15
16pub fn registry_dir() -> PathBuf {
18 let dir = base_dir().join("run");
19 if let Err(e) = std::fs::create_dir_all(&dir) {
20 tracing::warn!("registry: failed to create run dir {:?}: {}", dir, e);
21 }
22 #[cfg(unix)]
23 {
24 use std::os::unix::fs::PermissionsExt;
25 let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
26 }
27 dir
28}
29
30pub fn sanitize_session_id(raw: &str) -> String {
34 raw.chars()
39 .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
40 .collect::<String>()
41}
42
43pub fn socket_path_for_session(session_id: &str) -> String {
47 let safe_id = sanitize_session_id(session_id);
48 registry_dir().join(format!("{}.sock", safe_id))
49 .to_string_lossy()
50 .into_owned()
51}
52
53pub fn register_session(reg: &SessionRegistration) -> Result<(), String> {
55 register_session_in(reg, ®istry_dir())
56}
57
58fn register_session_in(reg: &SessionRegistration, dir: &std::path::Path) -> Result<(), String> {
59 let safe_id = sanitize_session_id(®.session_id);
60 let path = dir.join(format!("{}.json", safe_id));
61 let tmp = path.with_extension("tmp");
62
63 let json = serde_json::to_string(reg)
64 .map_err(|e| format!("serialize error: {}", e))?;
65
66 std::fs::write(&tmp, &json)
67 .map_err(|e| format!("write error: {}", e))?;
68
69 #[cfg(unix)]
70 {
71 use std::os::unix::fs::PermissionsExt;
72 let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
73 }
74
75 std::fs::rename(&tmp, &path)
76 .map_err(|e| format!("rename error: {}", e))?;
77
78 Ok(())
79}
80
81pub fn unregister_session(session_id: &str) {
84 unregister_session_in(session_id, ®istry_dir());
85}
86
87fn unregister_session_in(session_id: &str, dir: &std::path::Path) {
88 let safe_id = sanitize_session_id(session_id);
89 let path = dir.join(format!("{}.json", safe_id));
90
91 if let Ok(content) = std::fs::read_to_string(&path) {
93 if let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) {
94 let sock = std::path::Path::new(®.socket_path);
95 if sock.starts_with(dir) && sock.extension().is_some_and(|e| e == "sock") {
98 let _ = std::fs::remove_file(sock);
99 }
100 }
101 }
102
103 let _ = std::fs::remove_file(&path);
104}
105
106fn pid_is_alive(pid: u32) -> bool {
108 #[cfg(unix)]
109 {
110 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
113 result == 0
114 }
115 #[cfg(not(unix))]
116 {
117 let _ = pid;
118 true
119 }
120}
121
122pub fn list_active_sessions() -> Vec<SessionRegistration> {
124 list_active_sessions_in(®istry_dir())
125}
126
127fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
128 let Ok(entries) = std::fs::read_dir(dir) else {
129 return Vec::new();
130 };
131
132 let mut live = Vec::new();
133
134 for entry in entries.flatten() {
135 let path = entry.path();
136 if path.extension().is_some_and(|e| e == "json") {
137 let Ok(content) = std::fs::read_to_string(&path) else { continue };
138 let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) else {
139 let _ = std::fs::remove_file(&path);
140 continue;
141 };
142
143 if pid_is_alive(reg.pid) {
144 live.push(reg);
145 } else {
146 let _ = std::fs::remove_file(std::path::Path::new(®.socket_path));
147 let _ = std::fs::remove_file(&path);
148 }
149 }
150 }
151
152 live
153}
154
155pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
160 find_session_registration_in(query, ®istry_dir())
161}
162
163fn find_session_registration_in(query: &str, dir: &std::path::Path) -> Option<SessionRegistration> {
164 let sessions = list_active_sessions_in(dir);
165
166 if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
168 return Some(reg.clone());
169 }
170
171 if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
173 return Some(reg.clone());
174 }
175
176 let matches: Vec<_> = sessions
178 .iter()
179 .filter(|r| r.session_id.starts_with(query))
180 .collect();
181
182 if matches.len() == 1 {
183 Some(matches[0].clone())
184 } else {
185 None
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use serial_test::serial;
193 use tempfile::TempDir;
194
195 fn tmp_registry() -> TempDir {
196 let dir = tempfile::tempdir().unwrap();
197 std::fs::create_dir_all(dir.path()).unwrap();
198 dir
199 }
200
201 fn make_reg(id: &str, name: Option<&str>, pid: u32) -> SessionRegistration {
202 SessionRegistration {
203 session_id: id.to_string(),
204 name: name.map(|s| s.to_string()),
205 socket_path: socket_path_for_session(id),
206 pid,
207 started_at: Utc::now(),
208 }
209 }
210
211 fn dir_buf(tmp: &TempDir) -> PathBuf {
212 tmp.path().to_path_buf()
213 }
214
215 #[test]
216 fn register_creates_file() {
217 let tmp = tmp_registry();
218 let dir = dir_buf(&tmp);
219 let reg = make_reg("abc-1234", None, std::process::id());
220 register_session_in(®, &dir).unwrap();
221 assert!(dir.join("abc-1234.json").exists());
222 }
223
224 #[test]
225 fn list_returns_live_sessions() {
226 let tmp = tmp_registry();
227 let dir = dir_buf(&tmp);
228 let pid = std::process::id();
229 let reg = make_reg("live-0001", Some("my-agent"), pid);
230 register_session_in(®, &dir).unwrap();
231
232 let sessions = list_active_sessions_in(&dir);
233 assert!(sessions.iter().any(|r| r.session_id == "live-0001"));
234 }
235
236 #[test]
237 fn find_by_exact_id() {
238 let tmp = tmp_registry();
239 let dir = dir_buf(&tmp);
240 let reg = make_reg("find-exact-01", None, std::process::id());
241 register_session_in(®, &dir).unwrap();
242
243 let found = find_session_registration_in("find-exact-01", &dir);
244 assert!(found.is_some());
245 assert_eq!(found.unwrap().session_id, "find-exact-01");
246 }
247
248 #[test]
249 fn find_by_name() {
250 let tmp = tmp_registry();
251 let dir = dir_buf(&tmp);
252 let reg = make_reg("named-session-01", Some("prod-agent"), std::process::id());
253 register_session_in(®, &dir).unwrap();
254
255 let found = find_session_registration_in("prod-agent", &dir);
256 assert!(found.is_some());
257 assert_eq!(found.unwrap().session_id, "named-session-01");
258 }
259
260 #[test]
261 fn find_by_partial_prefix() {
262 let tmp = tmp_registry();
263 let dir = dir_buf(&tmp);
264 let reg = make_reg("prefix-abcdef-01", None, std::process::id());
265 register_session_in(®, &dir).unwrap();
266
267 let found = find_session_registration_in("prefix-abc", &dir);
268 assert!(found.is_some());
269 assert_eq!(found.unwrap().session_id, "prefix-abcdef-01");
270 }
271
272 #[test]
273 fn ambiguous_prefix_returns_none() {
274 let tmp = tmp_registry();
275 let dir = dir_buf(&tmp);
276 let pid = std::process::id();
277 register_session_in(&make_reg("dup-aaaa-01", None, pid), &dir).unwrap();
278 register_session_in(&make_reg("dup-aaaa-02", None, pid), &dir).unwrap();
279
280 let found = find_session_registration_in("dup-aaaa", &dir);
281 assert!(found.is_none(), "ambiguous prefix should return None");
282 }
283
284 #[test]
285 fn unregister_removes_file() {
286 let tmp = tmp_registry();
287 let dir = dir_buf(&tmp);
288 let reg = make_reg("unreg-0001", None, std::process::id());
289 register_session_in(®, &dir).unwrap();
290
291 let path = dir.join("unreg-0001.json");
292 assert!(path.exists());
293
294 unregister_session_in("unreg-0001", &dir);
295 assert!(!path.exists());
296 }
297
298 #[test]
299 fn unregister_is_idempotent() {
300 let tmp = tmp_registry();
301 let dir = dir_buf(&tmp);
302 unregister_session_in("ghost-session-99", &dir);
304 }
305
306 #[test]
307 fn stale_pid_pruned() {
308 let tmp = tmp_registry();
309 let dir = dir_buf(&tmp);
310 let reg = make_reg("stale-dead-pid", None, 999999);
312 register_session_in(®, &dir).unwrap();
313
314 let sessions = list_active_sessions_in(&dir);
315 assert!(
316 !sessions.iter().any(|r| r.session_id == "stale-dead-pid"),
317 "stale registration should have been pruned"
318 );
319
320 assert!(!dir.join("stale-dead-pid.json").exists());
322 }
323
324 #[test]
325 #[serial]
326 fn socket_path_format() {
327 let path = socket_path_for_session("20240101-120000-ab12");
328 assert!(path.ends_with("/run/20240101-120000-ab12.sock"), "got: {}", path);
330 assert!(!path.contains("/tmp/"), "socket should not be in /tmp");
331 }
332
333 #[cfg(unix)]
334 #[test]
335 fn registration_file_is_0600() {
336 use std::os::unix::fs::PermissionsExt;
337 let tmp = tmp_registry();
338 let dir = dir_buf(&tmp);
339 let reg = make_reg("perms-check-01", None, std::process::id());
340 register_session_in(®, &dir).unwrap();
341
342 let path = dir.join("perms-check-01.json");
343 let perms = std::fs::metadata(&path).unwrap().permissions();
344 assert_eq!(perms.mode() & 0o777, 0o600, "registry file should be 0600");
345 }
346}