Codex Handbook
rollout/src/session_index.rs 308 lines
use std::collections::HashMap;use std::collections::HashSet;use std::fs::File;use std::io::ErrorKind;use std::io::Read;use std::io::Seek;use std::io::SeekFrom;use std::io::Write;use std::path::Path;use std::path::PathBuf;use std::sync::LazyLock;use std::sync::Mutex;use codex_protocol::ThreadId;use codex_protocol::protocol::SessionMetaLine;use serde::Deserialize;use serde::Serialize;use tokio::io::AsyncBufReadExt;const SESSION_INDEX_FILE: &str = "session_index.jsonl";const READ_CHUNK_SIZE: usize = 8192;static SESSION_INDEX_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]pub struct SessionIndexEntry {    pub id: ThreadId,    pub thread_name: String,    pub updated_at: String,}/// Append a thread name update to the session index./// Name updates are append-only; the most recent entry wins when resolving names or ids.pub async fn append_thread_name(    codex_home: &Path,    thread_id: ThreadId,    name: &str,) -> std::io::Result<()> {    use time::OffsetDateTime;    use time::format_description::well_known::Rfc3339;    let updated_at = OffsetDateTime::now_utc()        .format(&Rfc3339)        .unwrap_or_else(|_| "unknown".to_string());    let entry = SessionIndexEntry {        id: thread_id,        thread_name: name.to_string(),        updated_at,    };    append_session_index_entry(codex_home, &entry).await}/// Append a raw session index entry to `session_index.jsonl`./// Consumers scan from the end to find the newest match.pub async fn append_session_index_entry(    codex_home: &Path,    entry: &SessionIndexEntry,) -> std::io::Result<()> {    let _guard = SESSION_INDEX_LOCK        .lock()        .map_err(|err| std::io::Error::other(err.to_string()))?;    let path = session_index_path(codex_home);    let mut file = std::fs::OpenOptions::new()        .create(true)        .append(true)        .open(&path)?;    let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;    line.push('\n');    file.write_all(line.as_bytes())?;    file.flush()?;    Ok(())}/// Remove all recorded names for a thread from the session index.pub async fn remove_thread_name_entries(    codex_home: &Path,    thread_id: ThreadId,) -> std::io::Result<()> {    let _guard = SESSION_INDEX_LOCK        .lock()        .map_err(|err| std::io::Error::other(err.to_string()))?;    let path = session_index_path(codex_home);    let contents = match std::fs::read_to_string(&path) {        Ok(contents) => contents,        Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),        Err(err) => return Err(err),    };    let mut removed = false;    let mut remaining = String::with_capacity(contents.len());    for line in contents.lines() {        let should_remove = serde_json::from_str::<SessionIndexEntry>(line.trim())            .is_ok_and(|entry| entry.id == thread_id);        if should_remove {            removed = true;        } else {            remaining.push_str(line);            remaining.push('\n');        }    }    if !removed {        return Ok(());    }    let temp_path = path.with_extension("jsonl.tmp");    std::fs::write(&temp_path, remaining)?;    std::fs::rename(temp_path, path)}/// Find the latest thread name for a thread id, if any.pub async fn find_thread_name_by_id(    codex_home: &Path,    thread_id: &ThreadId,) -> std::io::Result<Option<String>> {    let path = session_index_path(codex_home);    if !path.exists() {        return Ok(None);    }    let id = *thread_id;    let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_id(&path, &id))        .await        .map_err(std::io::Error::other)??;    Ok(entry.map(|entry| entry.thread_name))}/// Find the latest thread names for a batch of thread ids.pub async fn find_thread_names_by_ids(    codex_home: &Path,    thread_ids: &HashSet<ThreadId>,) -> std::io::Result<HashMap<ThreadId, String>> {    let path = session_index_path(codex_home);    if thread_ids.is_empty() || !path.exists() {        return Ok(HashMap::new());    }    let file = tokio::fs::File::open(&path).await?;    let reader = tokio::io::BufReader::new(file);    let mut lines = reader.lines();    let mut names = HashMap::with_capacity(thread_ids.len());    while let Some(line) = lines.next_line().await? {        let trimmed = line.trim();        if trimmed.is_empty() {            continue;        }        let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {            continue;        };        let name = entry.thread_name.trim();        if !name.is_empty() && thread_ids.contains(&entry.id) {            names.insert(entry.id, name.to_string());        }    }    Ok(names)}/// Locate a recorded thread rollout and read its session metadata by thread name./// Returns the newest indexed name that still has a readable rollout header.pub async fn find_thread_meta_by_name_str(    codex_home: &Path,    name: &str,    state_db_ctx: Option<&codex_state::StateRuntime>,) -> std::io::Result<Option<(PathBuf, SessionMetaLine)>> {    if name.trim().is_empty() {        return Ok(None);    }    let path = session_index_path(codex_home);    if !path.exists() {        return Ok(None);    }    let (tx, mut rx) = tokio::sync::mpsc::channel(1);    let name = name.to_string();    // Stream matching ids newest-first instead of stopping at the first name hit: the newest entry    // may point at a thread whose rollout was never materialized.    let scan =        tokio::task::spawn_blocking(move || stream_thread_ids_from_end_by_name(&path, &name, tx));    while let Some(thread_id) = rx.recv().await {        // Keep walking until a matching id resolves to a loadable rollout so an unsaved or partial        // rename cannot shadow an older persisted session with the same name.        if let Some(path) = super::list::find_thread_path_by_id_str(            codex_home,            &thread_id.to_string(),            state_db_ctx,        )        .await?            && let Ok(session_meta) = super::list::read_session_meta_line(&path).await        {            drop(rx);            scan.await.map_err(std::io::Error::other)??;            return Ok(Some((path, session_meta)));        }    }    scan.await.map_err(std::io::Error::other)??;    Ok(None)}fn session_index_path(codex_home: &Path) -> PathBuf {    codex_home.join(SESSION_INDEX_FILE)}fn scan_index_from_end_by_id(    path: &Path,    thread_id: &ThreadId,) -> std::io::Result<Option<SessionIndexEntry>> {    scan_index_from_end(path, |entry| entry.id == *thread_id)}fn stream_thread_ids_from_end_by_name(    path: &Path,    name: &str,    tx: tokio::sync::mpsc::Sender<ThreadId>,) -> std::io::Result<()> {    let mut seen = HashSet::new();    scan_index_from_end_for_each(path, |entry| {        // The first row seen for an id is its latest name. Ignore older rows for that id so a        // historical name cannot be treated as the current one after the thread is renamed.        if seen.insert(entry.id) && entry.thread_name == name && tx.blocking_send(entry.id).is_err()        {            return Ok(Some(entry.clone()));        }        Ok(None)    })?;    Ok(())}fn scan_index_from_end<F>(    path: &Path,    mut predicate: F,) -> std::io::Result<Option<SessionIndexEntry>>where    F: FnMut(&SessionIndexEntry) -> bool,{    scan_index_from_end_for_each(path, |entry| {        if predicate(entry) {            return Ok(Some(entry.clone()));        }        Ok(None)    })}fn scan_index_from_end_for_each<F>(    path: &Path,    mut visit_entry: F,) -> std::io::Result<Option<SessionIndexEntry>>where    F: FnMut(&SessionIndexEntry) -> std::io::Result<Option<SessionIndexEntry>>,{    let mut file = File::open(path)?;    let mut remaining = file.metadata()?.len();    let mut line_rev: Vec<u8> = Vec::new();    let mut buf = vec![0u8; READ_CHUNK_SIZE];    while remaining > 0 {        let read_size = usize::try_from(remaining.min(READ_CHUNK_SIZE as u64))            .map_err(std::io::Error::other)?;        remaining -= read_size as u64;        file.seek(SeekFrom::Start(remaining))?;        file.read_exact(&mut buf[..read_size])?;        for &byte in buf[..read_size].iter().rev() {            if byte == b'\n' {                if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut visit_entry)? {                    return Ok(Some(entry));                }                continue;            }            line_rev.push(byte);        }    }    if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut visit_entry)? {        return Ok(Some(entry));    }    Ok(None)}fn parse_line_from_rev<F>(    line_rev: &mut Vec<u8>,    visit_entry: &mut F,) -> std::io::Result<Option<SessionIndexEntry>>where    F: FnMut(&SessionIndexEntry) -> std::io::Result<Option<SessionIndexEntry>>,{    if line_rev.is_empty() {        return Ok(None);    }    line_rev.reverse();    let line = std::mem::take(line_rev);    let Ok(mut line) = String::from_utf8(line) else {        return Ok(None);    };    if line.ends_with('\r') {        line.pop();    }    let trimmed = line.trim();    if trimmed.is_empty() {        return Ok(None);    }    let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {        return Ok(None);    };    visit_entry(&entry)}#[cfg(test)]#[path = "session_index_tests.rs"]mod tests;