Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Ibrahim
6bc48e2d85 Allow finding rollouts by id without cwd filter 2025-09-14 23:57:08 -04:00
11 changed files with 214 additions and 49 deletions

View File

@@ -115,7 +115,8 @@ impl ConversationManager {
rollout_path: PathBuf, rollout_path: PathBuf,
auth_manager: Arc<AuthManager>, auth_manager: Arc<AuthManager>,
) -> CodexResult<NewConversation> { ) -> CodexResult<NewConversation> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?; let initial_history =
RolloutRecorder::get_rollout_history(&rollout_path, &config.cwd).await?;
let CodexSpawnOk { let CodexSpawnOk {
codex, codex,
conversation_id, conversation_id,
@@ -145,7 +146,7 @@ impl ConversationManager {
path: PathBuf, path: PathBuf,
) -> CodexResult<NewConversation> { ) -> CodexResult<NewConversation> {
// Compute the prefix up to the cut point. // Compute the prefix up to the cut point.
let history = RolloutRecorder::get_rollout_history(&path).await?; let history = RolloutRecorder::get_rollout_history(&path, &config.cwd).await?;
let history = truncate_after_nth_user_message(history, nth_user_message); let history = truncate_after_nth_user_message(history, nth_user_message);
// Spawn a new conversation with the computed initial history. // Spawn a new conversation with the computed initial history.

View File

@@ -17,6 +17,7 @@ use super::SESSIONS_SUBDIR;
use crate::protocol::EventMsg; use crate::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMetaLine;
/// Returned page of conversation summaries. /// Returned page of conversation summaries.
#[derive(Debug, Default, PartialEq)] #[derive(Debug, Default, PartialEq)]
@@ -87,6 +88,7 @@ impl<'de> serde::Deserialize<'de> for Cursor {
/// concurrent new sessions being appended. Ordering is stable by timestamp desc, then UUID desc. /// concurrent new sessions being appended. Ordering is stable by timestamp desc, then UUID desc.
pub(crate) async fn get_conversations( pub(crate) async fn get_conversations(
codex_home: &Path, codex_home: &Path,
cwd: &Path,
page_size: usize, page_size: usize,
cursor: Option<&Cursor>, cursor: Option<&Cursor>,
) -> io::Result<ConversationsPage> { ) -> io::Result<ConversationsPage> {
@@ -104,14 +106,26 @@ pub(crate) async fn get_conversations(
let anchor = cursor.cloned(); let anchor = cursor.cloned();
let result = traverse_directories_for_paths(root.clone(), page_size, anchor).await?; let result = traverse_directories_for_paths(root.clone(), cwd, page_size, anchor).await?;
Ok(result) Ok(result)
} }
/// Load the full contents of a single conversation session file at `path`. /// Load the full contents of a single conversation session file at `path`.
/// Returns the entire file contents as a String. /// Returns the entire file contents as a String.
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) async fn get_conversation(path: &Path) -> io::Result<String> { pub(crate) async fn get_conversation(path: &Path, cwd: &Path) -> io::Result<String> {
let (_head, session_meta, _, _) = read_head_and_flags(path, 1).await?;
let Some(meta) = session_meta else {
return Err(io::Error::other("missing session meta in rollout file"));
};
if meta.meta.cwd != cwd {
return Err(io::Error::other(format!(
"session cwd `{}` does not match requested cwd `{}`",
meta.meta.cwd.display(),
cwd.display()
)));
}
tokio::fs::read_to_string(path).await tokio::fs::read_to_string(path).await
} }
@@ -121,6 +135,7 @@ pub(crate) async fn get_conversation(path: &Path) -> io::Result<String> {
/// Returned newest (latest) first. /// Returned newest (latest) first.
async fn traverse_directories_for_paths( async fn traverse_directories_for_paths(
root: PathBuf, root: PathBuf,
cwd: &Path,
page_size: usize, page_size: usize,
anchor: Option<Cursor>, anchor: Option<Cursor>,
) -> io::Result<ConversationsPage> { ) -> io::Result<ConversationsPage> {
@@ -176,12 +191,20 @@ async fn traverse_directories_for_paths(
} }
// Read head and simultaneously detect message events within the same // Read head and simultaneously detect message events within the same
// first N JSONL records to avoid a second file read. // first N JSONL records to avoid a second file read.
let (head, saw_session_meta, saw_user_event) = let (head, session_meta, saw_session_meta, saw_user_event) =
read_head_and_flags(&path, HEAD_RECORD_LIMIT) match read_head_and_flags(&path, HEAD_RECORD_LIMIT).await {
.await Ok(res) => res,
.unwrap_or((Vec::new(), false, false)); Err(_) => continue,
// Apply filters: must have session meta and at least one user message event };
if saw_session_meta && saw_user_event { // Apply filters: must have session meta, matching cwd, and at least one
// user message event
if saw_session_meta
&& saw_user_event
&& session_meta
.as_ref()
.map(|meta| meta.meta.cwd.as_path() == cwd)
.unwrap_or(false)
{
items.push(ConversationItem { path, head }); items.push(ConversationItem { path, head });
} }
} }
@@ -289,7 +312,7 @@ fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uui
async fn read_head_and_flags( async fn read_head_and_flags(
path: &Path, path: &Path,
max_records: usize, max_records: usize,
) -> io::Result<(Vec<serde_json::Value>, bool, bool)> { ) -> io::Result<(Vec<serde_json::Value>, Option<SessionMetaLine>, bool, bool)> {
use tokio::io::AsyncBufReadExt; use tokio::io::AsyncBufReadExt;
let file = tokio::fs::File::open(path).await?; let file = tokio::fs::File::open(path).await?;
@@ -298,6 +321,7 @@ async fn read_head_and_flags(
let mut head: Vec<serde_json::Value> = Vec::new(); let mut head: Vec<serde_json::Value> = Vec::new();
let mut saw_session_meta = false; let mut saw_session_meta = false;
let mut saw_user_event = false; let mut saw_user_event = false;
let mut session_meta: Option<SessionMetaLine> = None;
while head.len() < max_records { while head.len() < max_records {
let line_opt = lines.next_line().await?; let line_opt = lines.next_line().await?;
@@ -312,10 +336,13 @@ async fn read_head_and_flags(
match rollout_line.item { match rollout_line.item {
RolloutItem::SessionMeta(session_meta_line) => { RolloutItem::SessionMeta(session_meta_line) => {
if let Ok(val) = serde_json::to_value(session_meta_line) { if let Ok(val) = serde_json::to_value(&session_meta_line) {
head.push(val); head.push(val);
saw_session_meta = true; saw_session_meta = true;
} }
if session_meta.is_none() {
session_meta = Some(session_meta_line);
}
} }
RolloutItem::ResponseItem(item) => { RolloutItem::ResponseItem(item) => {
if let Ok(val) = serde_json::to_value(item) { if let Ok(val) = serde_json::to_value(item) {
@@ -336,7 +363,7 @@ async fn read_head_and_flags(
} }
} }
Ok((head, saw_session_meta, saw_user_event)) Ok((head, session_meta, saw_session_meta, saw_user_event))
} }
/// Locate a recorded conversation rollout file by its UUID string using the existing /// Locate a recorded conversation rollout file by its UUID string using the existing

View File

@@ -103,10 +103,11 @@ impl RolloutRecorder {
/// List conversations (rollout files) under the provided Codex home directory. /// List conversations (rollout files) under the provided Codex home directory.
pub async fn list_conversations( pub async fn list_conversations(
codex_home: &Path, codex_home: &Path,
cwd: &Path,
page_size: usize, page_size: usize,
cursor: Option<&Cursor>, cursor: Option<&Cursor>,
) -> std::io::Result<ConversationsPage> { ) -> std::io::Result<ConversationsPage> {
get_conversations(codex_home, page_size, cursor).await get_conversations(codex_home, cwd, page_size, cursor).await
} }
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory /// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
@@ -202,7 +203,10 @@ impl RolloutRecorder {
.map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}"))) .map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}")))
} }
pub(crate) async fn get_rollout_history(path: &Path) -> std::io::Result<InitialHistory> { pub(crate) async fn get_rollout_history(
path: &Path,
cwd: &Path,
) -> std::io::Result<InitialHistory> {
info!("Resuming rollout from {path:?}"); info!("Resuming rollout from {path:?}");
let text = tokio::fs::read_to_string(path).await?; let text = tokio::fs::read_to_string(path).await?;
if text.trim().is_empty() { if text.trim().is_empty() {
@@ -211,6 +215,7 @@ impl RolloutRecorder {
let mut items: Vec<RolloutItem> = Vec::new(); let mut items: Vec<RolloutItem> = Vec::new();
let mut conversation_id: Option<ConversationId> = None; let mut conversation_id: Option<ConversationId> = None;
let mut session_cwd: Option<PathBuf> = None;
for line in text.lines() { for line in text.lines() {
if line.trim().is_empty() { if line.trim().is_empty() {
continue; continue;
@@ -232,6 +237,9 @@ impl RolloutRecorder {
if conversation_id.is_none() { if conversation_id.is_none() {
conversation_id = Some(session_meta_line.meta.id); conversation_id = Some(session_meta_line.meta.id);
} }
if session_cwd.is_none() {
session_cwd = Some(session_meta_line.meta.cwd.clone());
}
items.push(RolloutItem::SessionMeta(session_meta_line)); items.push(RolloutItem::SessionMeta(session_meta_line));
} }
RolloutItem::ResponseItem(item) => { RolloutItem::ResponseItem(item) => {
@@ -261,6 +269,22 @@ impl RolloutRecorder {
let conversation_id = conversation_id let conversation_id = conversation_id
.ok_or_else(|| IoError::other("failed to parse conversation ID from rollout file"))?; .ok_or_else(|| IoError::other("failed to parse conversation ID from rollout file"))?;
match session_cwd {
Some(meta_cwd) if meta_cwd == cwd => {}
Some(meta_cwd) => {
return Err(IoError::other(format!(
"session cwd `{}` does not match requested cwd `{}`",
meta_cwd.display(),
cwd.display()
)));
}
None => {
return Err(IoError::other(
"failed to parse session cwd from rollout file",
));
}
}
if items.is_empty() { if items.is_empty() {
return Ok(InitialHistory::New); return Ok(InitialHistory::New);
} }

View File

@@ -17,12 +17,14 @@ use crate::rollout::list::ConversationsPage;
use crate::rollout::list::Cursor; use crate::rollout::list::Cursor;
use crate::rollout::list::get_conversation; use crate::rollout::list::get_conversation;
use crate::rollout::list::get_conversations; use crate::rollout::list::get_conversations;
use crate::rollout::recorder::RolloutRecorder;
fn write_session_file( fn write_session_file(
root: &Path, root: &Path,
ts_str: &str, ts_str: &str,
uuid: Uuid, uuid: Uuid,
num_records: usize, num_records: usize,
cwd: &Path,
) -> std::io::Result<(OffsetDateTime, Uuid)> { ) -> std::io::Result<(OffsetDateTime, Uuid)> {
let format: &[FormatItem] = let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
@@ -40,6 +42,7 @@ fn write_session_file(
let file_path = dir.join(filename); let file_path = dir.join(filename);
let mut file = File::create(file_path)?; let mut file = File::create(file_path)?;
let cwd_str = cwd.to_string_lossy();
let meta = serde_json::json!({ let meta = serde_json::json!({
"timestamp": ts_str, "timestamp": ts_str,
"type": "session_meta", "type": "session_meta",
@@ -47,7 +50,7 @@ fn write_session_file(
"id": uuid, "id": uuid,
"timestamp": ts_str, "timestamp": ts_str,
"instructions": null, "instructions": null,
"cwd": ".", "cwd": cwd_str,
"originator": "test_originator", "originator": "test_originator",
"cli_version": "test_version" "cli_version": "test_version"
} }
@@ -86,12 +89,13 @@ async fn test_list_conversations_latest_first() {
let u2 = Uuid::from_u128(2); let u2 = Uuid::from_u128(2);
let u3 = Uuid::from_u128(3); let u3 = Uuid::from_u128(3);
let cwd = Path::new(".");
// Create three sessions across three days // Create three sessions across three days
write_session_file(home, "2025-01-01T12-00-00", u1, 3).unwrap(); write_session_file(home, "2025-01-01T12-00-00", u1, 3, cwd).unwrap();
write_session_file(home, "2025-01-02T12-00-00", u2, 3).unwrap(); write_session_file(home, "2025-01-02T12-00-00", u2, 3, cwd).unwrap();
write_session_file(home, "2025-01-03T12-00-00", u3, 3).unwrap(); write_session_file(home, "2025-01-03T12-00-00", u3, 3, cwd).unwrap();
let page = get_conversations(home, 10, None).await.unwrap(); let page = get_conversations(home, cwd, 10, None).await.unwrap();
// Build expected objects // Build expected objects
let p1 = home let p1 = home
@@ -164,6 +168,46 @@ async fn test_list_conversations_latest_first() {
assert_eq!(page, expected); assert_eq!(page, expected);
} }
#[tokio::test]
async fn test_list_conversations_filters_by_cwd() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let match_cwd = Path::new("/match");
let other_cwd = Path::new("/other");
let id_match = Uuid::from_u128(11);
let id_other = Uuid::from_u128(22);
write_session_file(home, "2025-02-01T00-00-00", id_match, 1, match_cwd).unwrap();
write_session_file(home, "2025-02-02T00-00-00", id_other, 1, other_cwd).unwrap();
let page_match = get_conversations(home, match_cwd, 10, None).await.unwrap();
assert_eq!(page_match.items.len(), 1);
let head_id = page_match.items[0]
.head
.first()
.and_then(|meta| meta.get("id"))
.and_then(|id| id.as_str())
.expect("id str");
assert_eq!(head_id, id_match.to_string());
let page_other = get_conversations(home, other_cwd, 10, None).await.unwrap();
assert_eq!(page_other.items.len(), 1);
let other_head_id = page_other.items[0]
.head
.first()
.and_then(|meta| meta.get("id"))
.and_then(|id| id.as_str())
.expect("id str");
assert_eq!(other_head_id, id_other.to_string());
let none_page = get_conversations(home, Path::new("/missing"), 10, None)
.await
.unwrap();
assert!(none_page.items.is_empty());
}
#[tokio::test] #[tokio::test]
async fn test_pagination_cursor() { async fn test_pagination_cursor() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@@ -177,13 +221,15 @@ async fn test_pagination_cursor() {
let u5 = Uuid::from_u128(55); let u5 = Uuid::from_u128(55);
// Oldest to newest // Oldest to newest
write_session_file(home, "2025-03-01T09-00-00", u1, 1).unwrap(); write_session_file(home, "2025-03-01T09-00-00", u1, 1, Path::new(".")).unwrap();
write_session_file(home, "2025-03-02T09-00-00", u2, 1).unwrap(); write_session_file(home, "2025-03-02T09-00-00", u2, 1, Path::new(".")).unwrap();
write_session_file(home, "2025-03-03T09-00-00", u3, 1).unwrap(); write_session_file(home, "2025-03-03T09-00-00", u3, 1, Path::new(".")).unwrap();
write_session_file(home, "2025-03-04T09-00-00", u4, 1).unwrap(); write_session_file(home, "2025-03-04T09-00-00", u4, 1, Path::new(".")).unwrap();
write_session_file(home, "2025-03-05T09-00-00", u5, 1).unwrap(); write_session_file(home, "2025-03-05T09-00-00", u5, 1, Path::new(".")).unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap(); let page1 = get_conversations(home, Path::new("."), 2, None)
.await
.unwrap();
let p5 = home let p5 = home
.join("sessions") .join("sessions")
.join("2025") .join("2025")
@@ -231,7 +277,7 @@ async fn test_pagination_cursor() {
}; };
assert_eq!(page1, expected_page1); assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref()) let page2 = get_conversations(home, Path::new("."), 2, page1.next_cursor.as_ref())
.await .await
.unwrap(); .unwrap();
let p3 = home let p3 = home
@@ -281,7 +327,7 @@ async fn test_pagination_cursor() {
}; };
assert_eq!(page2, expected_page2); assert_eq!(page2, expected_page2);
let page3 = get_conversations(home, 2, page2.next_cursor.as_ref()) let page3 = get_conversations(home, Path::new("."), 2, page2.next_cursor.as_ref())
.await .await
.unwrap(); .unwrap();
let p1 = home let p1 = home
@@ -319,12 +365,14 @@ async fn test_get_conversation_contents() {
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
let ts = "2025-04-01T10-30-00"; let ts = "2025-04-01T10-30-00";
write_session_file(home, ts, uuid, 2).unwrap(); write_session_file(home, ts, uuid, 2, Path::new(".")).unwrap();
let page = get_conversations(home, 1, None).await.unwrap(); let page = get_conversations(home, Path::new("."), 1, None)
.await
.unwrap();
let path = &page.items[0].path; let path = &page.items[0].path;
let content = get_conversation(path).await.unwrap(); let content = get_conversation(path, Path::new(".")).await.unwrap();
// Page equality (single item) // Page equality (single item)
let expected_path = home let expected_path = home
@@ -376,11 +424,13 @@ async fn test_stable_ordering_same_second_pagination() {
let u2 = Uuid::from_u128(2); let u2 = Uuid::from_u128(2);
let u3 = Uuid::from_u128(3); let u3 = Uuid::from_u128(3);
write_session_file(home, ts, u1, 0).unwrap(); write_session_file(home, ts, u1, 0, Path::new(".")).unwrap();
write_session_file(home, ts, u2, 0).unwrap(); write_session_file(home, ts, u2, 0, Path::new(".")).unwrap();
write_session_file(home, ts, u3, 0).unwrap(); write_session_file(home, ts, u3, 0, Path::new(".")).unwrap();
let page1 = get_conversations(home, 2, None).await.unwrap(); let page1 = get_conversations(home, Path::new("."), 2, None)
.await
.unwrap();
let p3 = home let p3 = home
.join("sessions") .join("sessions")
@@ -422,7 +472,7 @@ async fn test_stable_ordering_same_second_pagination() {
}; };
assert_eq!(page1, expected_page1); assert_eq!(page1, expected_page1);
let page2 = get_conversations(home, 2, page1.next_cursor.as_ref()) let page2 = get_conversations(home, Path::new("."), 2, page1.next_cursor.as_ref())
.await .await
.unwrap(); .unwrap();
let p1 = home let p1 = home
@@ -443,3 +493,31 @@ async fn test_stable_ordering_same_second_pagination() {
}; };
assert_eq!(page2, expected_page2); assert_eq!(page2, expected_page2);
} }
#[tokio::test]
async fn test_get_rollout_history_enforces_cwd() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let ts = "2025-08-01T00-00-00";
let uuid = Uuid::new_v4();
let match_cwd = Path::new("/history-match");
write_session_file(home, ts, uuid, 1, match_cwd).unwrap();
let path = home
.join("sessions")
.join("2025")
.join("08")
.join("01")
.join(format!("rollout-{ts}-{uuid}.jsonl"));
RolloutRecorder::get_rollout_history(&path, match_cwd)
.await
.expect("matching cwd should succeed");
let err = RolloutRecorder::get_rollout_history(&path, Path::new("/history-other"))
.await
.expect_err("mismatched cwd should error");
assert!(err.to_string().contains("does not match"));
}

View File

@@ -2,6 +2,7 @@ use assert_cmd::Command as AssertCommand;
use codex_core::RolloutRecorder; use codex_core::RolloutRecorder;
use codex_core::protocol::GitInfo; use codex_core::protocol::GitInfo;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use std::path::Path;
use std::time::Duration; use std::time::Duration;
use std::time::Instant; use std::time::Instant;
use tempfile::TempDir; use tempfile::TempDir;
@@ -81,7 +82,8 @@ async fn chat_mode_stream_cli() {
server.verify().await; server.verify().await;
// Verify a new session rollout was created and is discoverable via list_conversations // Verify a new session rollout was created and is discoverable via list_conversations
let page = RolloutRecorder::list_conversations(home.path(), 10, None) let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
let page = RolloutRecorder::list_conversations(home.path(), cwd, 10, None)
.await .await
.expect("list conversations"); .expect("list conversations");
assert!( assert!(

View File

@@ -8,12 +8,13 @@ use uuid::Uuid;
/// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the /// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the
/// provided conversation id in the SessionMeta line. Returns the absolute path. /// provided conversation id in the SessionMeta line. Returns the absolute path.
fn write_minimal_rollout_with_id(codex_home: &TempDir, id: Uuid) -> PathBuf { fn write_minimal_rollout_with_id(codex_home: &TempDir, id: Uuid, cwd: &std::path::Path) -> PathBuf {
let sessions = codex_home.path().join("sessions/2024/01/01"); let sessions = codex_home.path().join("sessions/2024/01/01");
std::fs::create_dir_all(&sessions).unwrap(); std::fs::create_dir_all(&sessions).unwrap();
let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl")); let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl"));
let mut f = std::fs::File::create(&file).unwrap(); let mut f = std::fs::File::create(&file).unwrap();
let cwd_str = cwd.to_string_lossy();
// Minimal first line: session_meta with the id so content search can find it // Minimal first line: session_meta with the id so content search can find it
writeln!( writeln!(
f, f,
@@ -25,7 +26,7 @@ fn write_minimal_rollout_with_id(codex_home: &TempDir, id: Uuid) -> PathBuf {
"id": id, "id": id,
"timestamp": "2024-01-01T00:00:00Z", "timestamp": "2024-01-01T00:00:00Z",
"instructions": null, "instructions": null,
"cwd": ".", "cwd": cwd_str,
"originator": "test", "originator": "test",
"cli_version": "test" "cli_version": "test"
} }
@@ -40,7 +41,8 @@ fn write_minimal_rollout_with_id(codex_home: &TempDir, id: Uuid) -> PathBuf {
async fn find_locates_rollout_file_by_id() { async fn find_locates_rollout_file_by_id() {
let home = TempDir::new().unwrap(); let home = TempDir::new().unwrap();
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let expected = write_minimal_rollout_with_id(&home, id); let cwd = std::path::Path::new(".");
let expected = write_minimal_rollout_with_id(&home, id, cwd);
let found = find_conversation_path_by_id_str(home.path(), &id.to_string()) let found = find_conversation_path_by_id_str(home.path(), &id.to_string())
.await .await

View File

@@ -313,7 +313,14 @@ async fn resolve_resume_path(
args: &crate::cli::ResumeArgs, args: &crate::cli::ResumeArgs,
) -> anyhow::Result<Option<PathBuf>> { ) -> anyhow::Result<Option<PathBuf>> {
if args.last { if args.last {
match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None).await { match codex_core::RolloutRecorder::list_conversations(
&config.codex_home,
&config.cwd,
1,
None,
)
.await
{
Ok(page) => Ok(page.items.first().map(|it| it.path.clone())), Ok(page) => Ok(page.items.first().map(|it| it.path.clone())),
Err(e) => { Err(e) => {
error!("Error listing conversations: {e}"); error!("Error listing conversations: {e}");

View File

@@ -675,6 +675,7 @@ impl CodexMessageProcessor {
let page = match RolloutRecorder::list_conversations( let page = match RolloutRecorder::list_conversations(
&self.config.codex_home, &self.config.codex_home,
&self.config.cwd,
page_size, page_size,
cursor_ref, cursor_ref,
) )

View File

@@ -23,23 +23,27 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
async fn test_list_and_resume_conversations() { async fn test_list_and_resume_conversations() {
// Prepare a temporary CODEX_HOME with a few fake rollout files. // Prepare a temporary CODEX_HOME with a few fake rollout files.
let codex_home = TempDir::new().expect("create temp dir"); let codex_home = TempDir::new().expect("create temp dir");
let cwd = std::env::current_dir().expect("current dir");
create_fake_rollout( create_fake_rollout(
codex_home.path(), codex_home.path(),
"2025-01-02T12-00-00", "2025-01-02T12-00-00",
"2025-01-02T12:00:00Z", "2025-01-02T12:00:00Z",
"Hello A", "Hello A",
&cwd,
); );
create_fake_rollout( create_fake_rollout(
codex_home.path(), codex_home.path(),
"2025-01-01T13-00-00", "2025-01-01T13-00-00",
"2025-01-01T13:00:00Z", "2025-01-01T13:00:00Z",
"Hello B", "Hello B",
&cwd,
); );
create_fake_rollout( create_fake_rollout(
codex_home.path(), codex_home.path(),
"2025-01-01T12-00-00", "2025-01-01T12-00-00",
"2025-01-01T12:00:00Z", "2025-01-01T12:00:00Z",
"Hello C", "Hello C",
&cwd,
); );
let mut mcp = McpProcess::new(codex_home.path()) let mut mcp = McpProcess::new(codex_home.path())
@@ -145,7 +149,13 @@ async fn test_list_and_resume_conversations() {
let _: uuid::Uuid = conversation_id.into(); let _: uuid::Uuid = conversation_id.into();
} }
fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) { fn create_fake_rollout(
codex_home: &Path,
filename_ts: &str,
meta_rfc3339: &str,
preview: &str,
cwd: &Path,
) {
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
// sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss) // sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss)
let year = &filename_ts[0..4]; let year = &filename_ts[0..4];
@@ -156,6 +166,7 @@ fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str,
let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl"));
let mut lines = Vec::new(); let mut lines = Vec::new();
let cwd_str = cwd.to_string_lossy();
// Meta line with timestamp (flattened meta in payload for new schema) // Meta line with timestamp (flattened meta in payload for new schema)
lines.push( lines.push(
json!({ json!({
@@ -164,7 +175,7 @@ fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str,
"payload": { "payload": {
"id": uuid, "id": uuid,
"timestamp": meta_rfc3339, "timestamp": meta_rfc3339,
"cwd": "/", "cwd": cwd_str,
"originator": "codex", "originator": "codex",
"cli_version": "0.0.0", "cli_version": "0.0.0",
"instructions": null "instructions": null

View File

@@ -354,7 +354,7 @@ async fn run_ratatui_app(
} }
} }
} else if cli.resume_last { } else if cli.resume_last {
match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await { match RolloutRecorder::list_conversations(&config.codex_home, &config.cwd, 1, None).await {
Ok(page) => page Ok(page) => page
.items .items
.first() .first()
@@ -363,7 +363,7 @@ async fn run_ratatui_app(
Err(_) => resume_picker::ResumeSelection::StartFresh, Err(_) => resume_picker::ResumeSelection::StartFresh,
} }
} else if cli.resume_picker { } else if cli.resume_picker {
match resume_picker::run_resume_picker(&mut tui, &config.codex_home).await? { match resume_picker::run_resume_picker(&mut tui, &config.codex_home, &config.cwd).await? {
resume_picker::ResumeSelection::Exit => { resume_picker::ResumeSelection::Exit => {
restore(); restore();
session_log::log_session_end(); session_log::log_session_end();

View File

@@ -39,9 +39,17 @@ pub enum ResumeSelection {
/// Interactive session picker that lists recorded rollout files with simple /// Interactive session picker that lists recorded rollout files with simple
/// search and pagination. Shows the first user input as the preview, relative /// search and pagination. Shows the first user input as the preview, relative
/// time (e.g., "5 seconds ago"), and the absolute path. /// time (e.g., "5 seconds ago"), and the absolute path.
pub async fn run_resume_picker(tui: &mut Tui, codex_home: &Path) -> Result<ResumeSelection> { pub async fn run_resume_picker(
tui: &mut Tui,
codex_home: &Path,
cwd: &Path,
) -> Result<ResumeSelection> {
let alt = AltScreenGuard::enter(tui); let alt = AltScreenGuard::enter(tui);
let mut state = PickerState::new(codex_home.to_path_buf(), alt.tui.frame_requester()); let mut state = PickerState::new(
codex_home.to_path_buf(),
cwd.to_path_buf(),
alt.tui.frame_requester(),
);
state.load_page(None).await?; state.load_page(None).await?;
state.request_frame(); state.request_frame();
@@ -88,6 +96,7 @@ impl Drop for AltScreenGuard<'_> {
struct PickerState { struct PickerState {
codex_home: PathBuf, codex_home: PathBuf,
cwd: PathBuf,
requester: FrameRequester, requester: FrameRequester,
// pagination // pagination
pagination: Pagination, pagination: Pagination,
@@ -115,9 +124,10 @@ struct Row {
} }
impl PickerState { impl PickerState {
fn new(codex_home: PathBuf, requester: FrameRequester) -> Self { fn new(codex_home: PathBuf, cwd: PathBuf, requester: FrameRequester) -> Self {
Self { Self {
codex_home, codex_home,
cwd,
requester, requester,
pagination: Pagination { pagination: Pagination {
current_anchor: None, current_anchor: None,
@@ -225,7 +235,9 @@ impl PickerState {
} }
async fn load_page(&mut self, anchor: Option<&Cursor>) -> Result<()> { async fn load_page(&mut self, anchor: Option<&Cursor>) -> Result<()> {
let page = RolloutRecorder::list_conversations(&self.codex_home, PAGE_SIZE, anchor).await?; let page =
RolloutRecorder::list_conversations(&self.codex_home, &self.cwd, PAGE_SIZE, anchor)
.await?;
self.pagination.next_cursor = page.next_cursor.clone(); self.pagination.next_cursor = page.next_cursor.clone();
self.all_rows = to_rows(page); self.all_rows = to_rows(page);
self.apply_filter(); self.apply_filter();