Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ htmd = "0.5.4"
lru = "0.18"
parking_lot = "0.12"
tar = "0.4"
# Vendored static liblzma keeps the full-fidelity session archive export
# (`session_export`) hermetic on every release target; no system xz headers.
# liblzma is the maintained, API-compatible continuation of xz2.
liblzma = { version = "0.4", features = ["static"] }
flate2 = "1.1"
sha2.workspace = true
semver.workspace = true
Expand Down
137 changes: 135 additions & 2 deletions crates/tui/src/lib.rs

Copy link
Copy Markdown
Contributor

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_only classifies every Sessions variant as read-only. Export writes files, so reviewers must reassess this command-level classification.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Prefix exports alter session history

A prefix fallback uses load_session_by_prefix, which applies resume-time tool-history repair. The archive can differ from the durable session selected by that prefix.

Learn more

Exact 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 session.json records.

Example: Session abc123 ends durably with an unfinished tool_use. sessions export abc123 preserves it, while sessions export abc repairs it before serialization. The second archive no longer represents the saved record.

Recommended fix: Add or reuse a prefix resolver that returns the unique full ID, then call load_session_snapshot with that ID. Keep load_session_by_prefix for resume callers that require repair.

Devin Review

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 No-force export overwrites concurrent files

With force false, a destination created after exists() is still replaced. write_session_archive publishes unconditionally, deleting the concurrent file.

Learn more

The 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 sessions export abc --output backup.tar.xz without --force and sees no destination. Process B creates backup.tar.xz before A finishes compression. Process A replaces B's file, although it was required to reject an existing destination.

Recommended fix: Carry the overwrite policy into write_session_archive and publish with an atomic no-clobber operation when force is false. Keep atomic replacement only for force == true; an additional preflight check can improve errors but cannot enforce the contract.

Devin Review

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Export output bypasses localization

The new success and restore messages are hard-coded English. TUI guidance requires user-visible prose through tr(locale, MessageId::...).

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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;
Expand Down
Loading
Loading