diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 939c3df51903..6440f0786d46 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1308,7 +1308,7 @@ impl ThreadRequestProcessor { params: ThreadArchiveParams, ) -> Result<(ThreadArchiveResponse, Vec), JSONRPCErrorError> { let thread_id = ThreadId::from_string(¶ms.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(¶ms.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}")), } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 36dac0d45ce3..8b302954e053 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -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; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e5660301b5bc..2a5ce171b7aa 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -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, @@ -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, + root_remote_auth_token_env: Option, + arg0_paths: Arg0DispatchPaths, +) -> anyhow::Result { + 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, + remote_auth_token_env: Option, +) -> std::io::Result> { + 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 { 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> { 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() { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1d6a54b080c7..e31b18044c20 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -652,6 +652,31 @@ fn active_turn_steer_race(error: &TypedRequestError) -> Option 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 { + 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(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 993772ab774f..19da8ea77df9 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -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 { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index e66b5ee8a27e..ac6ef45d0200 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -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 { + 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, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 36b800145e25..508977fb539e 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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; diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs new file mode 100644 index 000000000000..0b50a4409bbe --- /dev/null +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -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, +} + +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, +} + +pub async fn run_session_archive_command( + action: SessionArchiveAction, + target: String, + options: SessionArchiveCommandOptions, +) -> Result { + 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 { + 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 { + 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> { + 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 { + 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 { + 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), + ) +}