Skip to main content

macp_storage/
registry.rs

1use macp_core::session::Session;
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use tokio::sync::RwLock;
7
8#[derive(serde::Serialize, serde::Deserialize)]
9pub struct PersistedRoot {
10    pub uri: String,
11    pub name: String,
12}
13
14#[derive(serde::Serialize, serde::Deserialize)]
15pub struct PersistedSession {
16    #[serde(default = "default_schema_version")]
17    pub schema_version: u32,
18    pub session_id: String,
19    pub state: macp_core::session::SessionState,
20    pub ttl_expiry: i64,
21    #[serde(default)]
22    pub ttl_ms: i64,
23    pub started_at_unix_ms: i64,
24    pub resolution: Option<Vec<u8>>,
25    pub mode: String,
26    pub mode_state: Vec<u8>,
27    pub participants: Vec<String>,
28    pub seen_message_ids: Vec<String>,
29    pub intent: String,
30    pub mode_version: String,
31    pub configuration_version: String,
32    pub policy_version: String,
33    #[serde(default)]
34    pub context_id: String,
35    #[serde(default)]
36    pub extensions: HashMap<String, Vec<u8>>,
37    pub roots: Vec<PersistedRoot>,
38    pub initiator_sender: String,
39    #[serde(default)]
40    pub policy_definition: Option<macp_core::policy::PolicyDefinition>,
41    #[serde(default)]
42    pub suspended_at_ms: Option<i64>,
43    #[serde(default)]
44    pub accumulated_suspended_ms: i64,
45    /// Session-semantics revision (see `macp_core::session::CURRENT_SEMANTICS_REV`).
46    /// Legacy snapshots deserialize as 0 and keep legacy behavior.
47    #[serde(default)]
48    pub semantics_rev: u32,
49    /// Suspension cap bound at SessionStart. Legacy snapshots deserialize as
50    /// 0 (= default-cap semantics via `Session::effective_max_suspend_ms`).
51    #[serde(default)]
52    pub max_suspend_ms: i64,
53}
54
55fn default_schema_version() -> u32 {
56    2
57}
58
59impl From<&Session> for PersistedSession {
60    fn from(session: &Session) -> Self {
61        Self {
62            schema_version: 2,
63            session_id: session.session_id.clone(),
64            state: session.state.clone(),
65            ttl_expiry: session.ttl_expiry,
66            ttl_ms: session.ttl_ms,
67            started_at_unix_ms: session.started_at_unix_ms,
68            resolution: session.resolution.clone(),
69            mode: session.mode.clone(),
70            mode_state: session.mode_state.clone(),
71            participants: session.participants.clone(),
72            seen_message_ids: session.seen_message_ids.iter().cloned().collect(),
73            intent: session.intent.clone(),
74            mode_version: session.mode_version.clone(),
75            configuration_version: session.configuration_version.clone(),
76            policy_version: session.policy_version.clone(),
77            context_id: session.context_id.clone(),
78            extensions: session.extensions.clone(),
79            roots: session
80                .roots
81                .iter()
82                .map(|root| PersistedRoot {
83                    uri: root.uri.clone(),
84                    name: root.name.clone(),
85                })
86                .collect(),
87            initiator_sender: session.initiator_sender.clone(),
88            policy_definition: session.policy_definition.clone(),
89            suspended_at_ms: session.suspended_at_ms,
90            accumulated_suspended_ms: session.accumulated_suspended_ms,
91            semantics_rev: session.semantics_rev,
92            max_suspend_ms: session.max_suspend_ms,
93        }
94    }
95}
96
97impl From<PersistedSession> for Session {
98    fn from(session: PersistedSession) -> Self {
99        let ttl_ms = if session.ttl_ms > 0 {
100            session.ttl_ms
101        } else {
102            // Backward compatibility: compute from absolute timestamps
103            session
104                .ttl_expiry
105                .saturating_sub(session.started_at_unix_ms)
106        };
107        Session::builder(session.session_id, session.mode, session.initiator_sender)
108            .state(session.state)
109            .ttl_expiry(session.ttl_expiry)
110            .ttl_ms(ttl_ms)
111            .started_at_unix_ms(session.started_at_unix_ms)
112            .resolution(session.resolution)
113            .mode_state(session.mode_state)
114            .participants(session.participants)
115            .seen_message_ids(session.seen_message_ids.into_iter().collect())
116            .intent(session.intent)
117            .mode_version(session.mode_version)
118            .configuration_version(session.configuration_version)
119            .policy_version(session.policy_version)
120            .context_id(session.context_id)
121            .extensions(session.extensions)
122            .roots(
123                session
124                    .roots
125                    .into_iter()
126                    .map(|root| macp_pb::pb::Root {
127                        uri: root.uri,
128                        name: root.name,
129                    })
130                    .collect(),
131            )
132            .policy_definition(session.policy_definition)
133            .suspended_at_ms(session.suspended_at_ms)
134            .accumulated_suspended_ms(session.accumulated_suspended_ms)
135            .semantics_rev(session.semantics_rev)
136            .max_suspend_ms(session.max_suspend_ms)
137            .build()
138    }
139}
140
141/// A registered session behind its own async mutex. The registry map lock is
142/// held only for lookup/insert/remove; the per-session mutex serializes all
143/// processing (validate + storage append + commit) for that session ONLY โ€”
144/// RFC-MACP-0001 ยง8.1 requires acceptance serialization within a session,
145/// never across sessions. Lock ordering: map lock BEFORE session mutex, and
146/// never hold the map lock while awaiting a session mutex โ€” snapshot the
147/// `Arc`s, drop the map guard, then lock.
148pub type SharedSession = Arc<tokio::sync::Mutex<Session>>;
149
150pub struct SessionRegistry {
151    pub sessions: RwLock<HashMap<String, SharedSession>>,
152    persistence_path: Option<PathBuf>,
153}
154
155impl Default for SessionRegistry {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161impl SessionRegistry {
162    pub fn new() -> Self {
163        Self {
164            sessions: RwLock::new(HashMap::new()),
165            persistence_path: None,
166        }
167    }
168
169    pub fn with_persistence<P: AsRef<Path>>(dir: P) -> std::io::Result<Self> {
170        let dir = dir.as_ref().to_path_buf();
171        fs::create_dir_all(&dir)?;
172        let path = dir.join("sessions.json");
173        let sessions = Self::load_sessions(&path)?;
174        Ok(Self {
175            sessions: RwLock::new(sessions),
176            persistence_path: Some(path),
177        })
178    }
179
180    fn load_sessions(path: &Path) -> std::io::Result<HashMap<String, SharedSession>> {
181        if !path.exists() {
182            return Ok(HashMap::new());
183        }
184        let bytes = fs::read(path)?;
185        let persisted: HashMap<String, PersistedSession> = match serde_json::from_slice(&bytes) {
186            Ok(v) => v,
187            Err(e) => {
188                eprintln!("warning: failed to deserialize sessions from {}: {e}; starting with empty state", path.display());
189                HashMap::new()
190            }
191        };
192        Ok(persisted
193            .into_iter()
194            .map(|(id, session)| (id, Arc::new(tokio::sync::Mutex::new(session.into()))))
195            .collect())
196    }
197
198    fn persist_map(
199        path: &Path,
200        sessions: &HashMap<String, PersistedSession>,
201    ) -> std::io::Result<()> {
202        let bytes = serde_json::to_vec_pretty(sessions)?;
203        let tmp_path = path.with_extension("json.tmp");
204        fs::write(&tmp_path, bytes)?;
205        fs::rename(&tmp_path, path)
206    }
207
208    /// Snapshot every session (locking each briefly) and persist. Never holds
209    /// the map lock across the per-session locks or the fs write.
210    pub async fn persist_snapshot(&self) -> std::io::Result<()> {
211        let Some(path) = self.persistence_path.clone() else {
212            return Ok(());
213        };
214        let arcs: Vec<(String, SharedSession)> = {
215            let guard = self.sessions.read().await;
216            guard
217                .iter()
218                .map(|(id, arc)| (id.clone(), Arc::clone(arc)))
219                .collect()
220        };
221        let mut persisted = HashMap::with_capacity(arcs.len());
222        for (id, arc) in arcs {
223            let session = arc.lock().await;
224            persisted.insert(id, PersistedSession::from(&*session));
225        }
226        Self::persist_map(&path, &persisted)
227    }
228
229    /// Clone the shared handle for a session (brief map read; no session lock).
230    pub async fn get_shared(&self, session_id: &str) -> Option<SharedSession> {
231        let guard = self.sessions.read().await;
232        guard.get(session_id).cloned()
233    }
234
235    pub async fn get_session(&self, session_id: &str) -> Option<Session> {
236        let arc = self.get_shared(session_id).await?;
237        let session = arc.lock().await;
238        Some(session.clone())
239    }
240
241    pub async fn get_all_sessions(&self) -> Vec<Session> {
242        let arcs: Vec<SharedSession> = {
243            let guard = self.sessions.read().await;
244            guard.values().cloned().collect()
245        };
246        let mut out = Vec::with_capacity(arcs.len());
247        for arc in arcs {
248            out.push(arc.lock().await.clone());
249        }
250        out
251    }
252
253    pub async fn insert_recovered_session(&self, session_id: String, session: Session) {
254        {
255            let mut guard = self.sessions.write().await;
256            guard.insert(session_id, Arc::new(tokio::sync::Mutex::new(session)));
257        }
258        let _ = self.persist_snapshot().await;
259    }
260
261    pub async fn count_open_sessions_for_initiator(&self, sender: &str) -> usize {
262        let now = chrono::Utc::now().timestamp_millis();
263        let arcs: Vec<SharedSession> = {
264            let guard = self.sessions.read().await;
265            guard.values().cloned().collect()
266        };
267        let mut count = 0;
268        for arc in arcs {
269            // A session currently being processed is Open by definition โ€”
270            // count it (conservative for a rate limit) rather than await.
271            let counts = match arc.try_lock() {
272                Ok(session) => {
273                    session.initiator_sender == sender
274                        && session.state == macp_core::session::SessionState::Open
275                        && now <= session.ttl_expiry
276                }
277                Err(_) => true,
278            };
279            if counts {
280                count += 1;
281            }
282        }
283        count
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use macp_core::session::{Session, SessionState};
291    use std::collections::HashSet;
292    use std::time::{SystemTime, UNIX_EPOCH};
293
294    fn sample_session(id: &str) -> Session {
295        Session::builder(id, "macp.mode.decision.v1", "alice")
296            .ttl_expiry(10)
297            .ttl_ms(9)
298            .started_at_unix_ms(1)
299            .mode_state(vec![1, 2, 3])
300            .participants(vec!["alice".into()])
301            .seen_message_ids(HashSet::from(["m1".into()]))
302            .intent("intent")
303            .mode_version("1.0.0")
304            .configuration_version("cfg")
305            .policy_version("pol")
306            .context_id("test-ctx")
307            .roots(vec![macp_pb::pb::Root {
308                uri: "root://1".into(),
309                name: "r1".into(),
310            }])
311            .build()
312    }
313
314    #[tokio::test]
315    async fn expired_sessions_not_counted_against_limit() {
316        let registry = SessionRegistry::new();
317        let now = chrono::Utc::now().timestamp_millis();
318        // Insert a session with TTL already expired
319        let mut expired = sample_session("expired-s1");
320        expired.initiator_sender = "agent://alice".into();
321        expired.ttl_expiry = now - 1000; // expired 1 second ago
322        expired.state = SessionState::Open; // still Open but TTL is past
323        registry
324            .insert_recovered_session("expired-s1".into(), expired)
325            .await;
326
327        // Should not count the expired-but-open session
328        let count = registry
329            .count_open_sessions_for_initiator("agent://alice")
330            .await;
331        assert_eq!(count, 0);
332
333        // Insert a session that is still valid
334        let mut active = sample_session("active-s1");
335        active.initiator_sender = "agent://alice".into();
336        active.ttl_expiry = now + 60_000; // expires in 60s
337        active.state = SessionState::Open;
338        registry
339            .insert_recovered_session("active-s1".into(), active)
340            .await;
341
342        let count = registry
343            .count_open_sessions_for_initiator("agent://alice")
344            .await;
345        assert_eq!(count, 1);
346    }
347
348    #[tokio::test]
349    async fn persistent_registry_round_trip() {
350        let base = std::env::temp_dir().join(format!(
351            "macp-registry-test-{}",
352            SystemTime::now()
353                .duration_since(UNIX_EPOCH)
354                .unwrap()
355                .as_nanos()
356        ));
357
358        let registry = SessionRegistry::with_persistence(&base).unwrap();
359        registry
360            .insert_recovered_session("s1".into(), sample_session("s1"))
361            .await;
362
363        let reopened = SessionRegistry::with_persistence(&base).unwrap();
364        let session = reopened.get_session("s1").await.unwrap();
365        assert_eq!(session.mode, "macp.mode.decision.v1");
366        assert_eq!(session.mode_version, "1.0.0");
367        assert!(session.seen_message_ids.contains("m1"));
368    }
369}