From 16cfee1935f2e5dec2a2d8a69a89ffe1bfec7b11 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Thu, 22 Jan 2026 14:44:02 -0800 Subject: [PATCH] Fork remotely stored session --- codex-rs/core/src/session_share.rs | 43 ++++++++++++++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 4 +-- codex-rs/tui/src/lib.rs | 42 ++++++++++++++++++++++++----- 3 files changed, 81 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/session_share.rs b/codex-rs/core/src/session_share.rs index b00ee4a178..17e821c9e9 100644 --- a/codex-rs/core/src/session_share.rs +++ b/codex-rs/core/src/session_share.rs @@ -1,4 +1,5 @@ use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use anyhow::Context; @@ -16,6 +17,8 @@ use reqwest::Url; use serde::Deserialize; use serde::Serialize; use time::OffsetDateTime; +use time::format_description::FormatItem; +use time::macros::format_description; const SHARE_OBJECT_PREFIX: &str = "sessions"; const SHARE_OBJECT_SUFFIX: &str = ".jsonl"; @@ -181,6 +184,29 @@ pub async fn upload_rollout_with_owner( }) } +pub async fn download_rollout_if_available( + base_url: &str, + session_id: ThreadId, + codex_home: &Path, +) -> anyhow::Result> { + let store = SessionObjectStore::new(base_url).await?; + let key = object_key(session_id); + let Some(data) = store.get_object_bytes(&key).await? else { + return Ok(None); + }; + let path = build_rollout_download_path(codex_home, session_id)?; + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("failed to resolve rollout directory"))?; + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("failed to create rollout directory {}", parent.display()))?; + tokio::fs::write(&path, data) + .await + .with_context(|| format!("failed to write rollout file {}", path.display()))?; + Ok(Some(path)) +} + fn object_key(id: ThreadId) -> String { format!("{SHARE_OBJECT_PREFIX}/{id}{SHARE_OBJECT_SUFFIX}") } @@ -211,6 +237,23 @@ async fn upload_meta( Ok(()) } +fn build_rollout_download_path(codex_home: &Path, session_id: ThreadId) -> anyhow::Result { + let timestamp = OffsetDateTime::now_local() + .map_err(|e| anyhow::anyhow!("failed to get local time: {e}"))?; + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let date_str = timestamp + .format(format) + .map_err(|e| anyhow::anyhow!("failed to format timestamp: {e}"))?; + let mut dir = codex_home.to_path_buf(); + dir.push(crate::rollout::SESSIONS_SUBDIR); + dir.push(timestamp.year().to_string()); + dir.push(format!("{:02}", u8::from(timestamp.month()))); + dir.push(format!("{:02}", timestamp.day())); + let filename = format!("rollout-{date_str}-{session_id}.jsonl"); + Ok(dir.join(filename)) +} + impl HttpObjectStore { fn object_url(&self, key: &str) -> anyhow::Result { self.base_url diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e42fee2bdd..a23cb067ee 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -432,7 +432,7 @@ fn parse_share_scope(response: &RequestUserInputResponse) -> ShareScope { response .answers .get(SHARE_SCOPE_QUESTION_ID) - .and_then(|answer| selected_label(answer)) + .and_then(selected_label) .map(|label| { if label == SHARE_SCOPE_EMAIL_LABEL { ShareScope::Emails @@ -452,7 +452,7 @@ fn parse_share_emails(response: &RequestUserInputResponse) -> Vec { return Vec::new(); }; note.split(',') - .map(|entry| entry.trim()) + .map(str::trim) .filter(|entry| !entry.is_empty()) .map(ToString::to_string) .collect() diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index cb4e311576..76698724a1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -32,8 +32,10 @@ use codex_core::format_exec_policy_error_with_source; use codex_core::path_utils; use codex_core::protocol::AskForApproval; use codex_core::read_session_meta_line; +use codex_core::session_share::download_rollout_if_available; use codex_core::terminal::Multiplexer; use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_protocol::ThreadId; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::WindowsSandboxLevel; @@ -544,8 +546,15 @@ async fn run_ratatui_app( } else { initial_config }; - let mut missing_session_exit = |id_str: &str, action: &str| { - error!("Error finding conversation path: {id_str}"); + let ollama_chat_support_notice = match ollama_chat_deprecation_notice(&config).await { + Ok(notice) => notice, + Err(err) => { + tracing::warn!(?err, "Failed to detect Ollama wire API"); + None + } + }; + let mut fatal_exit = |message: String| { + error!("{message}"); restore(); session_log::log_session_end(); let _ = tui.terminal.clear(); @@ -554,11 +563,14 @@ async fn run_ratatui_app( thread_id: None, thread_name: None, update_action: None, - exit_reason: ExitReason::Fatal(format!( - "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." - )), + exit_reason: ExitReason::Fatal(message), }) }; + let mut missing_session_exit = |id_str: &str, action: &str| { + fatal_exit(format!( + "No saved session found with ID {id_str}. Run `codex {action}` without an ID to choose from existing sessions." + )) + }; let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some(); let session_selection = if use_fork { @@ -571,7 +583,25 @@ async fn run_ratatui_app( }; match path { Some(path) => resume_picker::SessionSelection::Fork(path), - None => return missing_session_exit(id_str, "fork"), + None => { + let Some(storage_url) = config.session_object_storage_url.as_deref() else { + return missing_session_exit(id_str, "fork"); + }; + let Ok(session_id) = ThreadId::from_string(id_str) else { + return missing_session_exit(id_str, "fork"); + }; + match download_rollout_if_available(storage_url, session_id, &config.codex_home) + .await + { + Ok(Some(path)) => resume_picker::SessionSelection::Fork(path), + Ok(None) => return missing_session_exit(id_str, "fork"), + Err(err) => { + return fatal_exit(format!( + "Failed to fetch remote session {id_str}: {err}" + )); + } + } + } } } else if cli.fork_last { let provider_filter = vec![config.model_provider_id.clone()];