Skip to main content

codex_rollout/
session_index.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::fs::File;
4use std::io::ErrorKind;
5use std::io::Write;
6use std::path::Path;
7use std::path::PathBuf;
8use std::sync::LazyLock;
9use std::sync::Mutex;
10
11use crate::reverse_jsonl_scanner::ReverseJsonlScanner;
12use crate::reverse_jsonl_scanner::ScanOutcome;
13use codex_protocol::ThreadId;
14use codex_protocol::protocol::SessionMetaLine;
15use serde::Deserialize;
16use serde::Serialize;
17use tokio::io::AsyncBufReadExt;
18
19const SESSION_INDEX_FILE: &str = "session_index.jsonl";
20static SESSION_INDEX_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct SessionIndexEntry {
24    pub id: ThreadId,
25    pub thread_name: String,
26    pub updated_at: String,
27}
28
29/// Append a thread name update to the session index.
30/// Name updates are append-only; the most recent entry wins when resolving names or ids.
31pub async fn append_thread_name(
32    codex_home: &Path,
33    thread_id: ThreadId,
34    name: &str,
35) -> std::io::Result<()> {
36    use time::OffsetDateTime;
37    use time::format_description::well_known::Rfc3339;
38
39    let updated_at = OffsetDateTime::now_utc()
40        .format(&Rfc3339)
41        .unwrap_or_else(|_| "unknown".to_string());
42    let entry = SessionIndexEntry {
43        id: thread_id,
44        thread_name: name.to_string(),
45        updated_at,
46    };
47    append_session_index_entry(codex_home, &entry).await
48}
49
50/// Append a raw session index entry to `session_index.jsonl`.
51/// Consumers scan from the end to find the newest match.
52pub async fn append_session_index_entry(
53    codex_home: &Path,
54    entry: &SessionIndexEntry,
55) -> std::io::Result<()> {
56    let _guard = SESSION_INDEX_LOCK
57        .lock()
58        .map_err(|err| std::io::Error::other(err.to_string()))?;
59    let path = session_index_path(codex_home);
60    let mut file = std::fs::OpenOptions::new()
61        .create(true)
62        .append(true)
63        .open(&path)?;
64    let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
65    line.push('\n');
66    file.write_all(line.as_bytes())?;
67    file.flush()?;
68    Ok(())
69}
70
71/// Remove all recorded names for a thread from the session index.
72pub async fn remove_thread_name_entries(
73    codex_home: &Path,
74    thread_id: ThreadId,
75) -> std::io::Result<()> {
76    let _guard = SESSION_INDEX_LOCK
77        .lock()
78        .map_err(|err| std::io::Error::other(err.to_string()))?;
79    let path = session_index_path(codex_home);
80    let contents = match std::fs::read_to_string(&path) {
81        Ok(contents) => contents,
82        Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
83        Err(err) => return Err(err),
84    };
85    let mut removed = false;
86    let mut remaining = String::with_capacity(contents.len());
87    for line in contents.lines() {
88        let should_remove = serde_json::from_str::<SessionIndexEntry>(line.trim())
89            .is_ok_and(|entry| entry.id == thread_id);
90        if should_remove {
91            removed = true;
92        } else {
93            remaining.push_str(line);
94            remaining.push('\n');
95        }
96    }
97    if !removed {
98        return Ok(());
99    }
100    let temp_path = path.with_extension("jsonl.tmp");
101    std::fs::write(&temp_path, remaining)?;
102    std::fs::rename(temp_path, path)
103}
104
105/// Find the latest thread name for a thread id, if any.
106pub async fn find_thread_name_by_id(
107    codex_home: &Path,
108    thread_id: &ThreadId,
109) -> std::io::Result<Option<String>> {
110    let path = session_index_path(codex_home);
111    if !path.exists() {
112        return Ok(None);
113    }
114    let id = *thread_id;
115    let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_id(&path, &id))
116        .await
117        .map_err(std::io::Error::other)??;
118    Ok(entry.map(|entry| entry.thread_name))
119}
120
121/// Find the latest thread names for a batch of thread ids.
122pub async fn find_thread_names_by_ids(
123    codex_home: &Path,
124    thread_ids: &HashSet<ThreadId>,
125) -> std::io::Result<HashMap<ThreadId, String>> {
126    let path = session_index_path(codex_home);
127    if thread_ids.is_empty() || !path.exists() {
128        return Ok(HashMap::new());
129    }
130
131    let file = tokio::fs::File::open(&path).await?;
132    let reader = tokio::io::BufReader::new(file);
133    let mut lines = reader.lines();
134    let mut names = HashMap::with_capacity(thread_ids.len());
135
136    while let Some(line) = lines.next_line().await? {
137        let trimmed = line.trim();
138        if trimmed.is_empty() {
139            continue;
140        }
141        let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
142            continue;
143        };
144        let name = entry.thread_name.trim();
145        if !name.is_empty() && thread_ids.contains(&entry.id) {
146            names.insert(entry.id, name.to_string());
147        }
148    }
149
150    Ok(names)
151}
152
153/// Locate a recorded thread rollout and read its session metadata by thread name.
154/// Returns the newest indexed name that still has a readable rollout header.
155pub async fn find_thread_meta_by_name_str(
156    codex_home: &Path,
157    name: &str,
158    state_db_ctx: Option<&codex_state::StateRuntime>,
159) -> std::io::Result<Option<(PathBuf, SessionMetaLine)>> {
160    if name.trim().is_empty() {
161        return Ok(None);
162    }
163    let path = session_index_path(codex_home);
164    if !path.exists() {
165        return Ok(None);
166    }
167    let (tx, mut rx) = tokio::sync::mpsc::channel(1);
168    let name = name.to_string();
169    // Stream matching ids newest-first instead of stopping at the first name hit: the newest entry
170    // may point at a thread whose rollout was never materialized.
171    let scan =
172        tokio::task::spawn_blocking(move || stream_thread_ids_from_end_by_name(&path, &name, tx));
173
174    while let Some(thread_id) = rx.recv().await {
175        // Keep walking until a matching id resolves to a loadable rollout so an unsaved or partial
176        // rename cannot shadow an older persisted session with the same name.
177        if let Some(path) = super::list::find_thread_path_by_id_str(
178            codex_home,
179            &thread_id.to_string(),
180            state_db_ctx,
181        )
182        .await?
183            && let Ok(session_meta) = super::list::read_session_meta_line(&path).await
184        {
185            drop(rx);
186            scan.await.map_err(std::io::Error::other)??;
187            return Ok(Some((path, session_meta)));
188        }
189    }
190    scan.await.map_err(std::io::Error::other)??;
191
192    Ok(None)
193}
194
195fn session_index_path(codex_home: &Path) -> PathBuf {
196    codex_home.join(SESSION_INDEX_FILE)
197}
198
199fn scan_index_from_end_by_id(
200    path: &Path,
201    thread_id: &ThreadId,
202) -> std::io::Result<Option<SessionIndexEntry>> {
203    scan_index_from_end(path, |entry| entry.id == *thread_id)
204}
205
206fn stream_thread_ids_from_end_by_name(
207    path: &Path,
208    name: &str,
209    tx: tokio::sync::mpsc::Sender<ThreadId>,
210) -> std::io::Result<()> {
211    let mut seen = HashSet::new();
212    scan_index_from_end_for_each(path, |entry| {
213        // The first row seen for an id is its latest name. Ignore older rows for that id so a
214        // historical name cannot be treated as the current one after the thread is renamed.
215        if seen.insert(entry.id) && entry.thread_name == name && tx.blocking_send(entry.id).is_err()
216        {
217            return Ok(Some(entry.clone()));
218        }
219        Ok(None)
220    })?;
221    Ok(())
222}
223
224fn scan_index_from_end<F>(
225    path: &Path,
226    mut predicate: F,
227) -> std::io::Result<Option<SessionIndexEntry>>
228where
229    F: FnMut(&SessionIndexEntry) -> bool,
230{
231    scan_index_from_end_for_each(path, |entry| {
232        if predicate(entry) {
233            return Ok(Some(entry.clone()));
234        }
235        Ok(None)
236    })
237}
238
239fn scan_index_from_end_for_each<F>(
240    path: &Path,
241    mut visit_entry: F,
242) -> std::io::Result<Option<SessionIndexEntry>>
243where
244    F: FnMut(&SessionIndexEntry) -> std::io::Result<Option<SessionIndexEntry>>,
245{
246    let mut scanner = ReverseJsonlScanner::new(File::open(path)?)?;
247    while let Some(outcome) = scanner.scan_next::<SessionIndexEntry>()? {
248        let ScanOutcome::Parsed(entry) = outcome else {
249            continue;
250        };
251        if let Some(entry) = visit_entry(&entry)? {
252            return Ok(Some(entry));
253        }
254    }
255    Ok(None)
256}
257
258#[cfg(test)]
259#[path = "session_index_tests.rs"]
260mod tests;