Skip to main content

flow_server/
state.rs

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