Skip to main content

agent_sessions/
discover.rs

1use crate::{Agent, DiscoverError, DiscoverOperation, FileKind, Roots};
2use std::{
3    fs, io,
4    path::{Path, PathBuf},
5    time::SystemTime,
6};
7
8#[derive(Debug, Clone, Default)]
9pub struct DiscoverFilter {
10    pub agents: Vec<Agent>,
11    pub include_subagents: bool,
12    pub modified_after: Option<SystemTime>,
13}
14#[derive(Debug, Clone)]
15pub struct SessionFile {
16    pub agent: Agent,
17    pub path: PathBuf,
18    pub kind: FileKind,
19    pub size: u64,
20    pub modified: SystemTime,
21}
22#[derive(Debug, Default)]
23pub struct Discovery {
24    pub files: Vec<SessionFile>,
25    pub errors: Vec<DiscoverError>,
26}
27
28impl SessionFile {
29    /// Explicit host selection for hooks and nonstandard roots.
30    pub fn inspect(agent: Agent, path: impl Into<PathBuf>) -> io::Result<Self> {
31        let path = path.into();
32        let meta = fs::symlink_metadata(&path)?;
33        if !meta.is_file() {
34            return Err(io::Error::new(
35                io::ErrorKind::InvalidInput,
36                "session path is not a regular file",
37            ));
38        }
39        let kind = if path.components().any(|p| p.as_os_str() == "subagents") {
40            FileKind::Subagent
41        } else {
42            FileKind::Main
43        };
44        Ok(Self {
45            agent,
46            path,
47            kind,
48            size: meta.len(),
49            modified: meta.modified()?,
50        })
51    }
52}
53
54fn scan_roots(roots: &Roots) -> Vec<(Agent, PathBuf)> {
55    let mut result = Vec::new();
56    if let Some(root) = &roots.claude {
57        result.push((Agent::ClaudeCode, root.join("projects")));
58    }
59    if let Some(root) = &roots.codex {
60        for subdir in ["sessions", "archived_sessions"] {
61            result.push((Agent::Codex, root.join(subdir)));
62        }
63    }
64    result
65}
66
67/// Classify a known path lexically under configured roots, preserving IO errors.
68/// Paths containing parent components are rejected rather than escaping a root.
69pub fn classify(path: &Path, roots: &Roots) -> io::Result<Option<SessionFile>> {
70    if path
71        .components()
72        .any(|c| matches!(c, std::path::Component::ParentDir))
73    {
74        return Err(io::Error::new(
75            io::ErrorKind::InvalidInput,
76            "parent path component",
77        ));
78    }
79    if path.extension().is_none_or(|e| e != "jsonl") {
80        return Ok(None);
81    }
82    for (agent, root) in scan_roots(roots) {
83        if path.starts_with(root) {
84            return SessionFile::inspect(agent, path).map(Some);
85        }
86    }
87    Ok(None)
88}
89
90pub fn discover(roots: &Roots, filter: &DiscoverFilter) -> Discovery {
91    let mut result = Discovery::default();
92    for (agent, root) in scan_roots(roots) {
93        scan_directory(agent, &root, filter, true, &mut result);
94    }
95    sort_files(&mut result);
96    result
97}
98
99/// Scan an explicitly selected directory recursively. Unlike configured default
100/// roots, a missing directory is reported in `errors`. Symlinks are not followed.
101/// Subagent exclusion applies to descendant directories named `subagents`;
102/// an explicitly selected root remains eligible regardless of its ancestors.
103pub fn discover_directory(agent: Agent, root: &Path, filter: &DiscoverFilter) -> Discovery {
104    let mut result = Discovery::default();
105    scan_directory(agent, root, filter, false, &mut result);
106    sort_files(&mut result);
107    result
108}
109
110fn scan_directory(
111    agent: Agent,
112    root: &Path,
113    filter: &DiscoverFilter,
114    optional: bool,
115    result: &mut Discovery,
116) {
117    if !filter.agents.is_empty() && !filter.agents.contains(&agent) {
118        return;
119    }
120    let skip_tagged_subagents = optional && !filter.include_subagents;
121    let mut pending = vec![(root.to_path_buf(), optional)];
122    while let Some((dir, optional)) = pending.pop() {
123        let metadata = match fs::symlink_metadata(&dir) {
124            Ok(m) => m,
125            Err(e) if optional && e.kind() == io::ErrorKind::NotFound => continue,
126            Err(source) => {
127                result.errors.push(DiscoverError {
128                    agent,
129                    path: dir,
130                    source,
131                    operation: DiscoverOperation::DirectoryMetadata,
132                });
133                continue;
134            }
135        };
136        if metadata.is_symlink() {
137            continue;
138        }
139        let entries = match fs::read_dir(&dir) {
140            Ok(entries) => entries,
141            Err(source) => {
142                result.errors.push(DiscoverError {
143                    agent,
144                    path: dir,
145                    source,
146                    operation: DiscoverOperation::ReadDirectory,
147                });
148                continue;
149            }
150        };
151        for entry in entries {
152            let entry = match entry {
153                Ok(e) => e,
154                Err(source) => {
155                    result.errors.push(DiscoverError {
156                        agent,
157                        path: dir.clone(),
158                        source,
159                        operation: DiscoverOperation::ReadEntry,
160                    });
161                    continue;
162                }
163            };
164            let path = entry.path();
165            let ty = match entry.file_type() {
166                Ok(ty) => ty,
167                Err(source) => {
168                    result.errors.push(DiscoverError {
169                        agent,
170                        path,
171                        source,
172                        operation: DiscoverOperation::FileType,
173                    });
174                    continue;
175                }
176            };
177            if ty.is_dir() {
178                if filter.include_subagents || entry.file_name() != "subagents" {
179                    pending.push((path, false));
180                }
181            } else if ty.is_file() && path.extension().is_some_and(|e| e == "jsonl") {
182                match SessionFile::inspect(agent, &path) {
183                    Ok(file)
184                        if filter.modified_after.is_none_or(|t| file.modified >= t)
185                            && (!skip_tagged_subagents || file.kind == FileKind::Main) =>
186                    {
187                        result.files.push(file)
188                    }
189                    Ok(_) => {}
190                    Err(source) => result.errors.push(DiscoverError {
191                        agent,
192                        path,
193                        source,
194                        operation: DiscoverOperation::InspectFile,
195                    }),
196                }
197            }
198        }
199    }
200}
201fn sort_files(result: &mut Discovery) {
202    result.files.sort_by(|a, b| a.path.cmp(&b.path));
203    result
204        .files
205        .dedup_by(|a, b| a.path == b.path && a.agent == b.agent);
206}