Skip to content

Commit f29b3c2

Browse files
committed
fix(cli): add session cleanup commands (delete and prune)
Fixes bounty issue #1656 Added new subcommands to the sessions command: - `sessions list` - list sessions (default behavior, backwards compatible) - `sessions delete <id>` - delete a specific session by ID - `sessions prune --older-than <days>` - delete old sessions Features: - Partial session ID matching (first 8 chars are sufficient) - Confirmation prompts with --force flag to skip - Dry-run mode for prune command - Support for --all flag on list subcommand Also fixed a pre-existing compilation error in snapshot.rs where `permissions` was destructured as `_` but later used.
1 parent 8f839ec commit f29b3c2

2 files changed

Lines changed: 232 additions & 8 deletions

File tree

cortex-cli/src/main.rs

Lines changed: 229 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -298,14 +298,67 @@ struct ResumeCommand {
298298
config_overrides: CliConfigOverrides,
299299
}
300300

301-
/// Sessions command.
301+
/// Sessions command with subcommands.
302302
#[derive(Args)]
303303
struct SessionsCommand {
304+
#[command(subcommand)]
305+
action: Option<SessionsSubcommand>,
306+
307+
/// Show all sessions including from other directories (for list subcommand)
308+
#[arg(long)]
309+
all: bool,
310+
}
311+
312+
/// Sessions subcommands.
313+
#[derive(Subcommand)]
314+
enum SessionsSubcommand {
315+
/// List all sessions (default if no subcommand given)
316+
List(SessionsListArgs),
317+
318+
/// Delete a specific session by ID
319+
#[command(visible_alias = "rm")]
320+
Delete(SessionsDeleteArgs),
321+
322+
/// Delete old sessions (garbage collection)
323+
#[command(visible_alias = "gc")]
324+
Prune(SessionsPruneArgs),
325+
}
326+
327+
/// Arguments for sessions list subcommand.
328+
#[derive(Args)]
329+
struct SessionsListArgs {
304330
/// Show all sessions including from other directories
305331
#[arg(long)]
306332
all: bool,
307333
}
308334

335+
/// Arguments for sessions delete subcommand.
336+
#[derive(Args)]
337+
struct SessionsDeleteArgs {
338+
/// Session ID to delete (first 8 characters are sufficient)
339+
session_id: String,
340+
341+
/// Skip confirmation prompt
342+
#[arg(short, long)]
343+
force: bool,
344+
}
345+
346+
/// Arguments for sessions prune subcommand.
347+
#[derive(Args)]
348+
struct SessionsPruneArgs {
349+
/// Delete sessions older than this many days
350+
#[arg(long, default_value = "30")]
351+
older_than: u64,
352+
353+
/// Skip confirmation prompt
354+
#[arg(short, long)]
355+
force: bool,
356+
357+
/// Show what would be deleted without actually deleting
358+
#[arg(long)]
359+
dry_run: bool,
360+
}
361+
309362
/// Config command.
310363
#[derive(Args)]
311364
struct ConfigCommand {
@@ -478,7 +531,7 @@ async fn main() -> Result<()> {
478531
}
479532
},
480533
Some(Commands::Resume(resume_cli)) => run_resume(resume_cli).await,
481-
Some(Commands::Sessions(sessions_cli)) => list_sessions(sessions_cli.all).await,
534+
Some(Commands::Sessions(sessions_cli)) => run_sessions(sessions_cli).await,
482535
Some(Commands::Export(export_cli)) => export_cli.run().await,
483536
Some(Commands::Import(import_cli)) => import_cli.run().await,
484537
Some(Commands::Config(config_cli)) => show_config(config_cli).await,
@@ -676,6 +729,19 @@ async fn run_resume(resume_cli: ResumeCommand) -> Result<()> {
676729
Ok(())
677730
}
678731

732+
/// Handle sessions command with subcommands.
733+
async fn run_sessions(sessions_cli: SessionsCommand) -> Result<()> {
734+
match sessions_cli.action {
735+
None => {
736+
// Default: list sessions (backwards compatible)
737+
list_sessions(sessions_cli.all).await
738+
}
739+
Some(SessionsSubcommand::List(args)) => list_sessions(args.all).await,
740+
Some(SessionsSubcommand::Delete(args)) => delete_session(args).await,
741+
Some(SessionsSubcommand::Prune(args)) => prune_sessions(args).await,
742+
}
743+
}
744+
679745
async fn list_sessions(show_all: bool) -> Result<()> {
680746
let config = cortex_engine::Config::default();
681747
let sessions = cortex_engine::list_sessions(&config.cortex_home)?;
@@ -741,6 +807,162 @@ async fn list_sessions(show_all: bool) -> Result<()> {
741807
if !show_all {
742808
println!(" Cortex sessions --all (show all directories)");
743809
}
810+
println!("\nTo delete: Cortex sessions delete <session-id>");
811+
println!("To cleanup: Cortex sessions prune --older-than 30");
812+
Ok(())
813+
}
814+
815+
/// Delete a specific session by ID.
816+
async fn delete_session(args: SessionsDeleteArgs) -> Result<()> {
817+
use std::io::{BufRead, Write};
818+
819+
let config = cortex_engine::Config::default();
820+
let sessions = cortex_engine::list_sessions(&config.cortex_home)?;
821+
822+
// Find the session (support partial ID match)
823+
let session = sessions
824+
.iter()
825+
.find(|s| s.id.starts_with(&args.session_id) || s.id == args.session_id);
826+
827+
let session = match session {
828+
Some(s) => s,
829+
None => {
830+
eprintln!("No session found matching '{}'", args.session_id);
831+
eprintln!("\nUse 'Cortex sessions' to list available sessions.");
832+
return Ok(());
833+
}
834+
};
835+
836+
// Confirm deletion unless --force is used
837+
if !args.force {
838+
let date = if session.timestamp.len() >= 19 {
839+
session.timestamp[..19].replace('T', " ")
840+
} else {
841+
session.timestamp.clone()
842+
};
843+
println!("Session to delete:");
844+
println!(" ID: {}", session.id);
845+
println!(" Date: {}", date);
846+
println!(" Messages: {}", session.message_count);
847+
println!(" CWD: {}", session.cwd.display());
848+
println!();
849+
print!("Delete this session? [y/N]: ");
850+
io::stdout().flush()?;
851+
852+
let mut input = String::new();
853+
io::stdin().lock().read_line(&mut input)?;
854+
855+
if !input.trim().eq_ignore_ascii_case("y") {
856+
println!("Cancelled.");
857+
return Ok(());
858+
}
859+
}
860+
861+
// Delete the session files
862+
let sessions_dir = config.cortex_home.join("sessions");
863+
let session_file = sessions_dir.join(format!("{}.jsonl", session.id));
864+
865+
if session_file.exists() {
866+
std::fs::remove_file(&session_file)?;
867+
println!(
868+
"Deleted session: {}",
869+
&session.id[..8.min(session.id.len())]
870+
);
871+
} else {
872+
eprintln!("Session file not found: {}", session_file.display());
873+
}
874+
875+
Ok(())
876+
}
877+
878+
/// Prune (garbage collect) old sessions.
879+
async fn prune_sessions(args: SessionsPruneArgs) -> Result<()> {
880+
use std::io::{BufRead, Write};
881+
882+
let config = cortex_engine::Config::default();
883+
let sessions = cortex_engine::list_sessions(&config.cortex_home)?;
884+
885+
if sessions.is_empty() {
886+
println!("No sessions found.");
887+
return Ok(());
888+
}
889+
890+
// Calculate cutoff date
891+
let cutoff = chrono::Utc::now() - chrono::Duration::days(args.older_than as i64);
892+
let cutoff_str = cutoff.to_rfc3339();
893+
894+
// Find sessions older than cutoff
895+
let to_delete: Vec<_> = sessions
896+
.iter()
897+
.filter(|s| s.timestamp < cutoff_str)
898+
.collect();
899+
900+
if to_delete.is_empty() {
901+
println!("No sessions older than {} days found.", args.older_than);
902+
return Ok(());
903+
}
904+
905+
println!(
906+
"Found {} session(s) older than {} days:",
907+
to_delete.len(),
908+
args.older_than
909+
);
910+
println!("{:-<80}", "");
911+
912+
for session in &to_delete {
913+
let date = if session.timestamp.len() >= 19 {
914+
session.timestamp[..19].replace('T', " ")
915+
} else {
916+
session.timestamp.clone()
917+
};
918+
println!(
919+
" {} | {} | {} msgs",
920+
&session.id[..8.min(session.id.len())],
921+
date,
922+
session.message_count
923+
);
924+
}
925+
println!();
926+
927+
if args.dry_run {
928+
println!("(Dry run - no sessions were deleted)");
929+
return Ok(());
930+
}
931+
932+
// Confirm deletion unless --force is used
933+
if !args.force {
934+
print!("Delete {} session(s)? [y/N]: ", to_delete.len());
935+
io::stdout().flush()?;
936+
937+
let mut input = String::new();
938+
io::stdin().lock().read_line(&mut input)?;
939+
940+
if !input.trim().eq_ignore_ascii_case("y") {
941+
println!("Cancelled.");
942+
return Ok(());
943+
}
944+
}
945+
946+
// Delete the sessions
947+
let sessions_dir = config.cortex_home.join("sessions");
948+
let mut deleted_count = 0;
949+
950+
for session in &to_delete {
951+
let session_file = sessions_dir.join(format!("{}.jsonl", session.id));
952+
if session_file.exists() {
953+
if let Err(e) = std::fs::remove_file(&session_file) {
954+
eprintln!(
955+
"Failed to delete session {}: {}",
956+
&session.id[..8.min(session.id.len())],
957+
e
958+
);
959+
} else {
960+
deleted_count += 1;
961+
}
962+
}
963+
}
964+
965+
println!("Deleted {} session(s).", deleted_count);
744966
Ok(())
745967
}
746968

@@ -860,13 +1082,13 @@ async fn run_serve(serve_cli: ServeCommand) -> Result<()> {
8601082
let result = cortex_app_server::run(config).await;
8611083

8621084
// Stop mDNS advertising on shutdown
863-
if let Some(ref mut mdns) = mdns_service {
864-
if let Err(e) = mdns.stop_advertising().await {
865-
eprintln!("Warning: Failed to stop mDNS advertising: {e}");
866-
}
1085+
if let Some(ref mut mdns) = mdns_service
1086+
&& let Err(e) = mdns.stop_advertising().await
1087+
{
1088+
eprintln!("Warning: Failed to stop mDNS advertising: {e}");
8671089
}
8681090

869-
result.map_err(Into::into)
1091+
result
8701092
}
8711093

8721094
async fn run_servers(servers_cli: ServersCommand) -> Result<()> {

cortex-engine/src/tasks/snapshot.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ impl SnapshotManager {
292292
match state {
293293
FileState::Exists {
294294
content,
295-
permissions: _,
295+
permissions,
296296
..
297297
} => {
298298
// Create parent directories
@@ -309,6 +309,8 @@ impl SnapshotManager {
309309
use std::os::unix::fs::PermissionsExt;
310310
fs::set_permissions(path, std::fs::Permissions::from_mode(*mode))?;
311311
}
312+
#[cfg(not(unix))]
313+
let _ = permissions; // Silence unused warning on non-Unix
312314

313315
result.restored.push(path.clone());
314316
}

0 commit comments

Comments
 (0)