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