-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat(session): export full-fidelity session archives as tar.xz #6056
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -124,6 +124,7 @@ mod session_diagnostics; | |
| mod doctor_loader_tests; | ||
| #[cfg(test)] | ||
| mod session_control_acceptance; | ||
| pub mod session_export; | ||
| #[allow(dead_code)] | ||
| mod session_manager; | ||
| mod session_peek; | ||
|
|
@@ -287,14 +288,16 @@ enum Commands { | |
| #[arg(value_enum)] | ||
| shell: Shell, | ||
| }, | ||
| /// List saved sessions | ||
| /// List saved sessions, or export one as a full-fidelity archive | ||
| Sessions { | ||
| /// Maximum number of sessions to display | ||
| #[arg(short, long, default_value = "20")] | ||
| limit: usize, | ||
| /// Search sessions by title | ||
| #[arg(short, long)] | ||
| search: Option<String>, | ||
| #[command(subcommand)] | ||
| command: Option<SessionsCommand>, | ||
| }, | ||
| /// Create default AGENTS.md in current directory | ||
| Init, | ||
|
|
@@ -380,6 +383,40 @@ enum Commands { | |
| }, | ||
| } | ||
|
|
||
| /// Subcommands of `codewhale sessions`. Without one, the command falls back | ||
| /// to listing sessions. | ||
| #[derive(Subcommand, Debug, Clone)] | ||
| enum SessionsCommand { | ||
| /// List saved sessions (default when no subcommand is given) | ||
| List { | ||
| /// Maximum number of sessions to display | ||
| #[arg(short, long, default_value = "20")] | ||
| limit: usize, | ||
| /// Search sessions by title | ||
| #[arg(short, long)] | ||
| search: Option<String>, | ||
| }, | ||
| /// Export a session as a full-fidelity tar.xz archive (complete context: | ||
| /// system prompt, messages, tool calls and results, plus artifacts) | ||
| Export { | ||
| /// Session id (or unambiguous id prefix) to export | ||
| #[arg(value_name = "SESSION_ID")] | ||
| id: String, | ||
| /// Destination .tar.xz path (default: codewhale-session-<id>.tar.xz) | ||
| #[arg(short, long, value_name = "PATH")] | ||
| output: Option<PathBuf>, | ||
| /// Exclude the session artifacts directory from the archive | ||
| #[arg(long, default_value_t = false)] | ||
| skip_artifacts: bool, | ||
| /// xz compression preset, 0 (fastest) through 9 (smallest) | ||
| #[arg(long, default_value_t = session_export::DEFAULT_XZ_COMPRESSION_LEVEL)] | ||
| compression: u32, | ||
| /// Overwrite the destination file if it already exists | ||
| #[arg(long, default_value_t = false)] | ||
| force: bool, | ||
| }, | ||
| } | ||
|
|
||
| #[derive(Args, Debug, Clone)] | ||
| #[command(after_help = "\ | ||
| Examples: | ||
|
|
@@ -2213,7 +2250,23 @@ async fn run_async_main_dispatch( | |
| generate_completions(shell); | ||
| Ok(()) | ||
| } | ||
| Commands::Sessions { limit, search } => list_sessions(limit, search), | ||
| Commands::Sessions { | ||
| command, | ||
| limit, | ||
| search, | ||
| } => match command { | ||
| None => list_sessions(limit, search), | ||
| Some(SessionsCommand::List { limit, search }) => list_sessions(limit, search), | ||
| Some(SessionsCommand::Export { | ||
| id, | ||
| output, | ||
| skip_artifacts, | ||
| compression, | ||
| force, | ||
| }) => { | ||
| run_sessions_export(&id, output.as_deref(), skip_artifacts, compression, force) | ||
| } | ||
| }, | ||
| Commands::Init => init_project(), | ||
| Commands::Login { api_key } => run_login(api_key), | ||
| Commands::Logout => run_logout(), | ||
|
|
@@ -7901,6 +7954,86 @@ fn list_sessions(limit: usize, search: Option<String>) -> Result<()> { | |
| Ok(()) | ||
| } | ||
|
|
||
| /// Export one saved session as a full-fidelity `tar.xz` archive | ||
| /// (`session_export`). Prefers an exact session id; falls back to an | ||
| /// unambiguous id prefix like the resume flow. | ||
| fn run_sessions_export( | ||
| id: &str, | ||
| output: Option<&Path>, | ||
| skip_artifacts: bool, | ||
| compression: u32, | ||
| force: bool, | ||
| ) -> Result<()> { | ||
| use session_export::{SessionArchiveOptions, default_archive_file_name, write_session_archive}; | ||
|
|
||
| let manager = SessionManager::default_location()?; | ||
| let session = match manager.load_session_snapshot(id) { | ||
| Ok(session) => session, | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => { | ||
| manager.load_session_by_prefix(id)? | ||
| } | ||
|
Comment on lines
+7972
to
+7974
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Prefix exports alter session history A prefix fallback uses Learn moreExact IDs use the non-repairing snapshot loader, but the prefix fallback reaches load_session_by_prefix. That method delegates to the resume loader, which repairs dangling, duplicate, and orphaned tool call/result pairs. Exporting the same session by exact ID and by prefix can therefore produce different Example: Session Recommended fix: Add or reuse a prefix resolver that returns the unique full ID, then call Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| Err(error) => return Err(error.into()), | ||
| }; | ||
|
|
||
| let output_path = output.map_or_else( | ||
| || PathBuf::from(default_archive_file_name(&session.metadata)), | ||
| Path::to_path_buf, | ||
| ); | ||
| if output_path.exists() && !force { | ||
| bail!( | ||
| "{} already exists; pass --force to overwrite it", | ||
| output_path.display() | ||
| ); | ||
| } | ||
|
Comment on lines
+7982
to
+7987
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 No-force export overwrites concurrent files With Learn moreThe no-force decision and archive publication are separate filesystem operations. The initial existence check can succeed, then another process can create the destination before the temporary archive is persisted at write_session_archive. The archive writer replaces existing destinations, as its replacement test confirms, so the later file is lost. Example: Process A runs Recommended fix: Carry the overwrite policy into Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| let artifacts_dir = if skip_artifacts { | ||
| None | ||
| } else { | ||
| session_export::session_artifacts_dir(manager.sessions_dir(), &session.metadata.id) | ||
| }; | ||
| let summary = write_session_archive( | ||
| &session, | ||
| artifacts_dir.as_deref(), | ||
| &output_path, | ||
| SessionArchiveOptions { | ||
| include_artifacts: !skip_artifacts, | ||
| compression_level: compression, | ||
| }, | ||
| )?; | ||
|
|
||
| println!( | ||
| "Exported session {} ({}) to {}", | ||
| truncate_id(&session.metadata.id), | ||
| session.metadata.title, | ||
| summary.output.display() | ||
| ); | ||
| println!( | ||
| " {} member(s), {} uncompressed -> {} archive", | ||
| summary.members.len(), | ||
| format_bytes(summary.total_member_bytes()), | ||
| format_bytes(summary.compressed_bytes()) | ||
| ); | ||
| if artifacts_dir.is_none() && !skip_artifacts { | ||
| println!(" (no artifacts directory found for this session)"); | ||
| } | ||
| println!( | ||
| " Restore (full fidelity): /load <extracted session.json> inside the TUI; /resume imports the conversation only" | ||
| ); | ||
|
Comment on lines
+8004
to
+8021
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| Ok(()) | ||
| } | ||
|
|
||
| fn format_bytes(bytes: u64) -> String { | ||
| const KIB: f64 = 1024.0; | ||
| let bytes = bytes as f64; | ||
| if bytes >= KIB * KIB { | ||
| format!("{:.1} MiB", bytes / (KIB * KIB)) | ||
| } else if bytes >= KIB { | ||
| format!("{:.1} KiB", bytes / KIB) | ||
| } else { | ||
| format!("{bytes} B") | ||
| } | ||
| } | ||
|
|
||
| /// Initialize a new project with AGENTS.md | ||
| fn init_project() -> Result<()> { | ||
| use codewhale_palette as palette; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Read-only classification now includes export
telemetry_command_is_read_onlyclassifies everySessionsvariant as read-only. Export writes files, so reviewers must reassess this command-level classification.Was this helpful? React with 👍 or 👎 to provide feedback.