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#[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 pub last_response_at: Option<String>,
41 pub user_turns: usize,
43 pub assistant_turns: usize,
45 pub rounds: usize,
47 pub first_user_message: Option<String>,
48 pub source: SessionSummarySource,
49 pub sort_hint_ms: Option<u64>,
50}
51
52#[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#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct SessionTranscriptMessage {
63 pub timestamp: Option<String>,
64 pub role: String,
65 pub text: String,
66}
67
68#[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
122pub 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
169pub 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
216pub 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
273pub 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
279pub 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
288pub 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
534pub 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
589pub 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
666pub 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 mtime_ms: u64,
728 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 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 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 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(¤t)
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 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 let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
1254
1255 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;