mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
e0435afb72
commit
3e7baa00e4
@@ -1308,7 +1308,7 @@ impl ThreadRequestProcessor {
|
||||
params: ThreadArchiveParams,
|
||||
) -> Result<(ThreadArchiveResponse, Vec<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 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}")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user