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 #[serde(default)]
48 pub semantics_rev: u32,
49 #[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 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
141pub 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 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 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 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 let mut expired = sample_session("expired-s1");
320 expired.initiator_sender = "agent://alice".into();
321 expired.ttl_expiry = now - 1000; expired.state = SessionState::Open; registry
324 .insert_recovered_session("expired-s1".into(), expired)
325 .await;
326
327 let count = registry
329 .count_open_sessions_for_initiator("agent://alice")
330 .await;
331 assert_eq!(count, 0);
332
333 let mut active = sample_session("active-s1");
335 active.initiator_sender = "agent://alice".into();
336 active.ttl_expiry = now + 60_000; 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}