agtrace_runtime/ops/
index.rs

1use crate::{Error, Result};
2use agtrace_index::{Database, LogFileRecord, ProjectRecord, SessionRecord};
3use agtrace_providers::ProviderAdapter;
4use std::collections::HashSet;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone)]
8pub enum IndexProgress {
9    IncrementalHint {
10        indexed_files: usize,
11    },
12    LogRootMissing {
13        provider_name: String,
14        log_root: PathBuf,
15    },
16    ProviderScanning {
17        provider_name: String,
18    },
19    ProviderSessionCount {
20        provider_name: String,
21        count: usize,
22        project_hash: String,
23        all_projects: bool,
24    },
25    SessionRegistered {
26        session_id: String,
27    },
28    Completed {
29        total_sessions: usize,
30        scanned_files: usize,
31        skipped_files: usize,
32    },
33}
34
35pub struct IndexService<'a> {
36    db: &'a Database,
37    providers: Vec<(ProviderAdapter, PathBuf)>,
38}
39
40impl<'a> IndexService<'a> {
41    pub fn new(db: &'a Database, providers: Vec<(ProviderAdapter, PathBuf)>) -> Self {
42        Self { db, providers }
43    }
44
45    pub fn run<F>(
46        &self,
47        scope: agtrace_types::ProjectScope,
48        force: bool,
49        mut on_progress: F,
50    ) -> Result<()>
51    where
52        F: FnMut(IndexProgress),
53    {
54        let indexed_files = if force {
55            HashSet::new()
56        } else {
57            self.db
58                .get_all_log_files()?
59                .into_iter()
60                .filter_map(|f| {
61                    if should_skip_indexed_file(&f) {
62                        Some(f.path)
63                    } else {
64                        None
65                    }
66                })
67                .collect::<HashSet<_>>()
68        };
69
70        if !force {
71            on_progress(IndexProgress::IncrementalHint {
72                indexed_files: indexed_files.len(),
73            });
74        }
75
76        let mut total_sessions = 0;
77        let mut scanned_files = 0;
78        let mut skipped_files = 0;
79
80        for (provider, log_root) in &self.providers {
81            let provider_name = provider.id();
82
83            if !log_root.exists() {
84                on_progress(IndexProgress::LogRootMissing {
85                    provider_name: provider_name.to_string(),
86                    log_root: log_root.clone(),
87                });
88                continue;
89            }
90
91            on_progress(IndexProgress::ProviderScanning {
92                provider_name: provider_name.to_string(),
93            });
94
95            let sessions = provider
96                .discovery
97                .scan_sessions(log_root)
98                .map_err(Error::Provider)?;
99
100            // Filter sessions by project_hash if specified
101            let filtered_sessions: Vec<_> = sessions
102                .into_iter()
103                .filter(|session| {
104                    if let Some(expected_hash) = scope.hash() {
105                        if let Some(session_root) = &session.project_root {
106                            let session_hash = agtrace_core::project_hash_from_root(&session_root.to_string_lossy());
107                            &session_hash == expected_hash
108                        } else {
109                            // Gemini sessions might not have project_root, compute hash from file
110                            if provider_name == "gemini" {
111                                use agtrace_providers::gemini::io::extract_project_hash_from_gemini_file;
112                                if let Some(session_hash) = extract_project_hash_from_gemini_file(&session.main_file) {
113                                    &session_hash == expected_hash
114                                } else {
115                                    false
116                                }
117                            } else {
118                                false
119                            }
120                        }
121                    } else {
122                        true
123                    }
124                })
125                .collect();
126
127            on_progress(IndexProgress::ProviderSessionCount {
128                provider_name: provider_name.to_string(),
129                count: filtered_sessions.len(),
130                project_hash: match &scope {
131                    agtrace_types::ProjectScope::All => "<all>".to_string(),
132                    agtrace_types::ProjectScope::Specific(hash) => hash.to_string(),
133                },
134                all_projects: matches!(scope, agtrace_types::ProjectScope::All),
135            });
136
137            // Sort sessions: parents (no parent_session_id) before children
138            // This ensures FK constraint on parent_session_id is satisfied
139            let mut filtered_sessions = filtered_sessions;
140            filtered_sessions.sort_by_key(|s| s.parent_session_id.is_some());
141
142            for session in filtered_sessions {
143                // Collect all file paths for this session
144                let mut all_files = vec![session.main_file.display().to_string()];
145                for side_file in &session.sidechain_files {
146                    all_files.push(side_file.display().to_string());
147                }
148
149                let all_files_unchanged =
150                    !force && all_files.iter().all(|f| indexed_files.contains(f));
151
152                if all_files_unchanged {
153                    skipped_files += all_files.len();
154                    continue;
155                }
156
157                on_progress(IndexProgress::SessionRegistered {
158                    session_id: session.session_id.clone(),
159                });
160
161                // Calculate project_hash from session data
162                let session_project_hash = if let Some(ref root) = session.project_root {
163                    agtrace_core::project_hash_from_root(&root.to_string_lossy())
164                } else if provider_name == "gemini" {
165                    // For Gemini, extract project_hash directly from the file
166                    use agtrace_providers::gemini::io::extract_project_hash_from_gemini_file;
167                    extract_project_hash_from_gemini_file(&session.main_file).unwrap_or_else(|| {
168                        agtrace_core::project_hash_from_log_path(&session.main_file)
169                    })
170                } else {
171                    // Generate unique hash from log path for orphaned sessions
172                    agtrace_core::project_hash_from_log_path(&session.main_file)
173                };
174
175                let project_record = ProjectRecord {
176                    hash: session_project_hash.clone(),
177                    root_path: session
178                        .project_root
179                        .as_ref()
180                        .map(|p| p.to_string_lossy().to_string()),
181                    last_scanned_at: Some(chrono::Utc::now().to_rfc3339()),
182                };
183                self.db.insert_or_update_project(&project_record)?;
184
185                let session_record = SessionRecord {
186                    id: session.session_id.clone(),
187                    project_hash: session_project_hash,
188                    provider: provider_name.to_string(),
189                    start_ts: session.timestamp.clone(),
190                    end_ts: None,
191                    snippet: session.snippet.clone(),
192                    is_valid: true,
193                    parent_session_id: session.parent_session_id.clone(),
194                    spawned_by: session.spawned_by.clone(),
195                };
196                self.db.insert_or_update_session(&session_record)?;
197
198                // Register main file
199                let to_log_file_record = |path: &PathBuf, role: &str| -> Result<LogFileRecord> {
200                    let meta = std::fs::metadata(path).ok();
201                    Ok(LogFileRecord {
202                        path: path.display().to_string(),
203                        session_id: session.session_id.clone(),
204                        role: role.to_string(),
205                        file_size: meta.as_ref().map(|m| m.len() as i64),
206                        mod_time: meta
207                            .and_then(|m| m.modified().ok())
208                            .map(|t| format!("{:?}", t)),
209                    })
210                };
211
212                scanned_files += 1;
213                let main_log_file = to_log_file_record(&session.main_file, "main")?;
214                self.db.insert_or_update_log_file(&main_log_file)?;
215
216                // Register sidechain files
217                for side_file in &session.sidechain_files {
218                    scanned_files += 1;
219                    let side_log_file = to_log_file_record(side_file, "sidechain")?;
220                    self.db.insert_or_update_log_file(&side_log_file)?;
221                }
222
223                total_sessions += 1;
224            }
225        }
226
227        on_progress(IndexProgress::Completed {
228            total_sessions,
229            scanned_files,
230            skipped_files,
231        });
232
233        Ok(())
234    }
235}
236
237fn should_skip_indexed_file(indexed: &LogFileRecord) -> bool {
238    use std::path::Path;
239
240    let path = Path::new(&indexed.path);
241
242    if !path.exists() {
243        return false;
244    }
245
246    let metadata = match std::fs::metadata(path) {
247        Ok(m) => m,
248        Err(_) => return false,
249    };
250
251    if let Some(db_size) = indexed.file_size {
252        if db_size != metadata.len() as i64 {
253            return false;
254        }
255    } else {
256        return false;
257    }
258
259    if let Some(db_mod_time) = &indexed.mod_time {
260        if let Ok(fs_mod_time) = metadata.modified() {
261            let fs_mod_time_str = format!("{:?}", fs_mod_time);
262            if db_mod_time != &fs_mod_time_str {
263                return false;
264            }
265        } else {
266            return false;
267        }
268    } else {
269        return false;
270    }
271
272    true
273}