Skip to main content

flow_server/
state.rs

1use serde_json::Value;
2use std::{
3    collections::HashMap,
4    fs::{self, File},
5    io::Read,
6    path::PathBuf,
7    sync::Arc,
8    time::{Duration, Instant},
9};
10use tokio::sync::{broadcast, RwLock};
11use flow_core::SessionMeta;
12
13/// Application state shared across all handlers
14pub struct AppState {
15    pub tasks_dir: PathBuf,
16    pub projects_dir: PathBuf,
17    pub tx: broadcast::Sender<String>,
18    pub metadata_cache: RwLock<MetadataCache>,
19    pub db: Option<Arc<flow_db::Database>>,
20}
21
22/// Cache for session metadata with time-based invalidation
23pub struct MetadataCache {
24    pub data: HashMap<String, SessionMeta>,
25    pub last_refresh: Instant,
26}
27
28impl Default for MetadataCache {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl MetadataCache {
35    pub fn new() -> Self {
36        Self {
37            data: HashMap::new(),
38            last_refresh: Instant::now().checked_sub(Duration::from_secs(60)).unwrap_or_else(Instant::now),
39        }
40    }
41
42    pub fn is_stale(&self) -> bool {
43        self.last_refresh.elapsed() > Duration::from_secs(10)
44    }
45}
46
47/// Refresh metadata cache if stale, return current data
48pub async fn get_metadata(state: &AppState) -> HashMap<String, SessionMeta> {
49    {
50        let cache = state.metadata_cache.read().await;
51        if !cache.is_stale() {
52            return cache.data.clone();
53        }
54    }
55
56    let mut cache = state.metadata_cache.write().await;
57    // Double-check after acquiring write lock
58    if !cache.is_stale() {
59        return cache.data.clone();
60    }
61
62    cache.data = load_session_metadata(&state.projects_dir);
63    cache.last_refresh = Instant::now();
64    cache.data.clone()
65}
66
67/// Scan all project directories to build session metadata cache
68pub fn load_session_metadata(projects_dir: &std::path::Path) -> HashMap<String, SessionMeta> {
69    let mut metadata = HashMap::new();
70
71    let Ok(project_dirs) = fs::read_dir(projects_dir) else {
72        return metadata;
73    };
74
75    for entry in project_dirs.flatten() {
76        if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
77            continue;
78        }
79
80        let project_path = entry.path();
81
82        // Find all .jsonl files (session logs)
83        let Ok(files) = fs::read_dir(&project_path) else {
84            continue;
85        };
86
87        for file_entry in files.flatten() {
88            let file_name = file_entry.file_name();
89            let file_name_str = file_name.to_string_lossy();
90
91            if file_name_str.ends_with(".jsonl") {
92                let session_id = file_name_str.trim_end_matches(".jsonl").to_string();
93                let jsonl_path = file_entry.path();
94                let session_info = read_session_info_from_jsonl(&jsonl_path);
95
96                metadata.insert(session_id, session_info);
97            }
98        }
99
100        // Also check sessions-index.json
101        let index_path = project_path.join("sessions-index.json");
102        if index_path.exists() {
103            if let Ok(content) = fs::read_to_string(&index_path) {
104                if let Ok(index_data) = serde_json::from_str::<Value>(&content) {
105                    if let Some(entries) = index_data.get("entries").and_then(|v| v.as_array()) {
106                        for entry in entries {
107                            if let Some(sid) = entry.get("sessionId").and_then(|v| v.as_str()) {
108                                if let Some(meta) = metadata.get_mut(sid) {
109                                    if let Some(desc) = entry.get("description").and_then(|v| v.as_str()) {
110                                        meta.description = Some(desc.to_string());
111                                    }
112                                    if let Some(branch) = entry.get("gitBranch").and_then(|v| v.as_str()) {
113                                        meta.git_branch = Some(branch.to_string());
114                                    }
115                                    if let Some(created) = entry.get("created").and_then(|v| v.as_str()) {
116                                        meta.created = Some(created.to_string());
117                                    }
118                                }
119                            }
120                        }
121                    }
122                }
123            }
124        }
125    }
126
127    metadata
128}
129
130/// Read customTitle, slug, and projectPath from first 64KB of a JSONL file
131pub fn read_session_info_from_jsonl(path: &std::path::Path) -> SessionMeta {
132    let mut meta = SessionMeta::default();
133
134    let Ok(mut file) = File::open(path) else {
135        return meta;
136    };
137
138    let mut buffer = vec![0u8; 65536];
139    let bytes_read = file.read(&mut buffer).unwrap_or(0);
140    buffer.truncate(bytes_read);
141
142    let content = String::from_utf8_lossy(&buffer);
143
144    for line in content.lines() {
145        if line.trim().is_empty() {
146            continue;
147        }
148
149        let Ok(data) = serde_json::from_str::<Value>(line) else {
150            continue;
151        };
152
153        // Check for custom-title entry (from /rename command)
154        if data.get("type").and_then(|v| v.as_str()) == Some("custom-title") {
155            if let Some(title) = data.get("customTitle").and_then(|v| v.as_str()) {
156                meta.custom_title = Some(title.to_string());
157            }
158        }
159
160        // Check for slug
161        if meta.slug.is_none() {
162            if let Some(slug) = data.get("slug").and_then(|v| v.as_str()) {
163                meta.slug = Some(slug.to_string());
164            }
165        }
166
167        // Extract project path from cwd field
168        if meta.project_path.is_none() {
169            if let Some(cwd) = data.get("cwd").and_then(|v| v.as_str()) {
170                meta.project_path = Some(cwd.to_string());
171            }
172        }
173
174        // Stop early if we found all three
175        if meta.custom_title.is_some() && meta.slug.is_some() && meta.project_path.is_some() {
176            break;
177        }
178    }
179
180    meta
181}