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