1use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use chrono::Utc;
15use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC};
16use crate::runtime::Mutex;
17use tracing::{debug, warn};
18
19use clawft_platform::Platform;
20use clawft_types::error::ClawftError;
21use clawft_types::session::Session;
22
23pub struct SessionManager<P: Platform> {
42 sessions_dir: PathBuf,
44
45 active_sessions: Arc<Mutex<HashMap<String, Session>>>,
47
48 platform: Arc<P>,
50}
51
52impl<P: Platform> SessionManager<P> {
53 pub async fn new(platform: Arc<P>) -> clawft_types::Result<Self> {
62 let home = platform
63 .fs()
64 .home_dir()
65 .ok_or_else(|| ClawftError::ConfigInvalid {
66 reason: "cannot determine home directory".into(),
67 })?;
68
69 let clawft_dir = home.join(".clawft").join("workspace").join("sessions");
70 let nanobot_dir = home.join(".nanobot").join("workspace").join("sessions");
71
72 let sessions_dir = if platform.fs().exists(&clawft_dir).await {
73 debug!(path = %clawft_dir.display(), "using clawft sessions dir");
74 clawft_dir
75 } else if platform.fs().exists(&nanobot_dir).await {
76 debug!(path = %nanobot_dir.display(), "using nanobot sessions dir (fallback)");
77 nanobot_dir
78 } else {
79 debug!(
80 path = %clawft_dir.display(),
81 "sessions dir does not exist, creating"
82 );
83 platform
84 .fs()
85 .create_dir_all(&clawft_dir)
86 .await
87 .map_err(ClawftError::Io)?;
88 clawft_dir
89 };
90
91 Ok(Self {
92 sessions_dir,
93 active_sessions: Arc::new(Mutex::new(HashMap::new())),
94 platform,
95 })
96 }
97
98 pub fn with_dir(platform: Arc<P>, sessions_dir: PathBuf) -> Self {
102 Self {
103 sessions_dir,
104 active_sessions: Arc::new(Mutex::new(HashMap::new())),
105 platform,
106 }
107 }
108
109 pub async fn get_or_create(&self, key: &str) -> clawft_types::Result<Session> {
118 crate::security::validate_session_id(key)?;
119
120 {
122 let cache = self.active_sessions.lock().await;
123 if let Some(session) = cache.get(key) {
124 return Ok(session.clone());
125 }
126 }
127
128 if let Ok(session) = self.load_session(key).await {
130 let mut cache = self.active_sessions.lock().await;
131 cache.insert(key.to_string(), session.clone());
132 return Ok(session);
133 }
134
135 let session = Session::new(key);
137 let mut cache = self.active_sessions.lock().await;
138 cache.insert(key.to_string(), session.clone());
139 Ok(session)
140 }
141
142 pub async fn load_session(&self, key: &str) -> clawft_types::Result<Session> {
151 crate::security::validate_session_id(key)?;
152 let path = self.session_path(key);
153
154 if !self.platform.fs().exists(&path).await {
156 let old_filename = format!("{}.jsonl", key.replace(':', "_"));
157 let old_path = self.sessions_dir.join(&old_filename);
158 if self.platform.fs().exists(&old_path).await {
159 warn!(
160 key = key,
161 old = %old_path.display(),
162 new = %path.display(),
163 "migrating session file from old encoding format"
164 );
165 let content = self.platform.fs().read_to_string(&old_path).await?;
167 self.platform.fs().write_string(&path, &content).await?;
168 }
169 }
170
171 let content = self.platform.fs().read_to_string(&path).await?;
172
173 let mut lines = content.lines();
174
175 let meta_line = lines.next().ok_or_else(|| ClawftError::ConfigInvalid {
177 reason: format!("session file is empty: {}", path.display()),
178 })?;
179
180 let meta: serde_json::Value = serde_json::from_str(meta_line)?;
181
182 let created_at = meta
183 .get("created_at")
184 .and_then(|v| v.as_str())
185 .and_then(|s| s.parse().ok())
186 .unwrap_or_else(Utc::now);
187
188 let updated_at = meta
189 .get("updated_at")
190 .and_then(|v| v.as_str())
191 .and_then(|s| s.parse().ok())
192 .unwrap_or_else(Utc::now);
193
194 let metadata: HashMap<String, serde_json::Value> = meta
195 .get("metadata")
196 .and_then(|v| serde_json::from_value(v.clone()).ok())
197 .unwrap_or_default();
198
199 let last_consolidated = meta
200 .get("last_consolidated")
201 .and_then(|v| v.as_u64())
202 .unwrap_or(0) as usize;
203
204 let mut messages = Vec::new();
206 for line in lines {
207 let trimmed = line.trim();
208 if trimmed.is_empty() {
209 continue;
210 }
211 match serde_json::from_str::<serde_json::Value>(trimmed) {
212 Ok(msg) => messages.push(msg),
213 Err(e) => {
214 warn!(
215 key = key,
216 error = %e,
217 "skipping malformed message line in session"
218 );
219 }
220 }
221 }
222
223 debug!(
224 key = key,
225 messages = messages.len(),
226 "loaded session from disk"
227 );
228
229 Ok(Session {
230 key: key.to_string(),
231 messages,
232 created_at,
233 updated_at,
234 metadata,
235 last_consolidated,
236 })
237 }
238
239 pub async fn save_session(&self, session: &Session) -> clawft_types::Result<()> {
244 let path = self.session_path(&session.key);
245
246 let meta = serde_json::json!({
247 "_type": "metadata",
248 "created_at": session.created_at.to_rfc3339(),
249 "updated_at": session.updated_at.to_rfc3339(),
250 "metadata": session.metadata,
251 "last_consolidated": session.last_consolidated,
252 });
253
254 let mut content = serde_json::to_string(&meta).map_err(ClawftError::Json)?;
255 content.push('\n');
256
257 for msg in &session.messages {
258 content.push_str(&serde_json::to_string(msg).map_err(ClawftError::Json)?);
259 content.push('\n');
260 }
261
262 self.platform.fs().write_string(&path, &content).await?;
263
264 let mut cache = self.active_sessions.lock().await;
266 cache.insert(session.key.clone(), session.clone());
267
268 debug!(key = %session.key, "saved session to disk");
269
270 Ok(())
271 }
272
273 pub async fn append_turn(
278 &self,
279 key: &str,
280 role: &str,
281 content: &str,
282 ) -> clawft_types::Result<()> {
283 crate::security::validate_session_id(key)?;
284 let mut session = self.get_or_create(key).await?;
285 session.add_message(role, content, None);
286
287 let msg = serde_json::json!({
289 "role": role,
290 "content": content,
291 "timestamp": Utc::now().to_rfc3339(),
292 });
293 let mut line = serde_json::to_string(&msg).map_err(ClawftError::Json)?;
294 line.push('\n');
295
296 let path = self.session_path(key);
297
298 if !self.platform.fs().exists(&path).await {
300 self.save_session(&session).await?;
301 } else {
302 self.platform.fs().append_string(&path, &line).await?;
303 let mut cache = self.active_sessions.lock().await;
305 cache.insert(key.to_string(), session);
306 }
307
308 Ok(())
309 }
310
311 pub async fn list_sessions(&self) -> clawft_types::Result<Vec<String>> {
316 let entries = self
317 .platform
318 .fs()
319 .list_dir(&self.sessions_dir)
320 .await
321 .map_err(ClawftError::Io)?;
322
323 let mut keys = Vec::new();
324 for entry in entries {
325 if let Some(name) = entry.file_name() {
326 let name = name.to_string_lossy();
327 if let Some(stem) = name.strip_suffix(".jsonl") {
328 match percent_decode_str(stem).decode_utf8() {
329 Ok(decoded) => keys.push(decoded.into_owned()),
330 Err(e) => {
331 warn!(filename = %name, error = %e, "skipping undecodable session filename");
332 }
333 }
334 }
335 }
336 }
337
338 keys.sort();
339 Ok(keys)
340 }
341
342 pub async fn invalidate(&self, key: &str) {
347 let mut cache = self.active_sessions.lock().await;
348 cache.remove(key);
349 debug!(key = key, "invalidated session cache entry");
350 }
351
352 pub async fn delete_session(&self, key: &str) -> clawft_types::Result<()> {
354 crate::security::validate_session_id(key)?;
355 let path = self.session_path(key);
356 if self.platform.fs().exists(&path).await {
357 self.platform
358 .fs()
359 .remove_file(&path)
360 .await
361 .map_err(ClawftError::Io)?;
362 }
363 self.invalidate(key).await;
364 Ok(())
365 }
366
367 pub fn sessions_dir(&self) -> &PathBuf {
369 &self.sessions_dir
370 }
371
372 fn session_path(&self, key: &str) -> PathBuf {
378 let encoded = percent_encode(key.as_bytes(), NON_ALPHANUMERIC).to_string();
379 let filename = format!("{encoded}.jsonl");
380 self.sessions_dir.join(filename)
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use async_trait::async_trait;
388 use clawft_platform::fs::FileSystem;
389 use std::sync::Mutex as StdMutex;
390
391 struct MockFs {
395 files: StdMutex<HashMap<PathBuf, String>>,
396 dirs: StdMutex<Vec<PathBuf>>,
397 }
398
399 impl MockFs {
400 fn new() -> Self {
401 Self {
402 files: StdMutex::new(HashMap::new()),
403 dirs: StdMutex::new(Vec::new()),
404 }
405 }
406 }
407
408 #[async_trait]
409 impl clawft_platform::fs::FileSystem for MockFs {
410 async fn read_to_string(&self, path: &std::path::Path) -> std::io::Result<String> {
411 let files = self.files.lock().unwrap();
412 files.get(path).cloned().ok_or_else(|| {
413 std::io::Error::new(
414 std::io::ErrorKind::NotFound,
415 format!("file not found: {}", path.display()),
416 )
417 })
418 }
419
420 async fn write_string(&self, path: &std::path::Path, content: &str) -> std::io::Result<()> {
421 if let Some(parent) = path.parent() {
423 let mut dirs = self.dirs.lock().unwrap();
424 if !dirs.contains(&parent.to_path_buf()) {
425 dirs.push(parent.to_path_buf());
426 }
427 }
428 let mut files = self.files.lock().unwrap();
429 files.insert(path.to_path_buf(), content.to_string());
430 Ok(())
431 }
432
433 async fn append_string(
434 &self,
435 path: &std::path::Path,
436 content: &str,
437 ) -> std::io::Result<()> {
438 let mut files = self.files.lock().unwrap();
439 let entry = files.entry(path.to_path_buf()).or_default();
440 entry.push_str(content);
441 Ok(())
442 }
443
444 async fn exists(&self, path: &std::path::Path) -> bool {
445 let files = self.files.lock().unwrap();
446 if files.contains_key(path) {
447 return true;
448 }
449 let dirs = self.dirs.lock().unwrap();
450 dirs.contains(&path.to_path_buf())
451 }
452
453 async fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
454 let files = self.files.lock().unwrap();
455 let mut entries = Vec::new();
456 for file_path in files.keys() {
457 if file_path.parent() == Some(path) {
458 entries.push(file_path.clone());
459 }
460 }
461 Ok(entries)
462 }
463
464 async fn create_dir_all(&self, path: &std::path::Path) -> std::io::Result<()> {
465 let mut dirs = self.dirs.lock().unwrap();
466 if !dirs.contains(&path.to_path_buf()) {
467 dirs.push(path.to_path_buf());
468 }
469 Ok(())
470 }
471
472 async fn remove_file(&self, path: &std::path::Path) -> std::io::Result<()> {
473 let mut files = self.files.lock().unwrap();
474 files.remove(path);
475 Ok(())
476 }
477
478 fn home_dir(&self) -> Option<PathBuf> {
479 Some(PathBuf::from("/mock-home"))
480 }
481 }
482
483 struct MockEnv;
484
485 impl clawft_platform::env::Environment for MockEnv {
486 fn get_var(&self, _name: &str) -> Option<String> {
487 None
488 }
489 fn set_var(&self, _name: &str, _value: &str) {}
490 fn remove_var(&self, _name: &str) {}
491 }
492
493 struct MockHttp;
494
495 #[async_trait]
496 impl clawft_platform::http::HttpClient for MockHttp {
497 async fn request(
498 &self,
499 _method: &str,
500 _url: &str,
501 _headers: &HashMap<String, String>,
502 _body: Option<&[u8]>,
503 ) -> Result<clawft_platform::http::HttpResponse, Box<dyn std::error::Error + Send + Sync>>
504 {
505 Err("MockHttp::request not implemented — use a real HTTP client for integration tests".into())
506 }
507 }
508
509 struct MockPlatform {
510 fs: MockFs,
511 env: MockEnv,
512 http: MockHttp,
513 }
514
515 impl MockPlatform {
516 fn new() -> Self {
517 Self {
518 fs: MockFs::new(),
519 env: MockEnv,
520 http: MockHttp,
521 }
522 }
523 }
524
525 #[async_trait]
526 impl Platform for MockPlatform {
527 fn http(&self) -> &dyn clawft_platform::http::HttpClient {
528 &self.http
529 }
530
531 fn fs(&self) -> &dyn clawft_platform::fs::FileSystem {
532 &self.fs
533 }
534
535 fn env(&self) -> &dyn clawft_platform::env::Environment {
536 &self.env
537 }
538
539 fn process(&self) -> Option<&dyn clawft_platform::process::ProcessSpawner> {
540 None
541 }
542 }
543
544 fn make_platform() -> Arc<MockPlatform> {
545 Arc::new(MockPlatform::new())
546 }
547
548 fn make_manager(platform: Arc<MockPlatform>) -> SessionManager<MockPlatform> {
549 let sessions_dir = PathBuf::from("/mock-home/.clawft/workspace/sessions");
550 SessionManager::with_dir(platform, sessions_dir)
551 }
552
553 #[tokio::test]
554 async fn get_or_create_new_session() {
555 let platform = make_platform();
556 let mgr = make_manager(platform);
557
558 let session = mgr.get_or_create("telegram:123").await.unwrap();
559 assert_eq!(session.key, "telegram:123");
560 assert!(session.messages.is_empty());
561 }
562
563 #[tokio::test]
564 async fn get_or_create_returns_cached() {
565 let platform = make_platform();
566 let mgr = make_manager(platform);
567
568 let session1 = mgr.get_or_create("test:key").await.unwrap();
569 let session2 = mgr.get_or_create("test:key").await.unwrap();
570 assert_eq!(session1.created_at, session2.created_at);
572 }
573
574 #[tokio::test]
575 async fn save_and_load_roundtrip() {
576 let platform = make_platform();
577 let mgr = make_manager(platform);
578
579 let mut session = Session::new("roundtrip:test");
580 session.add_message("user", "hello world", None);
581 session.add_message("assistant", "hi there", None);
582
583 mgr.save_session(&session).await.unwrap();
584
585 mgr.invalidate("roundtrip:test").await;
587
588 let loaded = mgr.load_session("roundtrip:test").await.unwrap();
589 assert_eq!(loaded.key, "roundtrip:test");
590 assert_eq!(loaded.messages.len(), 2);
591 assert_eq!(loaded.messages[0]["role"], "user");
592 assert_eq!(loaded.messages[0]["content"], "hello world");
593 assert_eq!(loaded.messages[1]["role"], "assistant");
594 assert_eq!(loaded.messages[1]["content"], "hi there");
595 }
596
597 #[tokio::test]
598 async fn jsonl_format_correctness() {
599 let platform = make_platform();
600 let mgr = make_manager(platform.clone());
601
602 let mut session = Session::new("fmt:check");
603 session.add_message("user", "test", None);
604
605 mgr.save_session(&session).await.unwrap();
606
607 let path = PathBuf::from("/mock-home/.clawft/workspace/sessions/fmt%3Acheck.jsonl");
608 let content = platform.fs.read_to_string(&path).await.unwrap();
609 let lines: Vec<&str> = content.lines().collect();
610
611 assert_eq!(lines.len(), 2);
613 let meta: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
614 assert_eq!(meta["_type"], "metadata");
615 assert!(meta.get("created_at").is_some());
616 assert!(meta.get("updated_at").is_some());
617 assert_eq!(meta["last_consolidated"], 0);
618
619 let msg: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
621 assert_eq!(msg["role"], "user");
622 assert_eq!(msg["content"], "test");
623 }
624
625 #[tokio::test]
626 async fn append_turn_creates_session_if_needed() {
627 let platform = make_platform();
628 let mgr = make_manager(platform);
629
630 mgr.append_turn("new:session", "user", "first message")
631 .await
632 .unwrap();
633
634 let session = mgr.get_or_create("new:session").await.unwrap();
635 assert_eq!(session.messages.len(), 1);
636 assert_eq!(session.messages[0]["content"], "first message");
637 }
638
639 #[tokio::test]
640 async fn append_turn_adds_to_existing() {
641 let platform = make_platform();
642 let mgr = make_manager(platform);
643
644 let mut session = Session::new("append:test");
646 session.add_message("user", "first", None);
647 mgr.save_session(&session).await.unwrap();
648
649 mgr.append_turn("append:test", "assistant", "second")
651 .await
652 .unwrap();
653
654 let loaded = mgr.get_or_create("append:test").await.unwrap();
655 assert_eq!(loaded.messages.len(), 2);
656 }
657
658 #[tokio::test]
659 async fn list_sessions_returns_keys() {
660 let platform = make_platform();
661 let mgr = make_manager(platform);
662
663 let s1 = Session::new("telegram:100");
664 let s2 = Session::new("slack:200");
665 mgr.save_session(&s1).await.unwrap();
666 mgr.save_session(&s2).await.unwrap();
667
668 let keys = mgr.list_sessions().await.unwrap();
669 assert_eq!(keys.len(), 2);
670 assert!(keys.contains(&"slack:200".to_string()));
671 assert!(keys.contains(&"telegram:100".to_string()));
672 }
673
674 #[tokio::test]
675 async fn invalidate_removes_from_cache() {
676 let platform = make_platform();
677 let mgr = make_manager(platform);
678
679 mgr.get_or_create("cache:test").await.unwrap();
680
681 {
683 let cache = mgr.active_sessions.lock().await;
684 assert!(cache.contains_key("cache:test"));
685 }
686
687 mgr.invalidate("cache:test").await;
688
689 {
690 let cache = mgr.active_sessions.lock().await;
691 assert!(!cache.contains_key("cache:test"));
692 }
693 }
694
695 #[tokio::test]
696 async fn session_path_uses_percent_encoding() {
697 let platform = make_platform();
698 let mgr = make_manager(platform);
699
700 let path = mgr.session_path("telegram:12345");
701 assert_eq!(
702 path,
703 PathBuf::from("/mock-home/.clawft/workspace/sessions/telegram%3A12345.jsonl")
704 );
705 }
706
707 #[tokio::test]
708 async fn roundtrip_key_with_underscores() {
709 let platform = make_platform();
710 let mgr = make_manager(platform);
711
712 let key = "telegram:user_123";
714 let session = Session::new(key);
715 mgr.save_session(&session).await.unwrap();
716
717 let keys = mgr.list_sessions().await.unwrap();
718 assert!(
719 keys.contains(&key.to_string()),
720 "list_sessions should contain '{key}', got: {keys:?}"
721 );
722
723 mgr.invalidate(key).await;
724 let loaded = mgr.load_session(key).await.unwrap();
725 assert_eq!(loaded.key, key);
726 }
727
728 #[tokio::test]
729 async fn roundtrip_key_with_multiple_colons() {
730 let platform = make_platform();
731 let mgr = make_manager(platform);
732
733 let key = "slack:channel:thread";
734 let session = Session::new(key);
735 mgr.save_session(&session).await.unwrap();
736
737 let keys = mgr.list_sessions().await.unwrap();
738 assert!(keys.contains(&key.to_string()));
739
740 mgr.invalidate(key).await;
741 let loaded = mgr.load_session(key).await.unwrap();
742 assert_eq!(loaded.key, key);
743 }
744
745 #[tokio::test]
746 async fn roundtrip_key_with_special_chars() {
747 let platform = make_platform();
748 let mgr = make_manager(platform);
749
750 let key = "discord:guild#channel+123";
751 let session = Session::new(key);
752 mgr.save_session(&session).await.unwrap();
753
754 let keys = mgr.list_sessions().await.unwrap();
755 assert!(keys.contains(&key.to_string()));
756
757 mgr.invalidate(key).await;
758 let loaded = mgr.load_session(key).await.unwrap();
759 assert_eq!(loaded.key, key);
760 }
761
762 #[tokio::test]
763 async fn migration_from_old_underscore_format() {
764 let platform = make_platform();
765 let mgr = make_manager(platform.clone());
766
767 let old_path = PathBuf::from("/mock-home/.clawft/workspace/sessions/telegram_user_123.jsonl");
769 let meta = serde_json::json!({
770 "_type": "metadata",
771 "created_at": "2025-01-01T00:00:00Z",
772 "updated_at": "2025-01-01T00:00:00Z",
773 "metadata": {},
774 "last_consolidated": 0,
775 });
776 let content = format!("{}\n", serde_json::to_string(&meta).unwrap());
777 platform.fs.write_string(&old_path, &content).await.unwrap();
778
779 let loaded = mgr.load_session("telegram:user_123").await.unwrap();
781 assert_eq!(loaded.key, "telegram:user_123");
782 }
783
784 #[tokio::test]
785 async fn load_nonexistent_session_returns_error() {
786 let platform = make_platform();
787 let mgr = make_manager(platform);
788
789 let result = mgr.load_session("nonexistent:key").await;
790 assert!(result.is_err());
791 }
792
793 #[tokio::test]
794 async fn save_preserves_metadata() {
795 let platform = make_platform();
796 let mgr = make_manager(platform);
797
798 let mut session = Session::new("meta:test");
799 session
800 .metadata
801 .insert("agent".into(), serde_json::json!("test-agent"));
802 session.last_consolidated = 5;
803
804 mgr.save_session(&session).await.unwrap();
805 mgr.invalidate("meta:test").await;
806
807 let loaded = mgr.load_session("meta:test").await.unwrap();
808 assert_eq!(loaded.last_consolidated, 5);
809 assert_eq!(loaded.metadata["agent"], "test-agent");
810 }
811
812 #[tokio::test]
813 async fn new_discovers_sessions_dir() {
814 let platform = make_platform();
815 let mgr = SessionManager::new(platform).await.unwrap();
818 assert_eq!(
819 mgr.sessions_dir,
820 PathBuf::from("/mock-home/.clawft/workspace/sessions")
821 );
822 }
823
824 #[tokio::test]
825 async fn list_sessions_empty_dir() {
826 let platform = make_platform();
827 let mgr = make_manager(platform);
828
829 let keys = mgr.list_sessions().await.unwrap();
830 assert!(keys.is_empty());
831 }
832}