Skip to content
Open
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
120 changes: 113 additions & 7 deletions cortex-cli/src/export_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
//! Session export command for Cortex CLI.
//!
//! Exports a session to a portable JSON format that can be shared or imported.
//!
//! Features:
//! - Export format version field for backward compatibility (Issue #3079)
//! - Compression option for large exports (Issue #3080)

use anyhow::{Context, Result, bail};
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::PathBuf;

use cortex_engine::list_sessions;
use cortex_engine::rollout::get_rollout_path;
use cortex_engine::rollout::reader::{RolloutItem, get_session_meta, read_rollout};
use cortex_protocol::{ConversationId, EventMsg};

/// Current export format version (Issue #3079).
/// Increment this when making breaking changes to the export format.
pub const EXPORT_FORMAT_VERSION: u32 = 2;

/// Export format for sessions.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum ExportFormat {
Expand Down Expand Up @@ -42,19 +51,51 @@ pub struct ExportCommand {
/// Pretty-print the output (for json/yaml)
#[arg(long, default_value_t = true)]
pub pretty: bool,

/// Compress output using gzip (Issue #3080)
/// Automatically adds .gz extension to output file if not present.
#[arg(long, short = 'z')]
pub compress: bool,

/// Include format version metadata in export (Issue #3079)
/// This helps with backward compatibility checking during import.
#[arg(long, default_value_t = true)]
pub include_version: bool,
}

/// Portable session export format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionExport {
/// Export format version.
/// Export format version (Issue #3079).
/// Version history:
/// - v1: Initial format
/// - v2: Added format_version_info field with detailed version metadata
pub version: u32,
/// Detailed format version information (Issue #3079).
#[serde(skip_serializing_if = "Option::is_none")]
pub format_version_info: Option<FormatVersionInfo>,
/// Session metadata.
pub session: SessionMetadata,
/// Conversation messages.
pub messages: Vec<ExportMessage>,
}

/// Detailed format version information for backward compatibility (Issue #3079).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormatVersionInfo {
/// Format version number.
pub version: u32,
/// Minimum compatible version for import.
pub min_compatible_version: u32,
/// CLI version that created this export.
pub cli_version: String,
/// Export timestamp (ISO 8601).
pub exported_at: String,
/// Optional description of format changes.
#[serde(skip_serializing_if = "Option::is_none")]
pub version_notes: Option<String>,
}

/// Session metadata in export format.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetadata {
Expand Down Expand Up @@ -192,9 +233,23 @@ impl ExportCommand {
},
};

// Build format version info (Issue #3079)
let format_version_info = if self.include_version {
Some(FormatVersionInfo {
version: EXPORT_FORMAT_VERSION,
min_compatible_version: 1, // Can import v1 exports
cli_version: env!("CARGO_PKG_VERSION").to_string(),
exported_at: chrono::Utc::now().to_rfc3339(),
version_notes: Some("v2: Added detailed format version metadata".to_string()),
})
} else {
None
};

// Build export
let export = SessionExport {
version: 1,
version: EXPORT_FORMAT_VERSION,
format_version_info,
session: session_meta,
messages: messages.clone(),
};
Expand Down Expand Up @@ -225,14 +280,58 @@ impl ExportCommand {
}
};

// Write to output
// Write to output (with optional compression - Issue #3080)
match self.output {
Some(path) => {
std::fs::write(&path, &output_content)
.with_context(|| format!("Failed to write to: {}", path.display()))?;
eprintln!("Exported session to: {}", path.display());
if self.compress {
// Issue #3080: Compress with gzip
let output_path = if path.extension().map_or(true, |ext| ext != "gz") {
// Add .gz extension if not present
path.with_extension(format!(
"{}.gz",
path.extension().map_or("", |e| e.to_str().unwrap_or(""))
))
} else {
path.clone()
};

let file = std::fs::File::create(&output_path).with_context(|| {
format!("Failed to create file: {}", output_path.display())
})?;
let mut encoder =
flate2::write::GzEncoder::new(file, flate2::Compression::default());
encoder
.write_all(output_content.as_bytes())
.with_context(|| "Failed to write compressed data")?;
encoder
.finish()
.with_context(|| "Failed to finalize compression")?;

let original_size = output_content.len();
let compressed_size = std::fs::metadata(&output_path)?.len() as usize;
let ratio = if original_size > 0 {
(1.0 - (compressed_size as f64 / original_size as f64)) * 100.0
} else {
0.0
};

eprintln!(
"Exported session to: {} (compressed, {:.1}% smaller)",
output_path.display(),
ratio
);
} else {
std::fs::write(&path, &output_content)
.with_context(|| format!("Failed to write to: {}", path.display()))?;
eprintln!("Exported session to: {}", path.display());
}
}
None => {
if self.compress {
eprintln!(
"Warning: Compression is only supported when writing to a file. Use -o/--output."
);
}
println!("{output_content}");
}
}
Expand Down Expand Up @@ -383,7 +482,14 @@ mod tests {
#[test]
fn test_session_export_serialization() {
let export = SessionExport {
version: 1,
version: EXPORT_FORMAT_VERSION,
format_version_info: Some(FormatVersionInfo {
version: EXPORT_FORMAT_VERSION,
min_compatible_version: 1,
cli_version: "test".to_string(),
exported_at: "2024-01-01T00:00:00Z".to_string(),
version_notes: None,
}),
session: SessionMetadata {
id: "test-id".to_string(),
title: Some("Test Session".to_string()),
Expand Down
108 changes: 105 additions & 3 deletions cortex-cli/src/import_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ pub struct ImportCommand {
/// Resume the imported session after import
#[arg(long, default_value_t = false)]
pub resume: bool,

/// Merge imported session with an existing session instead of creating new (Issue #3078)
/// When specified, messages from the import will be appended to the target session.
#[arg(long, value_name = "SESSION_ID")]
pub merge_into: Option<String>,
}

impl ImportCommand {
Expand Down Expand Up @@ -107,14 +112,27 @@ impl ImportCommand {
)
})?;

// Validate version
if export.version != 1 {
// Validate version (Issue #3079: Support version checking)
// We support versions 1 and 2 (current version adds format_version_info)
if export.version > 2 {
bail!(
"Unsupported export version: {}. This CLI supports version 1.",
"Unsupported export version: {}. This CLI supports versions 1-2. \
Please upgrade Cortex CLI to import this session.",
export.version
);
}

// Check format_version_info if present for more detailed compatibility
if let Some(ref version_info) = export.format_version_info {
if version_info.min_compatible_version > 2 {
bail!(
"Export requires minimum version {} but this CLI supports up to version 2. \
Please upgrade Cortex CLI.",
version_info.min_compatible_version
);
}
}

// Validate all messages, including base64 content
validate_export_messages(&export.messages)?;

Expand All @@ -137,6 +155,13 @@ impl ImportCommand {
eprintln!();
}

// Issue #3078: Handle merge mode if specified
if let Some(merge_target) = &self.merge_into {
return self
.merge_into_session(&cortex_home, merge_target, &export)
.await;
}

// Generate a new session ID (we always create a new session on import)
let new_conversation_id = ConversationId::new();

Expand Down Expand Up @@ -235,6 +260,83 @@ impl ImportCommand {

Ok(())
}

/// Issue #3078: Merge imported session into an existing session.
/// This appends messages from the import to the target session instead of creating a new one.
async fn merge_into_session(
&self,
cortex_home: &PathBuf,
target_session_id: &str,
export: &SessionExport,
) -> Result<()> {
use cortex_engine::list_sessions;

// Find the target session
let sessions = list_sessions(cortex_home)?;
let target = sessions
.iter()
.find(|s| s.id == target_session_id || s.id.starts_with(target_session_id));

let target_session = match target {
Some(s) => s,
None => bail!(
"Target session '{}' not found. Use 'cortex sessions' to list available sessions.",
target_session_id
),
};

let target_id: ConversationId = target_session
.id
.parse()
.map_err(|_| anyhow::anyhow!("Invalid session ID format"))?;

// Get the rollout path for the target session
let rollout_path = get_rollout_path(cortex_home, &target_id);
if !rollout_path.exists() {
bail!(
"Target session rollout file not found: {}",
rollout_path.display()
);
}

// Create a recorder to append to the existing session
let mut recorder = RolloutRecorder::new(cortex_home, target_id)?;

// Determine cwd from target session or import
let cwd = export
.session
.cwd
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());

// Calculate starting turn_id based on existing messages
// We start from a high number to avoid conflicts
let mut turn_id = (target_session.message_count as u64) + 1000;

// Record messages as events
let merged_count = export.messages.len();
for message in &export.messages {
let event = message_to_event(message, &mut turn_id, &cwd)?;
recorder.record_event(&event)?;
}

recorder.flush()?;

print_success(&format!(
"Merged {} messages into session: {}",
merged_count,
&target_session.id[..8.min(target_session.id.len())]
));
println!(" Source: {}", export.session.id);
if let Some(title) = &export.session.title {
println!(" Source Title: {title}");
}
println!(" Messages merged: {}", merged_count);
println!("\nTo resume: cortex resume {}", target_session.id);

Ok(())
}
}

/// Fetch content from a URL.
Expand Down
Loading