Add thread archive CLI commands (#25021)

## Problem

Saved threads can already be archived through app-server RPCs, but the
command line did not expose direct archive or unarchive commands.

## Solution

Add `codex archive <thread>` and `codex unarchive <thread>`, resolving
UUIDs or exact thread names before calling the existing `thread/archive`
and `thread/unarchive` RPCs. The commands support scoped remote flags so
callers can target remote app-server endpoints when archiving or
unarchiving threads.

This also fixes a long-standing bug in `codex resume <thread id>` and
`codex fork <thread id>` that I found when testing the new commands.
These operations shouldn't be allowed on archived sessions. They now
fail with an error that tells the user to run `codex unarchive <thread
id>` first.

## Verification

Added app-server coverage for rejecting archived thread resume by id and
checking that the error includes the matching `codex unarchive <thread
id>` command.
This commit is contained in:
Eric Traut
2026-05-29 23:37:26 -07:00
committed by GitHub
Unverified
parent e0435afb72
commit 3e7baa00e4
8 changed files with 697 additions and 47 deletions
@@ -1308,7 +1308,7 @@ impl ThreadRequestProcessor {
params: ThreadArchiveParams,
) -> Result<(ThreadArchiveResponse, Vec<String>), JSONRPCErrorError> {
let thread_id = ThreadId::from_string(&params.thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
.map_err(|err| invalid_request(format!("invalid session id: {err}")))?;
let mut thread_ids = vec![thread_id];
if let Some(state_db_ctx) = self.state_db.as_ref() {
@@ -1317,7 +1317,7 @@ impl ThreadRequestProcessor {
.await
.map_err(|err| {
internal_error(format!(
"failed to list spawned descendants for thread id {thread_id}: {err}"
"failed to list spawned descendants for session {thread_id}: {err}"
))
})?;
let mut seen = HashSet::from([thread_id]);
@@ -1629,7 +1629,7 @@ impl ThreadRequestProcessor {
params: ThreadUnarchiveParams,
) -> Result<(ThreadUnarchiveResponse, String), JSONRPCErrorError> {
let thread_id = ThreadId::from_string(&params.thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
.map_err(|err| invalid_request(format!("invalid session id: {err}")))?;
let fallback_provider = self.config.model_provider_id.clone();
let stored_thread = self
@@ -2968,7 +2968,7 @@ impl ThreadRequestProcessor {
let existing_thread_id = match ThreadId::from_string(thread_id) {
Ok(id) => id,
Err(err) => {
return Err(invalid_request(format!("invalid thread id: {err}")));
return Err(invalid_request(format!("invalid session id: {err}")));
}
};
let params = StoreReadThreadParams {
@@ -2979,7 +2979,15 @@ impl ThreadRequestProcessor {
self.thread_store.read_thread(params).await
};
result.map_err(thread_store_resume_read_error)
let stored_thread = result.map_err(thread_store_resume_read_error)?;
if stored_thread.archived_at.is_some() {
let thread_id = stored_thread.thread_id;
return Err(invalid_request(format!(
"session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first."
)));
}
Ok(stored_thread)
}
async fn stored_thread_to_initial_history(
@@ -3964,7 +3972,7 @@ fn thread_store_archive_error(operation: &str, err: ThreadStoreError) -> JSONRPC
ThreadStoreError::Unsupported {
operation: unsupported_operation,
} => unsupported_thread_store_operation(unsupported_operation),
err => internal_error(format!("failed to {operation} thread: {err}")),
err => internal_error(format!("failed to {operation} session: {err}")),
}
}
@@ -51,6 +51,7 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::ThreadId;
use codex_protocol::config_types::Personality;
@@ -780,6 +781,57 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_resume_rejects_archived_session_by_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let filename_ts = "2025-01-05T12-00-00";
let conversation_id = create_fake_rollout_with_text_elements(
codex_home.path(),
filename_ts,
"2025-01-05T12:00:00Z",
"Archived saved user message",
Vec::new(),
Some("mock_provider"),
/*git_info*/ None,
)?;
let active_rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR);
std::fs::create_dir_all(&archived_dir)?;
std::fs::rename(
&active_rollout_path,
archived_dir.join(active_rollout_path.file_name().expect("rollout file name")),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: conversation_id.clone(),
..Default::default()
})
.await?;
let resume_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
)
.await??;
let message = resume_err.error.message;
assert!(
message.contains(&format!("session {conversation_id} is archived"))
&& message.contains(&format!(
"codex unarchive {conversation_id}` to unarchive it first"
)),
"unexpected resume error: {message}"
);
Ok(())
}
#[tokio::test]
async fn thread_resume_keeps_paused_goal_paused() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
+230 -32
View File
@@ -35,6 +35,7 @@ use codex_tui::UpdateAction;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_cli::ProfileV2Name;
use codex_utils_cli::SharedCliOptions;
use codex_utils_cli::resume_hint;
use owo_colors::OwoColorize;
use std::io::IsTerminal;
@@ -175,6 +176,12 @@ enum Subcommand {
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
Resume(ResumeCommand),
/// Archive a saved session by id or session name.
Archive(SessionArchiveCommand),
/// Unarchive a saved session by id or session name.
Unarchive(SessionArchiveCommand),
/// Fork a previous interactive session (picker by default; use --last to fork the most recent).
Fork(ForkCommand),
@@ -296,7 +303,7 @@ struct DebugTraceReduceCommand {
#[derive(Debug, Parser)]
struct ResumeCommand {
/// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses.
/// Session id (UUID) or session name. UUIDs take precedence if it parses.
/// If omitted, use --last to pick the most recent recorded session.
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,
@@ -320,6 +327,32 @@ struct ResumeCommand {
config_overrides: TuiCli,
}
#[derive(Debug, Parser)]
struct SessionArchiveCommand {
/// Session id (UUID) or session name. UUIDs take precedence if it parses.
#[arg(value_name = "SESSION")]
target: String,
#[clap(flatten)]
remote: InteractiveRemoteOptions,
#[clap(flatten)]
config_overrides: SessionArchiveConfigOverrides,
}
#[derive(Debug, Args, Clone, Default)]
struct SessionArchiveConfigOverrides {
#[clap(flatten)]
shared: SharedCliOptions,
/// Error out when config.toml contains fields that are not recognized by this version of Codex.
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
#[clap(flatten)]
config_overrides: CliConfigOverrides,
}
#[derive(Debug, Parser)]
struct ForkCommand {
/// Conversation/session id (UUID). When provided, forks this session.
@@ -736,6 +769,39 @@ fn run_execpolicycheck(cmd: ExecPolicyCheckCommand) -> anyhow::Result<()> {
cmd.run()
}
async fn run_session_archive_cli_command(
action: codex_tui::SessionArchiveAction,
cmd: SessionArchiveCommand,
mut interactive: TuiCli,
root_config_overrides: CliConfigOverrides,
root_remote: Option<String>,
root_remote_auth_token_env: Option<String>,
arg0_paths: Arg0DispatchPaths,
) -> anyhow::Result<String> {
let SessionArchiveCommand {
target,
remote,
config_overrides,
} = cmd;
interactive =
finalize_session_archive_interactive(interactive, root_config_overrides, config_overrides);
let explicit_remote_endpoint = resolve_remote_endpoint(
remote.remote.or(root_remote),
remote.remote_auth_token_env.or(root_remote_auth_token_env),
)?;
codex_tui::run_session_archive_command(
action,
target,
codex_tui::SessionArchiveCommandOptions {
cli: interactive,
arg0_paths,
explicit_remote_endpoint,
},
)
.await
.map_err(|err| anyhow::anyhow!("{err}"))
}
async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> {
match cmd.subcommand {
DebugAppServerSubcommand::SendMessageV2(cmd) => {
@@ -1126,6 +1192,32 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
handle_app_exit(exit_info)?;
}
Some(Subcommand::Archive(cmd)) => {
let output = run_session_archive_cli_command(
codex_tui::SessionArchiveAction::Archive,
cmd,
interactive,
root_config_overrides.clone(),
root_remote.clone(),
root_remote_auth_token_env.clone(),
arg0_paths.clone(),
)
.await?;
println!("{output}");
}
Some(Subcommand::Unarchive(cmd)) => {
let output = run_session_archive_cli_command(
codex_tui::SessionArchiveAction::Unarchive,
cmd,
interactive,
root_config_overrides.clone(),
root_remote.clone(),
root_remote_auth_token_env.clone(),
arg0_paths.clone(),
)
.await?;
println!("{output}");
}
Some(Subcommand::Fork(ForkCommand {
session_id,
last,
@@ -1475,6 +1567,8 @@ fn profile_v2_for_subcommand<'a>(
Subcommand::Exec(_)
| Subcommand::Review(_)
| Subcommand::Resume(_)
| Subcommand::Archive(_)
| Subcommand::Unarchive(_)
| Subcommand::Fork(_)
| Subcommand::Mcp(_)
| Subcommand::Sandbox(_)
@@ -1482,7 +1576,7 @@ fn profile_v2_for_subcommand<'a>(
subcommand: DebugSubcommand::PromptInput(_),
}) => Ok(Some(profile_v2)),
_ => anyhow::bail!(
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
),
}
}
@@ -1902,6 +1996,8 @@ fn unsupported_subcommand_name_for_strict_config(
| Some(Subcommand::McpServer(_))
| Some(Subcommand::ExecServer(_))
| Some(Subcommand::Resume(_))
| Some(Subcommand::Archive(_))
| Some(Subcommand::Unarchive(_))
| Some(Subcommand::Fork(_))
| Some(Subcommand::Doctor(_)) => None,
Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None,
@@ -2049,34 +2145,13 @@ async fn run_interactive_tui(
}
}
let mut remote_endpoint = remote
.as_deref()
.map(codex_tui::resolve_remote_addr)
.transpose()
.map_err(std::io::Error::other)?;
if let Some(remote_auth_token_env) = remote_auth_token_env {
let Some(endpoint) = remote_endpoint.as_mut() else {
return Ok(AppExitInfo::fatal(
"`--remote-auth-token-env` requires `--remote`.",
));
};
if !codex_tui::remote_addr_supports_auth_token(endpoint) {
return Ok(AppExitInfo::fatal(
"`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.",
));
let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env) {
Ok(remote_endpoint) => remote_endpoint,
Err(err) if is_remote_auth_usage_error(&err) => {
return Ok(AppExitInfo::fatal(err.to_string()));
}
let auth_token = read_remote_auth_token_from_env_var(&remote_auth_token_env)
.map_err(std::io::Error::other)?;
let codex_tui::RemoteAppServerEndpoint::WebSocket {
auth_token: slot, ..
} = endpoint
else {
return Ok(AppExitInfo::fatal(
"`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.",
));
};
*slot = Some(auth_token);
}
Err(err) => return Err(err),
};
let start_tui = || {
codex_tui::run_main(
interactive.clone(),
@@ -2120,6 +2195,46 @@ async fn run_interactive_tui(
}
}
fn resolve_remote_endpoint(
remote: Option<String>,
remote_auth_token_env: Option<String>,
) -> std::io::Result<Option<codex_tui::RemoteAppServerEndpoint>> {
let mut remote_endpoint = remote
.as_deref()
.map(codex_tui::resolve_remote_addr)
.transpose()
.map_err(std::io::Error::other)?;
if let Some(remote_auth_token_env) = remote_auth_token_env {
let Some(endpoint) = remote_endpoint.as_mut() else {
return Err(std::io::Error::other(
"`--remote-auth-token-env` requires `--remote`.",
));
};
if !codex_tui::remote_addr_supports_auth_token(endpoint) {
return Err(std::io::Error::other(
"`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.",
));
}
let auth_token = read_remote_auth_token_from_env_var(&remote_auth_token_env)
.map_err(std::io::Error::other)?;
let codex_tui::RemoteAppServerEndpoint::WebSocket {
auth_token: slot, ..
} = endpoint
else {
return Err(std::io::Error::other(
"`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.",
));
};
*slot = Some(auth_token);
}
Ok(remote_endpoint)
}
fn is_remote_auth_usage_error(err: &std::io::Error) -> bool {
err.to_string()
.starts_with("`--remote-auth-token-env` requires")
}
fn confirm(prompt: &str) -> std::io::Result<bool> {
eprintln!("{prompt}");
@@ -2183,9 +2298,31 @@ fn finalize_fork_interactive(
interactive
}
/// Merge flags provided to `codex resume`/`codex fork` so they take precedence over any
/// root-level flags. Only overrides fields explicitly set on the subcommand-scoped
/// CLI. Also appends `-c key=value` overrides with highest precedence.
fn finalize_session_archive_interactive(
mut interactive: TuiCli,
root_config_overrides: CliConfigOverrides,
archive_cli: SessionArchiveConfigOverrides,
) -> TuiCli {
let SessionArchiveConfigOverrides {
shared,
strict_config,
config_overrides,
} = archive_cli;
interactive.shared.apply_subcommand_overrides(shared);
if strict_config {
interactive.strict_config = true;
}
interactive
.config_overrides
.raw_overrides
.extend(config_overrides.raw_overrides);
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
interactive
}
/// Merge flags provided to runtime wrapper commands so they take precedence over any root-level
/// flags. Only overrides fields explicitly set on the subcommand-scoped CLI. Also appends
/// `-c key=value` overrides with highest precedence.
fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) {
let TuiCli {
shared,
@@ -2347,6 +2484,32 @@ mod tests {
finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli)
}
fn finalize_archive_from_args(args: &[&str]) -> (String, TuiCli, InteractiveRemoteOptions) {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let MultitoolCli {
interactive,
config_overrides: root_overrides,
subcommand,
feature_toggles: _,
remote: _,
} = cli;
let Subcommand::Archive(SessionArchiveCommand {
target,
remote,
config_overrides: archive_cli,
}) = subcommand.expect("archive present")
else {
unreachable!()
};
(
target,
finalize_session_archive_interactive(interactive, root_overrides, archive_cli),
remote,
)
}
fn profile_v2_for_args(args: &[&str]) -> anyhow::Result<Option<String>> {
let cli = MultitoolCli::try_parse_from(args).expect("parse");
let Some(subcommand) = cli.subcommand.as_ref() else {
@@ -2617,6 +2780,41 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Update)));
}
#[test]
fn archive_merges_scoped_tui_flags() {
let (target, interactive, remote) = finalize_archive_from_args(
[
"codex",
"-C",
"/root",
"archive",
"--remote",
"unix://archive.sock",
"--strict-config",
"--dangerously-bypass-hook-trust",
"-m",
"gpt-5.1-test",
"-p",
"work",
"-C",
"/archive",
"my-thread",
]
.as_ref(),
);
assert_eq!(target, "my-thread");
assert_eq!(remote.remote.as_deref(), Some("unix://archive.sock"));
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
assert_eq!(interactive.config_profile_v2.as_deref(), Some("work"));
assert_eq!(
interactive.cwd.as_deref(),
Some(std::path::Path::new("/archive"))
);
assert!(interactive.strict_config);
assert!(interactive.bypass_hook_trust);
}
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[test]
fn sandbox_parses_permissions_profile() {
+27 -8
View File
@@ -652,6 +652,31 @@ fn active_turn_steer_race(error: &TypedRequestError) -> Option<ActiveTurnSteerRa
Some(ActiveTurnSteerRace::ExpectedTurnMismatch { actual_turn_id })
}
fn session_start_error(
action: &str,
target_session: &SessionTarget,
err: color_eyre::eyre::Report,
) -> color_eyre::eyre::Report {
if let Some(message) = archived_session_guidance(&err) {
return color_eyre::eyre::eyre!("{message}");
}
let target_label = target_session.display_label();
color_eyre::eyre::eyre!("Failed to {action} session from {target_label}: {err}")
}
fn archived_session_guidance(err: &color_eyre::eyre::Report) -> Option<String> {
let err = err.to_string();
let message = &err[err.find("session ")?..];
if !message.contains(" is archived. Run `codex unarchive ") {
return None;
}
let message = message
.split_once(" (code ")
.map_or(message, |(message, _)| message);
Some(message.to_string())
}
impl App {
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
@@ -872,10 +897,7 @@ impl App {
let resumed = app_server
.resume_thread(config.clone(), target_session.thread_id)
.await
.wrap_err_with(|| {
let target_label = target_session.display_label();
format!("Failed to resume session from {target_label}")
})?;
.map_err(|err| session_start_error("resume", &target_session, err))?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
@@ -913,10 +935,7 @@ impl App {
let forked = app_server
.fork_thread(config.clone(), target_session.thread_id)
.await
.wrap_err_with(|| {
let target_label = target_session.display_label();
format!("Failed to fork session from {target_label}")
})?;
.map_err(|err| session_start_error("fork", &target_session, err))?;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
+26
View File
@@ -4371,6 +4371,32 @@ fn active_turn_not_steerable_turn_error_extracts_structured_server_error() {
);
}
#[test]
fn session_start_error_surfaces_archived_guidance_without_rollout_path() {
let thread_id =
ThreadId::from_string("019e72f4-e09a-70f2-b2c2-a153a57b8cc0").expect("thread id");
let target_session = SessionTarget {
path: Some(std::path::PathBuf::from(
"/Users/me/.codex/archived_sessions/rollout.jsonl",
)),
thread_id,
};
let expected = format!(
"session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first."
);
for action in ["resume", "fork"] {
let err = color_eyre::eyre::eyre!(
"thread/{action} failed during TUI bootstrap: thread/{action} failed: {expected} (code -32600)"
);
assert_eq!(
session_start_error(action, &target_session, err).to_string(),
expected
);
}
}
#[test]
fn active_turn_steer_race_detects_missing_active_turn() {
let error = TypedRequestError::Server {
+18 -1
View File
@@ -97,6 +97,8 @@ use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartSource;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::Turn;
@@ -577,10 +579,25 @@ impl AppServerSession {
},
})
.await
.wrap_err("thread/archive failed in TUI")?;
.wrap_err("failed to archive session")?;
Ok(())
}
pub(crate) async fn thread_unarchive(&mut self, thread_id: ThreadId) -> Result<Thread> {
let request_id = self.next_request_id();
let response: ThreadUnarchiveResponse = self
.client
.request_typed(ClientRequest::ThreadUnarchive {
request_id,
params: ThreadUnarchiveParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("failed to unarchive session")?;
Ok(response.thread)
}
pub(crate) async fn thread_metadata_update_branch(
&mut self,
thread_id: ThreadId,
+4
View File
@@ -64,6 +64,9 @@ use codex_utils_oss::ensure_oss_provider_ready;
use codex_utils_oss::get_default_model_for_oss_provider;
use color_eyre::eyre::WrapErr;
use cwd_prompt::CwdPromptAction;
pub use session_archive_commands::SessionArchiveAction;
pub use session_archive_commands::SessionArchiveCommandOptions;
pub use session_archive_commands::run_session_archive_command;
use std::fs::OpenOptions;
use std::path::Path;
use std::path::PathBuf;
@@ -167,6 +170,7 @@ mod resize_reflow_cap;
mod resume_picker;
mod selection_list;
mod service_tier_resolution;
mod session_archive_commands;
mod session_log;
mod session_resume;
mod session_state;
@@ -0,0 +1,326 @@
//! Shared implementation for `codex archive` and `codex unarchive`.
//!
//! The CLI commands are thin app-server clients: resolve a user-provided UUID or exact session
//! name, then call the existing `thread/archive` or `thread/unarchive` RPC.
use std::sync::Arc;
use crate::Cli;
use crate::app_server_session::AppServerSession;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options;
use crate::legacy_core::config::resolve_oss_provider;
use crate::legacy_core::config::resolve_profile_v2_config_path;
use codex_app_server_protocol::Thread as AppServerThread;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadSortKey;
use codex_arg0::Arg0DispatchPaths;
use codex_cloud_requirements::cloud_requirements_loader_for_storage;
use codex_config::ConfigLoadOptions;
use codex_config::LoaderOverrides;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_protocol::ThreadId;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_home_dir::find_codex_home;
use codex_utils_oss::get_default_model_for_oss_provider;
use color_eyre::eyre::ContextCompat;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use color_eyre::eyre::eyre;
use super::RemoteAppServerEndpoint;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionArchiveAction {
Archive,
Unarchive,
}
pub struct SessionArchiveCommandOptions {
pub cli: Cli,
pub arg0_paths: Arg0DispatchPaths,
pub explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
}
fn success_message(
action: SessionArchiveAction,
session_id: ThreadId,
session_name: Option<&str>,
) -> String {
let action = match action {
SessionArchiveAction::Archive => "Archived",
SessionArchiveAction::Unarchive => "Unarchived",
};
match session_name {
Some(name) => format!("{action} session {name} ({session_id})."),
None => format!("{action} session {session_id}."),
}
}
struct ResolvedSessionTarget {
session_id: ThreadId,
session_name: Option<String>,
}
pub async fn run_session_archive_command(
action: SessionArchiveAction,
target: String,
options: SessionArchiveCommandOptions,
) -> Result<String> {
let mut app_server = start_app_server_for_archive_command(options).await?;
run_session_archive_action_with_app_server(&mut app_server, action, &target).await
}
async fn run_session_archive_action_with_app_server(
app_server: &mut AppServerSession,
action: SessionArchiveAction,
target: &str,
) -> Result<String> {
let resolved = resolve_session_target(app_server, action, target).await?;
match action {
SessionArchiveAction::Archive => {
app_server.thread_archive(resolved.session_id).await?;
Ok(success_message(
action,
resolved.session_id,
resolved.session_name.as_deref(),
))
}
SessionArchiveAction::Unarchive => {
let thread = app_server.thread_unarchive(resolved.session_id).await?;
let session_name = thread.name.or(resolved.session_name);
Ok(success_message(
action,
resolved.session_id,
session_name.as_deref(),
))
}
}
}
async fn resolve_session_target(
app_server: &mut AppServerSession,
action: SessionArchiveAction,
target: &str,
) -> Result<ResolvedSessionTarget> {
if let Ok(session_id) = ThreadId::from_string(target) {
return Ok(ResolvedSessionTarget {
session_id,
session_name: None,
});
}
let search_scope = match action {
SessionArchiveAction::Archive => "active",
SessionArchiveAction::Unarchive => "archived",
};
let resolved = lookup_session_by_exact_name(app_server, action, target)
.await?
.map(session_target_from_app_server_thread)
.transpose()?;
resolved.with_context(|| format!("No {search_scope} session found matching '{target}'."))
}
async fn lookup_session_by_exact_name(
app_server: &mut AppServerSession,
action: SessionArchiveAction,
name: &str,
) -> Result<Option<AppServerThread>> {
let mut cursor = None;
loop {
let response = app_server
.thread_list(ThreadListParams {
cursor: cursor.clone(),
limit: Some(100),
sort_key: Some(ThreadSortKey::UpdatedAt),
sort_direction: None,
model_providers: None,
source_kinds: Some(super::resume_source_kinds(
/*include_non_interactive*/ false,
)),
archived: Some(matches!(action, SessionArchiveAction::Unarchive)),
cwd: None,
use_state_db_only: false,
search_term: Some(name.to_string()),
})
.await
.wrap_err("failed to list sessions while resolving session name")?;
if let Some(thread) = response
.data
.into_iter()
.find(|thread| thread.name.as_deref() == Some(name))
{
return Ok(Some(thread));
}
if response.next_cursor.is_none() {
return Ok(None);
}
cursor = response.next_cursor;
}
}
fn session_target_from_app_server_thread(thread: AppServerThread) -> Result<ResolvedSessionTarget> {
let session_id = ThreadId::from_string(&thread.id)
.wrap_err_with(|| format!("app server returned invalid session id `{}`", thread.id))?;
Ok(ResolvedSessionTarget {
session_id,
session_name: thread.name,
})
}
async fn start_app_server_for_archive_command(
options: SessionArchiveCommandOptions,
) -> Result<AppServerSession> {
let SessionArchiveCommandOptions {
cli,
arg0_paths,
explicit_remote_endpoint,
} = options;
let loader_overrides = LoaderOverrides::default();
let strict_config = cli.strict_config;
let raw_overrides = cli.config_overrides.raw_overrides.clone();
let overrides_cli = CliConfigOverrides { raw_overrides };
let cli_kv_overrides = overrides_cli
.parse_overrides()
.map_err(|err| eyre!("failed to parse -c overrides: {err}"))?;
let codex_home = find_codex_home().wrap_err("failed to find Codex home")?;
let mut launch_loader_overrides = loader_overrides.clone();
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
launch_loader_overrides.user_config_path = Some(resolve_profile_v2_config_path(
codex_home.as_path(),
profile_v2,
));
launch_loader_overrides.user_config_profile = Some(profile_v2.clone());
}
let reuse_implicit_local_daemon = super::can_reuse_implicit_local_daemon(
&cli_kv_overrides,
&launch_loader_overrides,
strict_config,
cli.bypass_hook_trust,
);
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
super::maybe_probe_default_daemon_socket(codex_home.as_path()).await
} else {
None
};
let app_server_target = super::app_server_target_for_launch(
explicit_remote_endpoint,
default_daemon,
reuse_implicit_local_daemon,
);
let remote_cwd_override = cli
.cwd
.clone()
.filter(|_| app_server_target.uses_remote_workspace());
let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)
.wrap_err("failed to resolve local runtime paths")?;
let environment_manager = EnvironmentManager::from_env(Some(local_runtime_paths))
.await
.map(Arc::new)
.wrap_err("failed to initialize environment manager")?;
let config_cwd = super::config_cwd_for_app_server_target(
cli.cwd.as_deref(),
&app_server_target,
&environment_manager,
)
.wrap_err("failed to resolve config cwd")?;
let mut loader_overrides = loader_overrides;
if let Some(profile_v2) = cli.config_profile_v2.as_ref() {
loader_overrides.user_config_path = Some(resolve_profile_v2_config_path(
codex_home.as_path(),
profile_v2,
));
loader_overrides.user_config_profile = Some(profile_v2.clone());
}
let config_toml = load_config_as_toml_with_cli_and_load_options(
codex_home.as_path(),
config_cwd.as_ref(),
cli_kv_overrides.clone(),
ConfigLoadOptions {
loader_overrides: loader_overrides.clone(),
strict_config,
},
)
.await
.wrap_err("failed to load config.toml")?;
let chatgpt_base_url = config_toml
.chatgpt_base_url
.clone()
.unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string());
let cloud_requirements = cloud_requirements_loader_for_storage(
codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config_toml.cli_auth_credentials_store.unwrap_or_default(),
chatgpt_base_url,
)
.await;
let model_provider = if cli.oss {
resolve_oss_provider(cli.oss_provider.as_deref(), &config_toml)
} else {
None
};
let model = cli.model.clone().or_else(|| {
model_provider
.as_deref()
.and_then(get_default_model_for_oss_provider)
.map(ToOwned::to_owned)
});
let cwd = cli.cwd.clone();
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides.clone())
.harness_overrides(ConfigOverrides {
model,
cwd: if app_server_target.uses_remote_workspace() {
None
} else {
cwd
},
model_provider,
codex_self_exe: arg0_paths.codex_self_exe.clone(),
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
show_raw_agent_reasoning: cli.oss.then_some(true),
bypass_hook_trust: cli.bypass_hook_trust.then_some(true),
..Default::default()
})
.loader_overrides(loader_overrides.clone())
.strict_config(strict_config)
.cloud_requirements(cloud_requirements.clone())
.build()
.await
.wrap_err("failed to load configuration")?;
let state_db = super::init_state_db_for_app_server_target(&config, &app_server_target)
.await
.wrap_err("failed to initialize state database")?;
let app_server = super::start_app_server(
&app_server_target,
arg0_paths,
config,
cli_kv_overrides,
loader_overrides,
strict_config,
cloud_requirements,
codex_feedback::CodexFeedback::new(),
/*log_db*/ None,
state_db,
environment_manager,
)
.await?;
Ok(
AppServerSession::new(app_server, app_server_target.thread_params_mode())
.with_remote_cwd_override(remote_cwd_override),
)
}