use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use crate::core::config::base_dir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRegistration {
pub session_id: String,
pub name: Option<String>,
pub socket_path: String,
pub pid: u32,
pub started_at: DateTime<Utc>,
}
pub fn registry_dir() -> PathBuf {
let dir = base_dir().join("run");
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!("registry: failed to create run dir {:?}: {}", dir, e);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
dir
}
pub fn sanitize_session_id(raw: &str) -> String {
raw.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect::<String>()
}
pub fn socket_path_for_session(session_id: &str) -> String {
let safe_id = sanitize_session_id(session_id);
registry_dir().join(format!("{}.sock", safe_id))
.to_string_lossy()
.into_owned()
}
pub fn register_session(reg: &SessionRegistration) -> Result<(), String> {
register_session_in(reg, ®istry_dir())
}
fn register_session_in(reg: &SessionRegistration, dir: &std::path::Path) -> Result<(), String> {
let safe_id = sanitize_session_id(®.session_id);
let path = dir.join(format!("{}.json", safe_id));
let tmp = path.with_extension("tmp");
let json = serde_json::to_string(reg)
.map_err(|e| format!("serialize error: {}", e))?;
std::fs::write(&tmp, &json)
.map_err(|e| format!("write error: {}", e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, &path)
.map_err(|e| format!("rename error: {}", e))?;
Ok(())
}
pub fn unregister_session(session_id: &str) {
unregister_session_in(session_id, ®istry_dir());
}
fn unregister_session_in(session_id: &str, dir: &std::path::Path) {
let safe_id = sanitize_session_id(session_id);
let path = dir.join(format!("{}.json", safe_id));
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) {
let sock = std::path::Path::new(®.socket_path);
if sock.starts_with(dir) && sock.extension().is_some_and(|e| e == "sock") {
let _ = std::fs::remove_file(sock);
}
}
}
let _ = std::fs::remove_file(&path);
}
fn pid_is_alive(pid: u32) -> bool {
#[cfg(unix)]
{
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0
}
#[cfg(not(unix))]
{
let _ = pid;
true
}
}
pub fn list_active_sessions() -> Vec<SessionRegistration> {
list_active_sessions_in(®istry_dir())
}
fn list_active_sessions_in(dir: &std::path::Path) -> Vec<SessionRegistration> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut live = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
let Ok(content) = std::fs::read_to_string(&path) else { continue };
let Ok(reg) = serde_json::from_str::<SessionRegistration>(&content) else {
let _ = std::fs::remove_file(&path);
continue;
};
if pid_is_alive(reg.pid) {
live.push(reg);
} else {
let _ = std::fs::remove_file(std::path::Path::new(®.socket_path));
let _ = std::fs::remove_file(&path);
}
}
}
live
}
pub fn find_session_registration(query: &str) -> Option<SessionRegistration> {
find_session_registration_in(query, ®istry_dir())
}
fn find_session_registration_in(query: &str, dir: &std::path::Path) -> Option<SessionRegistration> {
let sessions = list_active_sessions_in(dir);
if let Some(reg) = sessions.iter().find(|r| r.session_id == query) {
return Some(reg.clone());
}
if let Some(reg) = sessions.iter().find(|r| r.name.as_deref() == Some(query)) {
return Some(reg.clone());
}
let matches: Vec<_> = sessions
.iter()
.filter(|r| r.session_id.starts_with(query))
.collect();
if matches.len() == 1 {
Some(matches[0].clone())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::TempDir;
fn tmp_registry() -> TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path()).unwrap();
dir
}
fn make_reg(id: &str, name: Option<&str>, pid: u32) -> SessionRegistration {
SessionRegistration {
session_id: id.to_string(),
name: name.map(|s| s.to_string()),
socket_path: socket_path_for_session(id),
pid,
started_at: Utc::now(),
}
}
fn dir_buf(tmp: &TempDir) -> PathBuf {
tmp.path().to_path_buf()
}
#[test]
fn register_creates_file() {
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
let reg = make_reg("abc-1234", None, std::process::id());
register_session_in(®, &dir).unwrap();
assert!(dir.join("abc-1234.json").exists());
}
#[test]
fn list_returns_live_sessions() {
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
let pid = std::process::id();
let reg = make_reg("live-0001", Some("my-agent"), pid);
register_session_in(®, &dir).unwrap();
let sessions = list_active_sessions_in(&dir);
assert!(sessions.iter().any(|r| r.session_id == "live-0001"));
}
#[test]
fn find_by_exact_id() {
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
let reg = make_reg("find-exact-01", None, std::process::id());
register_session_in(®, &dir).unwrap();
let found = find_session_registration_in("find-exact-01", &dir);
assert!(found.is_some());
assert_eq!(found.unwrap().session_id, "find-exact-01");
}
#[test]
fn find_by_name() {
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
let reg = make_reg("named-session-01", Some("prod-agent"), std::process::id());
register_session_in(®, &dir).unwrap();
let found = find_session_registration_in("prod-agent", &dir);
assert!(found.is_some());
assert_eq!(found.unwrap().session_id, "named-session-01");
}
#[test]
fn find_by_partial_prefix() {
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
let reg = make_reg("prefix-abcdef-01", None, std::process::id());
register_session_in(®, &dir).unwrap();
let found = find_session_registration_in("prefix-abc", &dir);
assert!(found.is_some());
assert_eq!(found.unwrap().session_id, "prefix-abcdef-01");
}
#[test]
fn ambiguous_prefix_returns_none() {
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
let pid = std::process::id();
register_session_in(&make_reg("dup-aaaa-01", None, pid), &dir).unwrap();
register_session_in(&make_reg("dup-aaaa-02", None, pid), &dir).unwrap();
let found = find_session_registration_in("dup-aaaa", &dir);
assert!(found.is_none(), "ambiguous prefix should return None");
}
#[test]
fn unregister_removes_file() {
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
let reg = make_reg("unreg-0001", None, std::process::id());
register_session_in(®, &dir).unwrap();
let path = dir.join("unreg-0001.json");
assert!(path.exists());
unregister_session_in("unreg-0001", &dir);
assert!(!path.exists());
}
#[test]
fn unregister_is_idempotent() {
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
unregister_session_in("ghost-session-99", &dir);
}
#[test]
fn stale_pid_pruned() {
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
let reg = make_reg("stale-dead-pid", None, 999999);
register_session_in(®, &dir).unwrap();
let sessions = list_active_sessions_in(&dir);
assert!(
!sessions.iter().any(|r| r.session_id == "stale-dead-pid"),
"stale registration should have been pruned"
);
assert!(!dir.join("stale-dead-pid.json").exists());
}
#[test]
#[serial]
fn socket_path_format() {
let path = socket_path_for_session("20240101-120000-ab12");
assert!(path.ends_with("/run/20240101-120000-ab12.sock"), "got: {}", path);
assert!(!path.contains("/tmp/"), "socket should not be in /tmp");
}
#[cfg(unix)]
#[test]
fn registration_file_is_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = tmp_registry();
let dir = dir_buf(&tmp);
let reg = make_reg("perms-check-01", None, std::process::id());
register_session_in(®, &dir).unwrap();
let path = dir.join("perms-check-01.json");
let perms = std::fs::metadata(&path).unwrap().permissions();
assert_eq!(perms.mode() & 0o777, 0o600, "registry file should be 0600");
}
}