codex_rollout/
session_index.rs1use 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
29pub 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
50pub 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
71pub 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
105pub 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
121pub 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
153pub 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 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 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 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;