1use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use everruns_core::error::{AgentLoopError, Result};
8use everruns_core::session::Session;
9use everruns_core::session_file::{FileInfo, FileStat, GrepMatch, InitialFile, SessionFile};
10use everruns_core::traits::{
11 KeyInfo, SecretInfo, SessionFileSystem, SessionFileSystemFactory,
12 SessionFileSystemFactoryContext, SessionMutator, SessionStorageStore, SessionStore,
13};
14use everruns_core::typed_id::SessionId;
15use std::collections::{BTreeSet, HashMap};
16use std::sync::Arc;
17use tokio::sync::RwLock;
18use uuid::Uuid;
19
20#[derive(Debug, Default, Clone)]
24pub struct InMemorySessionStore {
25 sessions: Arc<RwLock<HashMap<SessionId, Session>>>,
26}
27
28impl InMemorySessionStore {
29 pub fn new() -> Self {
31 Self {
32 sessions: Arc::new(RwLock::new(HashMap::new())),
33 }
34 }
35
36 pub async fn add_session(&self, session: Session) {
38 self.sessions.write().await.insert(session.id, session);
39 }
40}
41
42#[async_trait]
43impl SessionStore for InMemorySessionStore {
44 async fn get_session(&self, session_id: SessionId) -> Result<Option<Session>> {
45 Ok(self.sessions.read().await.get(&session_id).cloned())
46 }
47}
48
49#[async_trait]
50impl SessionMutator for InMemorySessionStore {
51 async fn update_session_title(&self, session_id: SessionId, title: String) -> Result<Session> {
52 let mut sessions = self.sessions.write().await;
53 let session = sessions
54 .get_mut(&session_id)
55 .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))?;
56 session.title = Some(title);
57 session.updated_at = Utc::now();
58 Ok(session.clone())
59 }
60}
61
62#[derive(Debug, Clone)]
63struct FileEntry {
64 file: SessionFile,
65}
66
67#[derive(Debug, Default, Clone)]
72pub struct InMemorySessionFileStore {
73 files: Arc<RwLock<HashMap<(SessionId, String), FileEntry>>>,
74}
75
76#[derive(Debug, Clone, Default)]
78pub struct InMemorySessionFileSystemFactory;
79
80#[async_trait]
81impl SessionFileSystemFactory for InMemorySessionFileSystemFactory {
82 fn name(&self) -> &'static str {
83 "InMemorySessionFileSystemFactory"
84 }
85
86 async fn create_session_file_system(
87 &self,
88 _context: SessionFileSystemFactoryContext,
89 ) -> Result<Arc<dyn SessionFileSystem>> {
90 Ok(Arc::new(InMemorySessionFileStore::new()))
91 }
92}
93
94impl InMemorySessionFileStore {
95 pub fn new() -> Self {
97 Self {
98 files: Arc::new(RwLock::new(HashMap::new())),
99 }
100 }
101
102 pub async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
104 let path = normalize_path(&file.path);
105 self.ensure_parent_directories(session_id, &path).await?;
106 self.upsert_file(
107 session_id,
108 &path,
109 &file.content,
110 &file.encoding,
111 file.is_readonly,
112 )
113 .await
114 .map(|_| ())
115 }
116
117 async fn ensure_parent_directories(&self, session_id: SessionId, path: &str) -> Result<()> {
118 let mut current = String::new();
119 for segment in path.trim_start_matches('/').split('/').collect::<Vec<_>>() {
120 if segment.is_empty() {
121 continue;
122 }
123 current.push('/');
124 current.push_str(segment);
125 let is_leaf = current == path;
126 if is_leaf {
127 break;
128 }
129 self.insert_directory_if_missing(session_id, ¤t)
130 .await?;
131 }
132 Ok(())
133 }
134
135 async fn insert_directory_if_missing(&self, session_id: SessionId, path: &str) -> Result<()> {
136 let path = normalize_path(path);
137 if path == "/" {
138 return Ok(());
139 }
140
141 let mut files = self.files.write().await;
142 files
143 .entry((session_id, path.clone()))
144 .or_insert_with(|| FileEntry {
145 file: SessionFile {
146 id: Uuid::now_v7(),
147 session_id: session_id.uuid(),
148 path: path.clone(),
149 name: FileInfo::name_from_path(&path),
150 content: None,
151 encoding: "text".to_string(),
152 is_directory: true,
153 is_readonly: false,
154 size_bytes: 0,
155 created_at: Utc::now(),
156 updated_at: Utc::now(),
157 },
158 });
159 Ok(())
160 }
161
162 async fn upsert_file(
163 &self,
164 session_id: SessionId,
165 path: &str,
166 content: &str,
167 encoding: &str,
168 is_readonly: bool,
169 ) -> Result<SessionFile> {
170 let now = Utc::now();
171 let normalized = normalize_path(path);
172 let mut files = self.files.write().await;
173 let key = (session_id, normalized.clone());
174
175 let file = files
176 .entry(key)
177 .and_modify(|entry| {
178 entry.file.content = Some(content.to_string());
179 entry.file.encoding = encoding.to_string();
180 entry.file.is_directory = false;
181 entry.file.is_readonly = is_readonly;
182 entry.file.size_bytes = content.len() as i64;
183 entry.file.updated_at = now;
184 })
185 .or_insert_with(|| FileEntry {
186 file: SessionFile {
187 id: Uuid::now_v7(),
188 session_id: session_id.uuid(),
189 path: normalized.clone(),
190 name: FileInfo::name_from_path(&normalized),
191 content: Some(content.to_string()),
192 encoding: encoding.to_string(),
193 is_directory: false,
194 is_readonly,
195 size_bytes: content.len() as i64,
196 created_at: now,
197 updated_at: now,
198 },
199 })
200 .file
201 .clone();
202
203 Ok(file)
204 }
205
206 pub async fn read_text(&self, session_id: SessionId, path: &str) -> Option<String> {
208 self.read_file(session_id, path)
209 .await
210 .ok()
211 .flatten()
212 .and_then(|file| file.content)
213 }
214}
215
216#[async_trait]
217impl SessionFileSystem for InMemorySessionFileStore {
218 async fn seed_initial_file(&self, session_id: SessionId, file: &InitialFile) -> Result<()> {
219 InMemorySessionFileStore::seed_initial_file(self, session_id, file).await
220 }
221
222 async fn read_file(&self, session_id: SessionId, path: &str) -> Result<Option<SessionFile>> {
223 let normalized = normalize_path(path);
224 if normalized == "/" {
225 return Ok(Some(root_directory(session_id)));
226 }
227
228 Ok(self
229 .files
230 .read()
231 .await
232 .get(&(session_id, normalized))
233 .map(|entry| entry.file.clone()))
234 }
235
236 async fn write_file(
237 &self,
238 session_id: SessionId,
239 path: &str,
240 content: &str,
241 encoding: &str,
242 ) -> Result<SessionFile> {
243 let normalized = normalize_path(path);
244 self.ensure_parent_directories(session_id, &normalized)
245 .await?;
246
247 if let Some(existing) = self.read_file(session_id, &normalized).await?
248 && existing.is_readonly
249 {
250 return Err(AgentLoopError::tool(format!(
251 "file is read-only: {}",
252 normalized
253 )));
254 }
255
256 self.upsert_file(session_id, &normalized, content, encoding, false)
257 .await
258 }
259
260 async fn delete_file(
261 &self,
262 session_id: SessionId,
263 path: &str,
264 recursive: bool,
265 ) -> Result<bool> {
266 let normalized = normalize_path(path);
267 if normalized == "/" {
268 return Ok(false);
269 }
270
271 let mut files = self.files.write().await;
272 let key = (session_id, normalized.clone());
273 let Some(existing) = files.get(&key).cloned() else {
274 return Ok(false);
275 };
276
277 if existing.file.is_readonly {
278 return Ok(false);
279 }
280
281 if existing.file.is_directory {
282 let prefix = format!("{normalized}/");
283 let has_children = files
284 .keys()
285 .any(|(sid, candidate)| *sid == session_id && candidate.starts_with(&prefix));
286 if has_children && !recursive {
287 return Ok(false);
288 }
289 files.retain(|(sid, candidate), _| {
290 !(*sid == session_id
291 && (candidate == &normalized || candidate.starts_with(&prefix)))
292 });
293 return Ok(true);
294 }
295
296 Ok(files.remove(&key).is_some())
297 }
298
299 async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
300 let normalized = normalize_path(path);
301 if normalized != "/" {
302 let Some(dir) = self.read_file(session_id, &normalized).await? else {
303 return Ok(vec![]);
304 };
305 if !dir.is_directory {
306 return Ok(vec![]);
307 }
308 }
309
310 let files = self.files.read().await;
311 let mut infos = Vec::new();
312 let mut seen = BTreeSet::new();
313
314 for ((sid, candidate), entry) in files.iter() {
315 if *sid != session_id {
316 continue;
317 }
318 if FileInfo::parent_path(candidate).as_deref() != Some(normalized.as_str()) {
319 continue;
320 }
321 if seen.insert(candidate.clone()) {
322 infos.push(file_info(&entry.file));
323 }
324 }
325
326 infos.sort_by(|a, b| a.path.cmp(&b.path));
327 Ok(infos)
328 }
329
330 async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
331 let normalized = normalize_path(path);
332 if normalized == "/" {
333 let root = root_directory(session_id);
334 return Ok(Some(FileStat {
335 path: root.path,
336 name: root.name,
337 is_directory: true,
338 is_readonly: false,
339 size_bytes: 0,
340 created_at: root.created_at,
341 updated_at: root.updated_at,
342 }));
343 }
344
345 Ok(self
346 .files
347 .read()
348 .await
349 .get(&(session_id, normalized))
350 .map(|entry| FileStat {
351 path: entry.file.path.clone(),
352 name: entry.file.name.clone(),
353 is_directory: entry.file.is_directory,
354 is_readonly: entry.file.is_readonly,
355 size_bytes: entry.file.size_bytes,
356 created_at: entry.file.created_at,
357 updated_at: entry.file.updated_at,
358 }))
359 }
360
361 async fn grep_files(
362 &self,
363 session_id: SessionId,
364 pattern: &str,
365 path_pattern: Option<&str>,
366 ) -> Result<Vec<GrepMatch>> {
367 let path_pattern = path_pattern
368 .map(everruns_core::session_path::GrepPathPattern::new)
369 .transpose()?;
370 let files = self.files.read().await;
371 let mut matches = Vec::new();
372
373 for ((sid, path), entry) in files.iter() {
374 if *sid != session_id || entry.file.is_directory || entry.file.encoding != "text" {
375 continue;
376 }
377 if let Some(path_pattern) = &path_pattern
378 && !path_pattern.is_match(path)
379 {
380 continue;
381 }
382
383 let Some(content) = &entry.file.content else {
384 continue;
385 };
386
387 for (idx, line) in content.lines().enumerate() {
388 if line.contains(pattern) {
389 matches.push(GrepMatch {
390 path: path.clone(),
391 line_number: idx + 1,
392 line: line.to_string(),
393 });
394 }
395 }
396 }
397
398 Ok(matches)
399 }
400
401 async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
402 let normalized = normalize_path(path);
403 self.ensure_parent_directories(session_id, &normalized)
404 .await?;
405 self.insert_directory_if_missing(session_id, &normalized)
406 .await?;
407 let file = self
408 .read_file(session_id, &normalized)
409 .await?
410 .ok_or_else(|| AgentLoopError::store(format!("directory not found: {normalized}")))?;
411 Ok(file_info(&file))
412 }
413
414 fn is_mount_resolver(&self) -> bool {
415 false
416 }
417}
418
419#[derive(Debug, Default, Clone)]
421pub struct InMemorySessionStorageStore {
422 values: Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
423 secrets: Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
424}
425
426#[derive(Debug, Clone)]
427struct StorageValue {
428 value: String,
429 created_at: DateTime<Utc>,
430 updated_at: DateTime<Utc>,
431}
432
433impl InMemorySessionStorageStore {
434 pub fn new() -> Self {
436 Self {
437 values: Arc::new(RwLock::new(HashMap::new())),
438 secrets: Arc::new(RwLock::new(HashMap::new())),
439 }
440 }
441}
442
443#[async_trait]
444impl SessionStorageStore for InMemorySessionStorageStore {
445 async fn set_value(&self, session_id: SessionId, key: &str, value: &str) -> Result<()> {
446 upsert_storage(&self.values, session_id, key, value).await;
447 Ok(())
448 }
449
450 async fn get_value(&self, session_id: SessionId, key: &str) -> Result<Option<String>> {
451 Ok(self
452 .values
453 .read()
454 .await
455 .get(&(session_id, key.to_string()))
456 .map(|value| value.value.clone()))
457 }
458
459 async fn delete_value(&self, session_id: SessionId, key: &str) -> Result<bool> {
460 Ok(self
461 .values
462 .write()
463 .await
464 .remove(&(session_id, key.to_string()))
465 .is_some())
466 }
467
468 async fn list_keys(&self, session_id: SessionId) -> Result<Vec<KeyInfo>> {
469 Ok(list_storage(&self.values, session_id)
470 .await
471 .into_iter()
472 .map(|(key, value)| KeyInfo {
473 key,
474 created_at: value.created_at,
475 updated_at: value.updated_at,
476 })
477 .collect())
478 }
479
480 async fn set_secret(&self, session_id: SessionId, name: &str, value: &str) -> Result<()> {
481 upsert_storage(&self.secrets, session_id, name, value).await;
482 Ok(())
483 }
484
485 async fn get_secret(&self, session_id: SessionId, name: &str) -> Result<Option<String>> {
486 Ok(self
487 .secrets
488 .read()
489 .await
490 .get(&(session_id, name.to_string()))
491 .map(|value| value.value.clone()))
492 }
493
494 async fn delete_secret(&self, session_id: SessionId, name: &str) -> Result<bool> {
495 Ok(self
496 .secrets
497 .write()
498 .await
499 .remove(&(session_id, name.to_string()))
500 .is_some())
501 }
502
503 async fn list_secrets(&self, session_id: SessionId) -> Result<Vec<SecretInfo>> {
504 Ok(list_storage(&self.secrets, session_id)
505 .await
506 .into_iter()
507 .map(|(name, value)| SecretInfo {
508 name,
509 created_at: value.created_at,
510 updated_at: value.updated_at,
511 })
512 .collect())
513 }
514}
515
516async fn upsert_storage(
517 map: &Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
518 session_id: SessionId,
519 key: &str,
520 value: &str,
521) {
522 let mut map = map.write().await;
523 let now = Utc::now();
524 map.entry((session_id, key.to_string()))
525 .and_modify(|stored| {
526 stored.value = value.to_string();
527 stored.updated_at = now;
528 })
529 .or_insert_with(|| StorageValue {
530 value: value.to_string(),
531 created_at: now,
532 updated_at: now,
533 });
534}
535
536async fn list_storage(
537 map: &Arc<RwLock<HashMap<(SessionId, String), StorageValue>>>,
538 session_id: SessionId,
539) -> Vec<(String, StorageValue)> {
540 let mut values: Vec<_> = map
541 .read()
542 .await
543 .iter()
544 .filter(|((sid, _), _)| *sid == session_id)
545 .map(|((_, key), value)| (key.clone(), value.clone()))
546 .collect();
547 values.sort_by(|a, b| a.0.cmp(&b.0));
548 values
549}
550
551fn normalize_path(path: &str) -> String {
552 everruns_core::session_path::to_session_path(path)
555}
556
557fn root_directory(session_id: SessionId) -> SessionFile {
558 let now = Utc::now();
559 SessionFile {
560 id: Uuid::nil(),
561 session_id: session_id.uuid(),
562 path: "/".to_string(),
563 name: "/".to_string(),
564 content: None,
565 encoding: "text".to_string(),
566 is_directory: true,
567 is_readonly: false,
568 size_bytes: 0,
569 created_at: now,
570 updated_at: now,
571 }
572}
573
574fn file_info(file: &SessionFile) -> FileInfo {
575 FileInfo {
576 id: file.id,
577 session_id: file.session_id,
578 path: file.path.clone(),
579 name: file.name.clone(),
580 is_directory: file.is_directory,
581 is_readonly: file.is_readonly,
582 size_bytes: file.size_bytes,
583 created_at: file.created_at,
584 updated_at: file.updated_at,
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[tokio::test]
593 async fn grep_path_pattern_supports_globs_and_substring_compatibility() {
594 let store = InMemorySessionFileStore::new();
595 let session = SessionId::from_seed(1);
596 for path in [
597 "/src/lib.rs",
598 "/src/nested/mod.rs",
599 "/docs/readme.md",
600 "/notes.txt",
601 ] {
602 store
603 .write_file(session, path, "needle", "text")
604 .await
605 .unwrap();
606 }
607
608 let mut glob_paths: Vec<_> = store
609 .grep_files(session, "needle", Some("src/**/*.rs"))
610 .await
611 .unwrap()
612 .into_iter()
613 .map(|hit| hit.path)
614 .collect();
615 glob_paths.sort();
616 assert_eq!(glob_paths, vec!["/src/lib.rs", "/src/nested/mod.rs"]);
617
618 let substring_paths: Vec<_> = store
619 .grep_files(session, "needle", Some("docs"))
620 .await
621 .unwrap()
622 .into_iter()
623 .map(|hit| hit.path)
624 .collect();
625 assert_eq!(substring_paths, vec!["/docs/readme.md"]);
626 }
627}