gate4agent_wake/
registry.rs1use std::fs;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, SystemTime};
15
16use serde::{Deserialize, Serialize};
17
18use super::inbox::entry_stem;
19use super::{ProviderKind, ProviderSession, SessionKind, Surface};
20
21pub const REGISTRY_DIR: &str = "provider-sessions";
23pub const INBOX_ROOT: &str = "inbox";
25pub const RECORD_STALE: Duration = Duration::from_secs(7 * 24 * 3600);
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct SessionRecord {
31 pub provider: String,
33 #[serde(default = "local")]
35 pub surface: String,
36 pub session_id: String,
38 pub nick: String,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub cwd: Option<PathBuf>,
43 #[serde(default)]
45 pub headless: bool,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub pid: Option<u32>,
49}
50
51fn local() -> String {
52 "local".to_string()
53}
54
55impl SessionRecord {
56 pub fn session(&self) -> Option<ProviderSession> {
58 let provider = ProviderKind::parse(&self.provider)?;
59 let surface = if self.surface.eq_ignore_ascii_case("web") {
60 Surface::Web
61 } else {
62 Surface::Local
63 };
64 Some(ProviderSession {
65 kind: SessionKind { provider, surface },
66 session_id: self.session_id.clone(),
67 nick: self.nick.clone(),
68 cwd: self.cwd.clone(),
69 headless: self.headless,
70 })
71 }
72}
73
74pub fn inbox_dir(store_root: &Path, session_id: &str) -> PathBuf {
76 store_root.join(INBOX_ROOT).join(entry_stem(session_id))
77}
78
79pub fn register(store_root: &Path, record: &SessionRecord) -> std::io::Result<PathBuf> {
81 let dir = store_root.join(REGISTRY_DIR);
82 fs::create_dir_all(&dir)?;
83 #[cfg(unix)]
84 {
85 use std::os::unix::fs::PermissionsExt;
86 let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700));
87 }
88 let stem = entry_stem(&record.session_id);
89 let path = dir.join(format!("{stem}.json"));
90 let tmp = dir.join(format!(".{stem}.tmp"));
91 fs::write(
92 &tmp,
93 serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?,
94 )?;
95 fs::rename(&tmp, &path)?;
96 Ok(path)
97}
98
99pub fn unregister(store_root: &Path, session_id: &str) {
101 let path = store_root
102 .join(REGISTRY_DIR)
103 .join(format!("{}.json", entry_stem(session_id)));
104 let _ = fs::remove_file(path);
105}
106
107fn pid_alive(pid: u32) -> bool {
108 if cfg!(target_os = "linux") {
109 Path::new(&format!("/proc/{pid}")).exists()
110 } else {
111 true
112 }
113}
114
115pub fn live_sessions(store_root: &Path) -> Vec<ProviderSession> {
117 let Ok(read) = fs::read_dir(store_root.join(REGISTRY_DIR)) else {
118 return Vec::new();
119 };
120 let mut out: Vec<ProviderSession> = read
121 .filter_map(Result::ok)
122 .filter(|item| {
123 let name = item.file_name().to_string_lossy().into_owned();
124 !name.starts_with('.') && name.ends_with(".json")
125 })
126 .filter(|item| {
127 item.metadata()
128 .and_then(|meta| meta.modified())
129 .ok()
130 .and_then(|modified| SystemTime::now().duration_since(modified).ok())
131 .is_none_or(|age| age <= RECORD_STALE)
132 })
133 .filter_map(|item| fs::read(item.path()).ok())
134 .filter_map(|bytes| serde_json::from_slice::<SessionRecord>(&bytes).ok())
135 .filter(|record| record.pid.is_none_or(pid_alive))
136 .filter_map(|record| record.session())
137 .collect();
138 out.sort_by(|a, b| a.session_id.cmp(&b.session_id));
139 out
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn register_list_unregister() {
148 let root = std::env::temp_dir().join(format!("m4a-reg-{}", std::process::id()));
149 let _ = fs::remove_dir_all(&root);
150 let record = SessionRecord {
151 provider: "codex".into(),
152 surface: "local".into(),
153 session_id: "thr/1".into(),
154 nick: "builder".into(),
155 cwd: Some("/tmp".into()),
156 headless: false,
157 pid: Some(std::process::id()),
158 };
159 register(&root, &record).unwrap();
160 register(
161 &root,
162 &SessionRecord {
163 provider: "gemini".into(),
164 session_id: "x".into(),
165 ..record.clone()
166 },
167 )
168 .unwrap();
169 let live = live_sessions(&root);
170 assert_eq!(live.len(), 1);
171 assert_eq!(live[0].kind, SessionKind::local(ProviderKind::Codex));
172 assert_eq!(live[0].nick, "builder");
173 assert!(inbox_dir(&root, "thr/1").ends_with("inbox/thr_1"));
174 unregister(&root, "thr/1");
175 unregister(&root, "x");
176 assert!(live_sessions(&root).is_empty());
177 let _ = fs::remove_dir_all(&root);
178 }
179}