Skip to main content

gate4agent_wake/
registry.rs

1//! Sessions that announced themselves to the local client.
2//!
3//! A provider session registers from inside itself (a `SessionStart` hook
4//! or the agent running `m4a-inbox register`): one JSON file per session
5//! under `<store root>/provider-sessions/`. The local client adopts every
6//! live record, opens its mail store, and wakes it through
7//! [`super::chain::plan_chain`]. Grok sessions keep their own index
8//! (`active_sessions.json`) and the leader path; they are not listed here.
9//!
10//! Records hold ids and paths only. No bearer, no URL with a key.
11
12use 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
21/// Directory under the store root.
22pub const REGISTRY_DIR: &str = "provider-sessions";
23/// Directory under the store root holding one inbox per session.
24pub const INBOX_ROOT: &str = "inbox";
25/// A record not refreshed for this long is treated as gone.
26pub const RECORD_STALE: Duration = Duration::from_secs(7 * 24 * 3600);
27
28/// On-disk record.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct SessionRecord {
31    /// Provider id ([`ProviderKind::id`]).
32    pub provider: String,
33    /// `local` or `web`.
34    #[serde(default = "local")]
35    pub surface: String,
36    /// Vendor session / thread / chat id.
37    pub session_id: String,
38    /// mail4agent nick.
39    pub nick: String,
40    /// Working directory.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub cwd: Option<PathBuf>,
43    /// No client holds the session open (spawn fallback allowed).
44    #[serde(default)]
45    pub headless: bool,
46    /// Pid of the client process, when known (liveness on Linux).
47    #[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    /// The session, or `None` for an unknown provider.
57    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
74/// Inbox of one session under `store_root`.
75pub fn inbox_dir(store_root: &Path, session_id: &str) -> PathBuf {
76    store_root.join(INBOX_ROOT).join(entry_stem(session_id))
77}
78
79/// Writes (or refreshes) `record`. Owner-only on unix.
80pub 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
99/// Removes a record.
100pub 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
115/// Live records: parsed, known provider, fresh, and (Linux) pid alive.
116pub 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}