Fork remotely stored session

This commit is contained in:
Charles Cunningham
2026-01-22 14:44:02 -08:00
parent f4c955af57
commit 16cfee1935
3 changed files with 81 additions and 8 deletions

View File

@@ -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<Option<PathBuf>> {
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<PathBuf> {
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<Url> {
self.base_url

View File

@@ -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<String> {
return Vec::new();
};
note.split(',')
.map(|entry| entry.trim())
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(ToString::to_string)
.collect()

View File

@@ -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()];