Skip to main content

codex_helper_core/
sessions.rs

1use std::cmp::{Ordering, Reverse};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result, anyhow};
8use futures_util::StreamExt;
9use futures_util::stream;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use tokio::fs;
13use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, BufReader};
14
15use crate::config::codex_sessions_dir;
16use crate::file_replace::write_bytes_file_async;
17
18mod stats_cache;
19mod transcript;
20
21use stats_cache::{SessionStatsCache, SessionStatsSnapshot};
22pub use transcript::{codex_session_transcript_tail_contains_query, read_codex_session_transcript};
23
24/// Summary information for a Codex conversation session.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum SessionSummarySource {
27    #[default]
28    LocalFile,
29    ObservedOnly,
30}
31
32#[derive(Debug, Clone)]
33pub struct SessionSummary {
34    pub id: String,
35    pub path: PathBuf,
36    pub cwd: Option<String>,
37    pub created_at: Option<String>,
38    pub updated_at: Option<String>,
39    /// RFC3339 timestamp string for the most recent assistant message, if available.
40    pub last_response_at: Option<String>,
41    /// Number of user turns (from `event_msg` user_message).
42    pub user_turns: usize,
43    /// Number of assistant messages (from `response_item` message role=assistant).
44    pub assistant_turns: usize,
45    /// Conversation rounds (best-effort; currently `min(user_turns, assistant_turns)`).
46    pub rounds: usize,
47    pub first_user_message: Option<String>,
48    pub source: SessionSummarySource,
49    pub sort_hint_ms: Option<u64>,
50}
51
52/// Basic metadata for a Codex session (best-effort parsed from JSONL).
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SessionMeta {
55    pub id: String,
56    pub cwd: Option<String>,
57    pub created_at: Option<String>,
58}
59
60/// A single transcript message extracted from a Codex session JSONL.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct SessionTranscriptMessage {
63    pub timestamp: Option<String>,
64    pub role: String,
65    pub text: String,
66}
67
68/// Minimal data for printing `project_root session_id` style lists.
69#[derive(Debug, Clone)]
70pub struct RecentSession {
71    pub id: String,
72    pub cwd: Option<String>,
73    pub mtime_ms: u64,
74}
75
76#[cfg(feature = "gui")]
77#[derive(Debug, Clone)]
78pub struct SessionDayDir {
79    pub date: String,
80    pub path: PathBuf,
81}
82
83#[cfg(feature = "gui")]
84#[derive(Debug, Clone)]
85pub struct SessionIndexItem {
86    pub id: String,
87    pub path: PathBuf,
88    pub cwd: Option<String>,
89    pub created_at: Option<String>,
90    pub updated_hint: Option<String>,
91    pub mtime_ms: u64,
92    pub first_user_message: Option<String>,
93}
94
95pub fn infer_project_root_from_cwd(cwd: &str) -> String {
96    let path = std::path::PathBuf::from(cwd);
97    if !path.is_absolute() {
98        return cwd.to_string();
99    }
100
101    let canonical = std::fs::canonicalize(&path).unwrap_or(path);
102    let mut cur = canonical.clone();
103    loop {
104        if cur.join(".git").exists() {
105            return cur.to_string_lossy().to_string();
106        }
107        if !cur.pop() {
108            break;
109        }
110    }
111    canonical.to_string_lossy().to_string()
112}
113
114const MAX_SCAN_FILES: usize = 10_000;
115const HEAD_SCAN_LINES: usize = 512;
116const IO_CHUNK_SIZE: usize = 64 * 1024;
117const TAIL_SCAN_MAX_BYTES: usize = 1024 * 1024;
118const SESSION_IO_CONCURRENCY: usize = 8;
119
120const MAX_SCAN_FILES_RECENT: usize = 200_000;
121
122/// Find recent Codex sessions across all projects. Results are ordered newest-first by updated_at.
123pub async fn find_codex_sessions(limit: usize) -> Result<Vec<SessionSummary>> {
124    let root = codex_sessions_dir();
125    find_codex_sessions_in_dir(&root, limit).await
126}
127
128async fn find_codex_sessions_in_dir(
129    sessions_dir: &Path,
130    limit: usize,
131) -> Result<Vec<SessionSummary>> {
132    if limit == 0 {
133        return Ok(Vec::new());
134    }
135    if !sessions_dir.exists() {
136        return Ok(Vec::new());
137    }
138
139    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
140    let mut headers: Vec<SessionHeader> = Vec::new();
141    let mut scanned_files: usize = 0;
142
143    let year_dirs = collect_dirs_desc(sessions_dir, |s| s.parse::<u32>().ok()).await?;
144
145    'outer: for (_year, year_path) in year_dirs {
146        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
147        for (_month, month_path) in month_dirs {
148            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
149            for (_day, day_path) in day_dirs {
150                let day_files = collect_rollout_files_sorted(&day_path).await?;
151                for path in day_files {
152                    if scanned_files >= MAX_SCAN_FILES {
153                        break 'outer;
154                    }
155                    scanned_files += 1;
156
157                    let header_opt = read_session_header(&path, &cwd).await?;
158                    if let Some(header) = header_opt {
159                        headers.push(header);
160                    }
161                }
162            }
163        }
164    }
165
166    select_and_expand_headers(Vec::new(), headers, limit).await
167}
168
169/// Find recent Codex sessions for a given directory, preferring sessions whose cwd matches that directory
170/// (or one of its ancestors/descendants). Results are ordered newest-first by updated_at.
171pub async fn find_codex_sessions_for_dir(
172    root_dir: &Path,
173    limit: usize,
174) -> Result<Vec<SessionSummary>> {
175    let root = codex_sessions_dir();
176    if !root.exists() {
177        return Ok(Vec::new());
178    }
179
180    let mut matched: Vec<SessionHeader> = Vec::new();
181    let mut others: Vec<SessionHeader> = Vec::new();
182    let mut scanned_files: usize = 0;
183
184    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
185
186    'outer: for (_year, year_path) in year_dirs {
187        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
188        for (_month, month_path) in month_dirs {
189            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
190            for (_day, day_path) in day_dirs {
191                let day_files = collect_rollout_files_sorted(&day_path).await?;
192                for path in day_files {
193                    if scanned_files >= MAX_SCAN_FILES {
194                        break 'outer;
195                    }
196                    scanned_files += 1;
197
198                    let header_opt = read_session_header(&path, root_dir).await?;
199                    let Some(header) = header_opt else {
200                        continue;
201                    };
202
203                    if header.is_cwd_match {
204                        matched.push(header);
205                    } else {
206                        others.push(header);
207                    }
208                }
209            }
210        }
211    }
212
213    select_and_expand_headers(matched, others, limit).await
214}
215
216/// Search Codex sessions for user messages containing the given substring.
217/// Matching is case-insensitive and only considers the first user message per session.
218pub async fn search_codex_sessions_for_dir(
219    root_dir: &Path,
220    query: &str,
221    limit: usize,
222) -> Result<Vec<SessionSummary>> {
223    let needle = query.to_lowercase();
224
225    let root = codex_sessions_dir();
226    if !root.exists() {
227        return Ok(Vec::new());
228    }
229
230    let mut matched: Vec<SessionHeader> = Vec::new();
231    let mut others: Vec<SessionHeader> = Vec::new();
232    let mut scanned_files: usize = 0;
233
234    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
235
236    'outer: for (_year, year_path) in year_dirs {
237        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
238        for (_month, month_path) in month_dirs {
239            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
240            for (_day, day_path) in day_dirs {
241                let day_files = collect_rollout_files_sorted(&day_path).await?;
242                for path in day_files {
243                    if scanned_files >= MAX_SCAN_FILES {
244                        break 'outer;
245                    }
246                    scanned_files += 1;
247
248                    let header_opt = read_session_header(&path, root_dir).await?;
249                    let Some(header) = header_opt else {
250                        continue;
251                    };
252                    if !header
253                        .first_user_message
254                        .to_lowercase()
255                        .contains(needle.as_str())
256                    {
257                        continue;
258                    }
259
260                    if header.is_cwd_match {
261                        matched.push(header);
262                    } else {
263                        others.push(header);
264                    }
265                }
266            }
267        }
268    }
269
270    select_and_expand_headers(matched, others, limit).await
271}
272
273/// Convenience wrapper that uses the current working directory as the root for session matching.
274pub async fn find_codex_sessions_for_current_dir(limit: usize) -> Result<Vec<SessionSummary>> {
275    let cwd = std::env::current_dir().context("failed to resolve current directory")?;
276    find_codex_sessions_for_dir(&cwd, limit).await
277}
278
279/// Convenience wrapper to search sessions under the current working directory.
280pub async fn search_codex_sessions_for_current_dir(
281    query: &str,
282    limit: usize,
283) -> Result<Vec<SessionSummary>> {
284    let cwd = std::env::current_dir().context("failed to resolve current directory")?;
285    search_codex_sessions_for_dir(&cwd, query, limit).await
286}
287
288/// List recent Codex sessions across all projects, filtered by session file mtime.
289///
290/// This is optimized for "resume" workflows: it avoids counting turns/timestamps and only reads the
291/// `session_meta` header for sessions that pass the recency filter.
292pub async fn find_recent_codex_sessions(
293    since: Duration,
294    limit: usize,
295) -> Result<Vec<RecentSession>> {
296    let root = codex_sessions_dir();
297    find_recent_codex_sessions_in_dir(&root, since, limit).await
298}
299
300#[cfg(feature = "gui")]
301pub async fn find_recent_codex_session_summaries(
302    since: Duration,
303    limit: usize,
304) -> Result<Vec<SessionSummary>> {
305    if limit == 0 {
306        return Ok(Vec::new());
307    }
308    let sessions_dir = codex_sessions_dir();
309    if !sessions_dir.exists() {
310        return Ok(Vec::new());
311    }
312
313    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
314
315    let now_ms = SystemTime::now()
316        .duration_since(UNIX_EPOCH)
317        .unwrap_or_default()
318        .as_millis()
319        .min(u64::MAX as u128) as u64;
320    let since_ms = since.as_millis().min(u64::MAX as u128) as u64;
321    let threshold_ms = now_ms.saturating_sub(since_ms);
322
323    let mut headers: Vec<SessionHeader> = Vec::new();
324    let mut scanned_files: usize = 0;
325
326    let year_dirs = collect_dirs_desc(&sessions_dir, |s| s.parse::<u32>().ok()).await?;
327    'outer: for (_year, year_path) in year_dirs {
328        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
329        for (_month, month_path) in month_dirs {
330            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
331            for (_day, day_path) in day_dirs {
332                let day_files = collect_rollout_files_sorted(&day_path).await?;
333                for path in day_files {
334                    if scanned_files >= MAX_SCAN_FILES_RECENT {
335                        break 'outer;
336                    }
337                    scanned_files += 1;
338
339                    let meta = match fs::metadata(&path).await {
340                        Ok(m) => m,
341                        Err(_) => continue,
342                    };
343                    let mtime_ms = meta
344                        .modified()
345                        .ok()
346                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
347                        .map(|d| d.as_millis().min(u64::MAX as u128) as u64)
348                        .unwrap_or(0);
349                    if mtime_ms < threshold_ms {
350                        continue;
351                    }
352
353                    let header_opt = read_session_header(&path, &cwd).await?;
354                    let Some(header) = header_opt else {
355                        continue;
356                    };
357                    headers.push(header);
358                }
359            }
360        }
361    }
362
363    select_and_expand_headers(Vec::new(), headers, limit).await
364}
365
366#[cfg(feature = "gui")]
367pub async fn list_codex_session_day_dirs(limit: usize) -> Result<Vec<SessionDayDir>> {
368    if limit == 0 {
369        return Ok(Vec::new());
370    }
371    let root = codex_sessions_dir();
372    if !root.exists() {
373        return Ok(Vec::new());
374    }
375
376    let mut out: Vec<SessionDayDir> = Vec::new();
377    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
378    'outer: for (year, year_path) in year_dirs {
379        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
380        for (month, month_path) in month_dirs {
381            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
382            for (day, day_path) in day_dirs {
383                out.push(SessionDayDir {
384                    date: format!("{year:04}-{month:02}-{day:02}"),
385                    path: day_path,
386                });
387                if out.len() >= limit {
388                    break 'outer;
389                }
390            }
391        }
392    }
393    Ok(out)
394}
395
396#[cfg(feature = "gui")]
397pub async fn list_codex_sessions_in_day_dir(
398    day_dir: &Path,
399    limit: usize,
400) -> Result<Vec<SessionIndexItem>> {
401    if limit == 0 {
402        return Ok(Vec::new());
403    }
404    if !day_dir.exists() {
405        return Ok(Vec::new());
406    }
407
408    let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
409    let day_files = collect_rollout_files_sorted(day_dir).await?;
410    let mut out: Vec<SessionIndexItem> = Vec::new();
411    for chunk in day_files.chunks(SESSION_IO_CONCURRENCY) {
412        let cwd = cwd.clone();
413        let mut stream = stream::iter(chunk.iter().cloned())
414            .map(move |path| {
415                let cwd = cwd.clone();
416                async move { read_session_index_item(path, cwd).await }
417            })
418            .buffer_unordered(SESSION_IO_CONCURRENCY);
419
420        while let Some(item) = stream.next().await {
421            if let Some(item) = item? {
422                out.push(item);
423            }
424        }
425        if out.len() >= limit {
426            break;
427        }
428    }
429
430    out.sort_by_key(|item| Reverse(item.mtime_ms));
431    out.truncate(limit);
432    Ok(out)
433}
434
435#[cfg(feature = "gui")]
436async fn read_session_index_item(path: PathBuf, cwd: PathBuf) -> Result<Option<SessionIndexItem>> {
437    let header_opt = read_session_header(&path, &cwd).await?;
438    let Some(mut header) = header_opt else {
439        return Ok(None);
440    };
441    header.updated_hint = read_last_timestamp_from_tail(&header.path)
442        .await?
443        .or_else(|| header.created_at.clone());
444    Ok(Some(SessionIndexItem {
445        id: header.id,
446        path: header.path,
447        cwd: header.cwd,
448        created_at: header.created_at,
449        updated_hint: header.updated_hint,
450        mtime_ms: header.mtime_ms,
451        first_user_message: Some(header.first_user_message),
452    }))
453}
454
455async fn find_recent_codex_sessions_in_dir(
456    sessions_dir: &Path,
457    since: Duration,
458    limit: usize,
459) -> Result<Vec<RecentSession>> {
460    if limit == 0 {
461        return Ok(Vec::new());
462    }
463    if since.is_zero() {
464        return Ok(Vec::new());
465    }
466    if !sessions_dir.exists() {
467        return Ok(Vec::new());
468    }
469
470    let now_ms = SystemTime::now()
471        .duration_since(UNIX_EPOCH)
472        .unwrap_or_default()
473        .as_millis()
474        .min(u64::MAX as u128) as u64;
475    let since_ms = since.as_millis().min(u64::MAX as u128) as u64;
476    let threshold_ms = now_ms.saturating_sub(since_ms);
477
478    let mut out: Vec<RecentSession> = Vec::new();
479    let mut scanned_files: usize = 0;
480
481    let year_dirs = collect_dirs_desc(sessions_dir, |s| s.parse::<u32>().ok()).await?;
482    'outer: for (_year, year_path) in year_dirs {
483        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
484        for (_month, month_path) in month_dirs {
485            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
486            for (_day, day_path) in day_dirs {
487                let day_files = collect_rollout_files_sorted(&day_path).await?;
488                for path in day_files {
489                    if scanned_files >= MAX_SCAN_FILES_RECENT {
490                        break 'outer;
491                    }
492                    scanned_files += 1;
493
494                    let meta = match fs::metadata(&path).await {
495                        Ok(m) => m,
496                        Err(_) => continue,
497                    };
498                    let mtime_ms = meta
499                        .modified()
500                        .ok()
501                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
502                        .map(|d| d.as_millis().min(u64::MAX as u128) as u64)
503                        .unwrap_or(0);
504                    if mtime_ms < threshold_ms {
505                        continue;
506                    }
507
508                    let file_id = path
509                        .file_name()
510                        .and_then(|s| s.to_str())
511                        .and_then(parse_timestamp_and_uuid)
512                        .map(|(_, uuid)| uuid);
513
514                    let meta = read_codex_session_meta(&path).await?;
515                    let (id, cwd) = if let Some(meta) = meta {
516                        (meta.id, meta.cwd)
517                    } else if let Some(id) = file_id {
518                        (id, None)
519                    } else {
520                        continue;
521                    };
522
523                    out.push(RecentSession { id, cwd, mtime_ms });
524                }
525            }
526        }
527    }
528
529    out.sort_by_key(|item| Reverse((item.mtime_ms, item.id.clone())));
530    out.truncate(limit);
531    Ok(out)
532}
533
534/// Find a Codex session's cwd by its session id (UUID suffix in rollout filename).
535///
536/// This is best-effort and scans session files from newest to oldest until it finds a match.
537pub async fn find_codex_session_cwd_by_id(session_id: &str) -> Result<Option<String>> {
538    let root = codex_sessions_dir();
539    if !root.exists() {
540        return Ok(None);
541    }
542
543    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
544    for (_year, year_path) in year_dirs {
545        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
546        for (_month, month_path) in month_dirs {
547            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
548            for (_day, day_path) in day_dirs {
549                let day_files = collect_rollout_files_sorted(&day_path).await?;
550                for path in day_files {
551                    let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
552                        continue;
553                    };
554                    let Some((_ts, uuid)) = parse_timestamp_and_uuid(name) else {
555                        continue;
556                    };
557                    if uuid != session_id {
558                        continue;
559                    }
560
561                    let file = fs::File::open(&path)
562                        .await
563                        .with_context(|| format!("failed to open session file {:?}", path))?;
564                    let reader = BufReader::new(file);
565                    let mut lines = reader.lines();
566                    while let Some(line) = lines.next_line().await? {
567                        let line = line.trim();
568                        if line.is_empty() {
569                            continue;
570                        }
571                        let value: Value = match serde_json::from_str(line) {
572                            Ok(v) => v,
573                            Err(_) => continue,
574                        };
575                        if let Some(meta) = parse_session_meta(&value) {
576                            return Ok(meta.cwd);
577                        }
578                    }
579
580                    return Ok(None);
581                }
582            }
583        }
584    }
585
586    Ok(None)
587}
588
589/// Best-effort: locate a Codex session JSONL file by session id.
590///
591/// We first try to match the UUID suffix in the `rollout-...-<uuid>.jsonl` filename (fast path),
592/// then fall back to scanning session_meta records to match `payload.id`.
593pub async fn find_codex_session_file_by_id(session_id: &str) -> Result<Option<PathBuf>> {
594    Ok(find_codex_session_files_by_ids(&[session_id.to_string()])
595        .await?
596        .remove(session_id))
597}
598
599pub async fn find_codex_session_files_by_ids(
600    session_ids: &[String],
601) -> Result<HashMap<String, PathBuf>> {
602    find_codex_session_files_by_ids_in_dir(&codex_sessions_dir(), session_ids).await
603}
604
605async fn find_codex_session_files_by_ids_in_dir(
606    root: &Path,
607    session_ids: &[String],
608) -> Result<HashMap<String, PathBuf>> {
609    if !root.exists() || session_ids.is_empty() {
610        return Ok(HashMap::new());
611    }
612
613    let mut remaining = session_ids
614        .iter()
615        .map(|sid| sid.trim())
616        .filter(|sid| !sid.is_empty())
617        .map(ToOwned::to_owned)
618        .collect::<std::collections::HashSet<_>>();
619    if remaining.is_empty() {
620        return Ok(HashMap::new());
621    }
622
623    let mut found = HashMap::new();
624    let mut scanned_files: usize = 0;
625    let year_dirs = collect_dirs_desc(root, |s| s.parse::<u32>().ok()).await?;
626
627    'outer: for (_year, year_path) in year_dirs {
628        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
629        for (_month, month_path) in month_dirs {
630            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
631            for (_day, day_path) in day_dirs {
632                let day_files = collect_rollout_files_sorted(&day_path).await?;
633                for path in day_files {
634                    if scanned_files >= MAX_SCAN_FILES || remaining.is_empty() {
635                        break 'outer;
636                    }
637                    scanned_files += 1;
638
639                    if let Some(name) = path.file_name().and_then(|s| s.to_str())
640                        && let Some((_ts, uuid)) = parse_timestamp_and_uuid(name)
641                        && remaining.remove(&uuid)
642                    {
643                        found.insert(uuid.to_string(), path.clone());
644                        if remaining.is_empty() {
645                            break 'outer;
646                        }
647                        continue;
648                    }
649
650                    if let Some(meta) = read_codex_session_meta(&path).await?
651                        && remaining.remove(meta.id.as_str())
652                    {
653                        found.insert(meta.id, path);
654                        if remaining.is_empty() {
655                            break 'outer;
656                        }
657                    }
658                }
659            }
660        }
661    }
662
663    Ok(found)
664}
665
666/// Read the `session_meta` record from a Codex session JSONL file (best-effort).
667pub async fn read_codex_session_meta(path: &Path) -> Result<Option<SessionMeta>> {
668    let file = fs::File::open(path)
669        .await
670        .with_context(|| format!("failed to open session file {:?}", path))?;
671    let reader = BufReader::new(file);
672    let mut lines = reader.lines();
673
674    let mut lines_scanned = 0usize;
675    while let Some(line) = lines.next_line().await? {
676        let trimmed = line.trim();
677        if trimmed.is_empty() {
678            continue;
679        }
680        lines_scanned += 1;
681        if lines_scanned > HEAD_SCAN_LINES {
682            break;
683        }
684
685        let value: Value = match serde_json::from_str(trimmed) {
686            Ok(v) => v,
687            Err(_) => continue,
688        };
689
690        if let Some(meta) = parse_session_meta(&value) {
691            return Ok(Some(SessionMeta {
692                id: meta.id,
693                cwd: meta.cwd,
694                created_at: meta.created_at,
695            }));
696        }
697    }
698
699    Ok(None)
700}
701
702#[cfg(test)]
703async fn summarize_session_for_current_dir(
704    path: &Path,
705    cwd: &Path,
706) -> Result<Option<SessionSummary>> {
707    let header_opt = read_session_header(path, cwd).await?;
708    let Some(header) = header_opt else {
709        return Ok(None);
710    };
711    Ok(Some(expand_header_to_summary_uncached(header).await?))
712}
713
714struct SessionMetaInfo {
715    id: String,
716    cwd: Option<String>,
717    created_at: Option<String>,
718}
719
720#[derive(Debug, Clone)]
721struct SessionHeader {
722    id: String,
723    path: PathBuf,
724    cwd: Option<String>,
725    created_at: Option<String>,
726    /// File modified time in milliseconds since epoch (used for cheap recency sorting).
727    mtime_ms: u64,
728    /// Best-effort: timestamp of the most recent JSONL record (from the file tail; only computed for displayed rows).
729    updated_hint: Option<String>,
730    first_user_message: String,
731    is_cwd_match: bool,
732}
733
734fn parse_session_meta(value: &Value) -> Option<SessionMetaInfo> {
735    let obj = value.as_object()?;
736    let type_str = obj.get("type")?.as_str()?;
737    if type_str != "session_meta" {
738        return None;
739    }
740
741    let payload = obj.get("payload")?.as_object()?;
742    let id = payload.get("id").and_then(|v| v.as_str())?.to_string();
743    let cwd = payload
744        .get("cwd")
745        .and_then(|v| v.as_str())
746        .map(|s| s.to_string());
747    let created_at = payload
748        .get("timestamp")
749        .and_then(|v| v.as_str())
750        .map(|s| s.to_string())
751        .or_else(|| {
752            obj.get("timestamp")
753                .and_then(|v| v.as_str())
754                .map(|s| s.to_string())
755        });
756
757    Some(SessionMetaInfo {
758        id,
759        cwd,
760        created_at,
761    })
762}
763
764fn user_message_text(value: &Value) -> Option<&str> {
765    let obj = value.as_object()?;
766    let type_str = obj.get("type")?.as_str()?;
767    if type_str != "event_msg" {
768        return None;
769    }
770    let payload = obj.get("payload")?.as_object()?;
771    let payload_type = payload.get("type")?.as_str()?;
772    if payload_type != "user_message" {
773        return None;
774    }
775    payload.get("message").and_then(|v| v.as_str())
776}
777
778fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
779    if needle.is_empty() {
780        return true;
781    }
782    if haystack.len() < needle.len() {
783        return false;
784    }
785    haystack.windows(needle.len()).any(|w| w == needle)
786}
787
788async fn read_session_header(path: &Path, cwd: &Path) -> Result<Option<SessionHeader>> {
789    let meta = fs::metadata(path)
790        .await
791        .with_context(|| format!("failed to stat session file {:?}", path))?;
792    let mtime_ms = meta
793        .modified()
794        .ok()
795        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
796        .map(|d| d.as_millis() as u64)
797        .unwrap_or(0);
798
799    let file = fs::File::open(path)
800        .await
801        .with_context(|| format!("failed to open session file {:?}", path))?;
802    let reader = BufReader::new(file);
803    let mut lines = reader.lines();
804
805    let mut session_id: Option<String> = None;
806    let mut cwd_str: Option<String> = None;
807    let mut created_at: Option<String> = None;
808    let mut first_user_message: Option<String> = None;
809
810    let mut lines_scanned = 0usize;
811    while let Some(line) = lines.next_line().await? {
812        let trimmed = line.trim();
813        if trimmed.is_empty() {
814            continue;
815        }
816        lines_scanned += 1;
817        if lines_scanned > HEAD_SCAN_LINES {
818            break;
819        }
820        let value: Value = match serde_json::from_str(trimmed) {
821            Ok(v) => v,
822            Err(_) => continue,
823        };
824
825        if session_id.is_none()
826            && let Some(meta) = parse_session_meta(&value)
827        {
828            session_id = Some(meta.id);
829            cwd_str = meta.cwd;
830            created_at = meta.created_at;
831        }
832
833        if first_user_message.is_none()
834            && let Some(msg) = user_message_text(&value)
835        {
836            first_user_message = Some(msg.to_string());
837        }
838
839        if session_id.is_some() && first_user_message.is_some() {
840            break;
841        }
842    }
843
844    let Some(id) = session_id else {
845        return Ok(None);
846    };
847    let Some(first_user_message) = first_user_message else {
848        return Ok(None);
849    };
850
851    let cwd_value = cwd_str.clone();
852    let is_cwd_match = cwd_value
853        .as_deref()
854        .map(|s| path_matches_current_dir(s, cwd))
855        .unwrap_or(false);
856
857    Ok(Some(SessionHeader {
858        id,
859        path: path.to_path_buf(),
860        cwd: cwd_value,
861        created_at,
862        mtime_ms,
863        updated_hint: None,
864        first_user_message,
865        is_cwd_match,
866    }))
867}
868
869async fn select_and_expand_headers(
870    matched: Vec<SessionHeader>,
871    others: Vec<SessionHeader>,
872    limit: usize,
873) -> Result<Vec<SessionSummary>> {
874    if limit == 0 {
875        return Ok(Vec::new());
876    }
877
878    let mut chosen = if !matched.is_empty() { matched } else { others };
879    // Use file mtime for cheap recency ordering; this correctly surfaces sessions that were resumed
880    // (older filename timestamp but recently appended to).
881    chosen.sort_by_key(|item| Reverse(item.mtime_ms));
882    if chosen.len() > limit {
883        chosen.truncate(limit);
884    }
885
886    let cache = Arc::new(Mutex::new(SessionStatsCache::load_default().await));
887    let mut out: Vec<SessionSummary> = Vec::with_capacity(chosen.len().min(limit));
888    let mut stream = stream::iter(chosen)
889        .map(|header| {
890            let cache = Arc::clone(&cache);
891            async move { expand_header_to_summary_cached(cache, header).await }
892        })
893        .buffer_unordered(SESSION_IO_CONCURRENCY);
894
895    while let Some(summary) = stream.next().await {
896        out.push(summary?);
897    }
898
899    drop(stream);
900    let mut cache = Arc::try_unwrap(cache)
901        .map_err(|_| anyhow!("session stats cache still has active workers"))?
902        .into_inner()
903        .map_err(|_| anyhow!("session stats cache lock poisoned"))?;
904    cache.save_if_dirty().await?;
905
906    sort_by_updated_desc(&mut out);
907    out.truncate(limit);
908    Ok(out)
909}
910
911fn build_summary_from_stats(
912    header: SessionHeader,
913    user_turns: usize,
914    assistant_turns: usize,
915    last_response_at: Option<String>,
916) -> SessionSummary {
917    let rounds = user_turns.min(assistant_turns);
918    let updated_at = last_response_at
919        .clone()
920        .or_else(|| header.updated_hint.clone())
921        .or_else(|| header.created_at.clone());
922
923    SessionSummary {
924        id: header.id,
925        path: header.path,
926        cwd: header.cwd,
927        created_at: header.created_at,
928        updated_at,
929        last_response_at,
930        user_turns,
931        assistant_turns,
932        rounds,
933        first_user_message: Some(header.first_user_message),
934        source: SessionSummarySource::LocalFile,
935        sort_hint_ms: None,
936    }
937}
938
939async fn expand_header_to_summary_cached(
940    cache: Arc<Mutex<SessionStatsCache>>,
941    mut header: SessionHeader,
942) -> Result<SessionSummary> {
943    let path = header.path.clone();
944    let key = path.to_string_lossy().to_string();
945    let meta = fs::metadata(&path)
946        .await
947        .with_context(|| format!("failed to stat session file {:?}", path))?;
948    let size = meta.len();
949    let mtime_ms = meta
950        .modified()
951        .ok()
952        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
953        .map(|d| d.as_millis() as u64)
954        .unwrap_or(0);
955
956    let cached = {
957        let cache = cache
958            .lock()
959            .map_err(|_| anyhow!("session stats cache lock poisoned"))?;
960        cache.lookup(&key, mtime_ms, size)
961    };
962
963    let stats = if let Some(stats) = cached {
964        if stats.last_response_at.is_none() && header.updated_hint.is_none() {
965            header.updated_hint = read_last_timestamp_from_tail(&path)
966                .await?
967                .or_else(|| header.created_at.clone());
968        }
969        stats
970    } else {
971        let (counts, tail) = tokio::join!(
972            count_turns_in_file(&path),
973            read_tail_timestamps(&path, true)
974        );
975        let (user_turns, assistant_turns) = counts?;
976        let tail = tail?;
977        header.updated_hint = tail.last_record_at.or_else(|| header.created_at.clone());
978
979        let stats = SessionStatsSnapshot {
980            user_turns,
981            assistant_turns,
982            last_response_at: tail.last_assistant_at,
983        };
984        {
985            let mut cache = cache
986                .lock()
987                .map_err(|_| anyhow!("session stats cache lock poisoned"))?;
988            cache.insert(key, mtime_ms, size, &stats);
989        }
990        stats
991    };
992
993    Ok(build_summary_from_stats(
994        header,
995        stats.user_turns,
996        stats.assistant_turns,
997        stats.last_response_at,
998    ))
999}
1000
1001#[cfg(test)]
1002async fn expand_header_to_summary_uncached(header: SessionHeader) -> Result<SessionSummary> {
1003    let (user_turns, assistant_turns) = count_turns_in_file(&header.path).await?;
1004    let last_response_at = read_last_assistant_timestamp_from_tail(&header.path).await?;
1005    Ok(build_summary_from_stats(
1006        header,
1007        user_turns,
1008        assistant_turns,
1009        last_response_at,
1010    ))
1011}
1012
1013async fn count_turns_in_file(path: &Path) -> Result<(usize, usize)> {
1014    const USER_TURN_NEEDLE: &[u8] = br#""payload":{"type":"user_message""#;
1015    const ASSISTANT_TURN_NEEDLE: &[u8] = br#""role":"assistant""#;
1016
1017    let mut file = fs::File::open(path)
1018        .await
1019        .with_context(|| format!("failed to open session file {:?}", path))?;
1020
1021    let mut buf = vec![0u8; IO_CHUNK_SIZE];
1022    let mut user_carry: Vec<u8> = Vec::new();
1023    let mut assistant_carry: Vec<u8> = Vec::new();
1024    let mut user_total = 0usize;
1025    let mut assistant_total = 0usize;
1026    let mut user_window: Vec<u8> = Vec::with_capacity(IO_CHUNK_SIZE + USER_TURN_NEEDLE.len());
1027    let mut assistant_window: Vec<u8> =
1028        Vec::with_capacity(IO_CHUNK_SIZE + ASSISTANT_TURN_NEEDLE.len());
1029
1030    loop {
1031        let n = file.read(&mut buf).await?;
1032        if n == 0 {
1033            break;
1034        }
1035
1036        user_window.clear();
1037        user_window.extend_from_slice(&user_carry);
1038        user_window.extend_from_slice(&buf[..n]);
1039        user_total = user_total.saturating_add(count_subslice(&user_window, USER_TURN_NEEDLE));
1040
1041        assistant_window.clear();
1042        assistant_window.extend_from_slice(&assistant_carry);
1043        assistant_window.extend_from_slice(&buf[..n]);
1044        assistant_total = assistant_total
1045            .saturating_add(count_subslice(&assistant_window, ASSISTANT_TURN_NEEDLE));
1046
1047        let user_keep = USER_TURN_NEEDLE.len().saturating_sub(1);
1048        user_carry = if user_keep > 0 && user_window.len() >= user_keep {
1049            user_window[user_window.len() - user_keep..].to_vec()
1050        } else {
1051            Vec::new()
1052        };
1053
1054        let assistant_keep = ASSISTANT_TURN_NEEDLE.len().saturating_sub(1);
1055        assistant_carry = if assistant_keep > 0 && assistant_window.len() >= assistant_keep {
1056            assistant_window[assistant_window.len() - assistant_keep..].to_vec()
1057        } else {
1058            Vec::new()
1059        };
1060    }
1061
1062    Ok((user_total, assistant_total))
1063}
1064
1065fn count_subslice(haystack: &[u8], needle: &[u8]) -> usize {
1066    if needle.is_empty() {
1067        return 0;
1068    }
1069    if haystack.len() < needle.len() {
1070        return 0;
1071    }
1072    haystack
1073        .windows(needle.len())
1074        .filter(|w| *w == needle)
1075        .count()
1076}
1077
1078#[derive(Debug, Default)]
1079struct TailTimestamps {
1080    last_record_at: Option<String>,
1081    last_assistant_at: Option<String>,
1082}
1083
1084async fn read_last_timestamp_from_tail(path: &Path) -> Result<Option<String>> {
1085    Ok(read_tail_timestamps(path, false).await?.last_record_at)
1086}
1087
1088#[cfg(test)]
1089async fn read_last_assistant_timestamp_from_tail(path: &Path) -> Result<Option<String>> {
1090    Ok(read_tail_timestamps(path, true).await?.last_assistant_at)
1091}
1092
1093async fn read_tail_timestamps(path: &Path, include_assistant: bool) -> Result<TailTimestamps> {
1094    const ASSISTANT_ROLE_NEEDLE: &[u8] = br#""role":"assistant""#;
1095
1096    let mut file = fs::File::open(path)
1097        .await
1098        .with_context(|| format!("failed to open session file {:?}", path))?;
1099    let meta = file
1100        .metadata()
1101        .await
1102        .with_context(|| format!("failed to stat session file {:?}", path))?;
1103    let mut pos = meta.len();
1104    if pos == 0 {
1105        return Ok(TailTimestamps::default());
1106    }
1107
1108    let mut scanned = 0usize;
1109    let mut carry: Vec<u8> = Vec::new();
1110    let chunk_size = IO_CHUNK_SIZE as u64;
1111    let mut found = TailTimestamps::default();
1112
1113    while pos > 0 && scanned < TAIL_SCAN_MAX_BYTES {
1114        let start = pos.saturating_sub(chunk_size);
1115        let size = (pos - start) as usize;
1116        file.seek(std::io::SeekFrom::Start(start)).await?;
1117
1118        let mut chunk = vec![0u8; size];
1119        file.read_exact(&mut chunk).await?;
1120        scanned = scanned.saturating_add(size);
1121
1122        if !carry.is_empty() {
1123            chunk.extend_from_slice(&carry);
1124        }
1125
1126        // Iterate lines from the end.
1127        let mut end = chunk.len();
1128        while end > 0 {
1129            let mut begin = end;
1130            while begin > 0 && chunk[begin - 1] != b'\n' {
1131                begin -= 1;
1132            }
1133            let line = chunk[begin..end].trim_ascii();
1134            end = begin.saturating_sub(1);
1135
1136            if line.is_empty() {
1137                continue;
1138            }
1139
1140            let wants_record = found.last_record_at.is_none();
1141            let wants_assistant = include_assistant
1142                && found.last_assistant_at.is_none()
1143                && contains_bytes(line, ASSISTANT_ROLE_NEEDLE);
1144            if !wants_record && !wants_assistant {
1145                continue;
1146            }
1147
1148            let value: Value = match serde_json::from_slice(line) {
1149                Ok(v) => v,
1150                Err(_) => continue,
1151            };
1152            if let Some(ts) = value.get("timestamp").and_then(|v| v.as_str()) {
1153                let ts = ts.to_string();
1154                if wants_record {
1155                    found.last_record_at = Some(ts.clone());
1156                }
1157                if wants_assistant {
1158                    found.last_assistant_at = Some(ts);
1159                }
1160                if found.last_record_at.is_some()
1161                    && (!include_assistant || found.last_assistant_at.is_some())
1162                {
1163                    return Ok(found);
1164                }
1165            }
1166        }
1167
1168        // Keep the partial first line for the next iteration.
1169        if let Some(first_nl) = chunk.iter().position(|b| *b == b'\n') {
1170            carry = chunk[..first_nl].to_vec();
1171        } else {
1172            carry = chunk;
1173        }
1174
1175        pos = start;
1176    }
1177
1178    Ok(found)
1179}
1180
1181fn path_matches_current_dir(session_cwd: &str, current_dir: &Path) -> bool {
1182    let session_path = PathBuf::from(session_cwd);
1183    if !session_path.is_absolute() {
1184        return false;
1185    }
1186
1187    let current = std::fs::canonicalize(current_dir).unwrap_or_else(|_| current_dir.to_path_buf());
1188    let cwd = std::fs::canonicalize(&session_path).unwrap_or(session_path);
1189
1190    current == cwd || current.starts_with(&cwd) || cwd.starts_with(&current)
1191}
1192
1193async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> std::io::Result<Vec<(T, PathBuf)>>
1194where
1195    T: Ord + Copy,
1196    F: Fn(&str) -> Option<T>,
1197{
1198    let mut dir = fs::read_dir(parent).await?;
1199    let mut vec: Vec<(T, PathBuf)> = Vec::new();
1200    while let Some(entry) = dir.next_entry().await? {
1201        if entry
1202            .file_type()
1203            .await
1204            .map(|ft| ft.is_dir())
1205            .unwrap_or(false)
1206            && let Some(s) = entry.file_name().to_str()
1207            && let Some(v) = parse(s)
1208        {
1209            vec.push((v, entry.path()));
1210        }
1211    }
1212    vec.sort_by_key(|(v, _)| Reverse(*v));
1213    Ok(vec)
1214}
1215
1216async fn collect_rollout_files_sorted(parent: &Path) -> std::io::Result<Vec<PathBuf>> {
1217    let mut dir = fs::read_dir(parent).await?;
1218    let mut records: Vec<(String, String, PathBuf)> = Vec::new();
1219
1220    while let Some(entry) = dir.next_entry().await? {
1221        if entry
1222            .file_type()
1223            .await
1224            .map(|ft| ft.is_file())
1225            .unwrap_or(false)
1226        {
1227            let name_os = entry.file_name();
1228            let Some(name) = name_os.to_str() else {
1229                continue;
1230            };
1231            if !name.starts_with("rollout-") || !name.ends_with(".jsonl") {
1232                continue;
1233            }
1234            if let Some((ts, uuid)) = parse_timestamp_and_uuid(name) {
1235                records.push((ts, uuid, entry.path()));
1236            }
1237        }
1238    }
1239
1240    records.sort_by(|a, b| {
1241        // Sort by timestamp desc, then UUID desc.
1242        match b.0.cmp(&a.0) {
1243            Ordering::Equal => b.1.cmp(&a.1),
1244            other => other,
1245        }
1246    });
1247
1248    Ok(records.into_iter().map(|(_, _, path)| path).collect())
1249}
1250
1251fn parse_timestamp_and_uuid(name: &str) -> Option<(String, String)> {
1252    // Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl
1253    let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
1254
1255    // Timestamp format is stable and has a fixed width: "YYYY-MM-DDThh-mm-ss" (19 chars).
1256    const TS_LEN: usize = 19;
1257    if core.len() <= TS_LEN + 1 {
1258        return None;
1259    }
1260    let (ts, rest) = core.split_at(TS_LEN);
1261    let uuid = rest.strip_prefix('-')?;
1262    if uuid.is_empty() {
1263        return None;
1264    }
1265    Some((ts.to_string(), uuid.to_string()))
1266}
1267
1268fn sort_by_updated_desc(vec: &mut [SessionSummary]) {
1269    vec.sort_by(|a, b| {
1270        let ta = a.updated_at.as_deref();
1271        let tb = b.updated_at.as_deref();
1272        match (ta, tb) {
1273            (Some(ta), Some(tb)) => tb.cmp(ta),
1274            (Some(_), None) => Ordering::Less,
1275            (None, Some(_)) => Ordering::Greater,
1276            (None, None) => Ordering::Equal,
1277        }
1278    });
1279}
1280
1281#[cfg(test)]
1282mod tests;