Skip to content
Draft
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
4 changes: 4 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ pub enum ConfirmAction {
Kill,
/// Quit the dashboard while leaving EA sessions and persisted state intact.
Quit,
/// Quit OMAR and reset persisted runtime state.
ResetQuit,
/// Delete the currently active EA (blocked only if it is the last one)
DeleteEa,
}
Expand Down Expand Up @@ -88,6 +90,7 @@ pub struct App {
pub selected: usize,
pub manager_selected: bool,
pub should_quit: bool,
pub reset_on_quit: bool,
pub show_help: bool,
pub pending_confirm: Option<ConfirmAction>,
pub filter: String,
Expand Down Expand Up @@ -186,6 +189,7 @@ impl App {
selected: 0,
manager_selected: true,
should_quit: false,
reset_on_quit: false,
show_help: false,
pending_confirm: None,
filter: String::new(),
Expand Down
29 changes: 0 additions & 29 deletions src/ea.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,16 +261,6 @@ pub fn unregister_ea(base_dir: &Path, ea_id: EaId) -> anyhow::Result<()> {
save_registry(base_dir, &eas)
}

/// Compact the ID counter on clean quit.
/// Resets ea_next_id to max(registered_ids) so the next session starts
/// numbering from a compact point rather than the old high-water mark.
/// Safe to call only on graceful exit — not during crash recovery.
pub fn compact_id_counter(base_dir: &Path) -> anyhow::Result<()> {
let eas = load_registry(base_dir);
let max_id = eas.iter().map(|e| e.id).max().unwrap_or(0);
save_next_id_counter(base_dir, max_id)
}

fn save_registry(base_dir: &Path, eas: &[EaInfo]) -> anyhow::Result<()> {
let path = base_dir.join("eas.json");
fs::create_dir_all(base_dir).ok();
Expand Down Expand Up @@ -575,25 +565,6 @@ mod tests {
assert_eq!(id4, 4, "ID should be 4 (monotonic), not a reused ID");
}

#[test]
fn test_compact_id_counter() {
let dir = tempfile::tempdir().unwrap();
// Create EAs 1, 2, 3; delete 2 and 3
let _id1 = register_ea(dir.path(), "Alpha", None).unwrap();
let id2 = register_ea(dir.path(), "Beta", None).unwrap();
let id3 = register_ea(dir.path(), "Gamma", None).unwrap();
unregister_ea(dir.path(), id2).unwrap();
unregister_ea(dir.path(), id3).unwrap();
// Counter is at 3 (high-water mark)
assert_eq!(load_next_id_counter(dir.path()), 3);
// Compact: counter should drop to max registered (which is 1)
compact_id_counter(dir.path()).unwrap();
assert_eq!(load_next_id_counter(dir.path()), 1);
// Next register should get ID 2, not 4
let id_next = register_ea(dir.path(), "Delta", None).unwrap();
assert_eq!(id_next, 2);
}

#[test]
fn test_ids_monotonic_without_counter_file() {
// If the counter file is missing (e.g., upgraded from old version),
Expand Down
225 changes: 213 additions & 12 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,7 @@ async fn run_dashboard(config: Config) -> Result<()> {
continue;
}

// Handle confirmation dialog (kill, quit, or delete EA)
// Handle confirmation dialog (kill, quit, reset quit, or delete EA)
if let Some(action) = app.pending_confirm {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => match action {
Expand All @@ -1045,6 +1045,11 @@ async fn run_dashboard(config: Config) -> Result<()> {
}
}
app::ConfirmAction::Quit => {
app.reset_on_quit = false;
app.should_quit = true;
}
app::ConfirmAction::ResetQuit => {
app.reset_on_quit = true;
app.should_quit = true;
}
app::ConfirmAction::DeleteEa => {
Expand Down Expand Up @@ -1172,7 +1177,7 @@ async fn run_dashboard(config: Config) -> Result<()> {
// Normal key handling
match key.code {
KeyCode::Char('Q') => {
app.pending_confirm = Some(app::ConfirmAction::Quit);
app.pending_confirm = Some(app::ConfirmAction::ResetQuit);
}
KeyCode::Esc => {
app.drill_up();
Expand Down Expand Up @@ -1221,9 +1226,6 @@ async fn run_dashboard(config: Config) -> Result<()> {
KeyCode::Char('[') => {
app.cycle_previous_ea();
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.pending_confirm = Some(app::ConfirmAction::Quit);
}
KeyCode::Char('j') | KeyCode::Down => {
if app.sidebar_focused {
app.sidebar_next();
Expand Down Expand Up @@ -1471,19 +1473,15 @@ async fn run_dashboard(config: Config) -> Result<()> {
}

// Check quit flag
let should_quit = {
let quit_request = {
let app = shared_app.lock().await;
if app.should_quit {
Some(app.omar_dir.clone())
Some((app.omar_dir.clone(), app.reset_on_quit))
} else {
None
}
};
if let Some(omar_dir) = should_quit {
// Compact EA ID counter on clean quit so next session starts from a compact point
if let Err(e) = ea::compact_id_counter(&omar_dir) {
eprintln!("compact_id_counter failed: {}", e);
}
if quit_request.is_some() {
break;
}
}
Expand Down Expand Up @@ -1521,13 +1519,216 @@ async fn run_dashboard(config: Config) -> Result<()> {
kill_child_gracefully(child, Duration::from_secs(3));
}

let reset_on_quit = {
let app = shared_app.lock().await;
app.reset_on_quit
};
if reset_on_quit {
let omar_dir = {
let app = shared_app.lock().await;
app.omar_dir.clone()
};
purge_persisted_runtime_state_on_quit(&omar_dir)?;
}

Ok(())
}

fn purge_persisted_runtime_state_on_quit(omar_dir: &std::path::Path) -> io::Result<()> {
archive_action_logs(omar_dir)?;
archive_manager_notes(omar_dir)?;

for file in [
"eas.json",
"eas.json.tmp",
"active_ea",
"ea_next_id",
"scheduled_events.json",
"scheduled_events.lock",
"scheduled_events.tmp",
] {
remove_file_if_exists(&omar_dir.join(file))?;
}

remove_dir_if_exists(&omar_dir.join("ea"))?;
remove_dir_if_exists(&omar_dir.join("mcp"))?;

Ok(())
}

fn archive_action_logs(omar_dir: &std::path::Path) -> io::Result<()> {
let ea_dir = omar_dir.join("ea");
if !ea_dir.exists() {
return Ok(());
}

let archive_dir = omar_dir.join("logs").join("action_logs");
std::fs::create_dir_all(&archive_dir)?;

for entry in std::fs::read_dir(ea_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}

let ea_id = entry.file_name().to_string_lossy().into_owned();
let source = entry.path().join("action_log.jsonl");
if !source.exists() {
continue;
}

let archive_path =
unique_archive_path(&archive_dir, &format!("ea-{}-{}", ea_id, now_ns()), "jsonl");
std::fs::rename(source, archive_path)?;
}

Ok(())
}

fn archive_manager_notes(omar_dir: &std::path::Path) -> io::Result<()> {
let archive_dir = omar_dir.join("logs").join("manager_notes");
std::fs::create_dir_all(&archive_dir)?;

let prefix = "manager_notes_ea";
for entry in std::fs::read_dir(omar_dir)? {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}

let file_name = entry.file_name().to_string_lossy().into_owned();
if !file_name.starts_with(prefix) || !file_name.ends_with(".md") {
continue;
}

let ea_id = file_name
.strip_prefix(prefix)
.and_then(|name| name.strip_suffix(".md"))
.unwrap_or("unknown");
let archive_path = unique_archive_path(
&archive_dir,
&format!("manager_notes_ea{}-{}", ea_id, now_ns()),
"md",
);
std::fs::rename(entry.path(), archive_path)?;
}

Ok(())
}

fn unique_archive_path(dir: &std::path::Path, stem: &str, extension: &str) -> PathBuf {
let mut candidate = dir.join(format!("{}.{}", stem, extension));
let mut attempt = 1;
while candidate.exists() {
candidate = dir.join(format!("{}-{}.{}", stem, attempt, extension));
attempt += 1;
}
candidate
}

fn remove_file_if_exists(path: &std::path::Path) -> io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}

fn remove_dir_if_exists(path: &std::path::Path) -> io::Result<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn purge_persisted_runtime_state_archives_logs_and_notes() {
let dir = tempfile::tempdir().unwrap();
let omar_dir = dir.path();

std::fs::create_dir_all(omar_dir.join("ea/7")).unwrap();
std::fs::create_dir_all(omar_dir.join("mcp")).unwrap();
std::fs::create_dir_all(omar_dir.join("slack_outbox")).unwrap();
std::fs::create_dir_all(omar_dir.join("logs/panics")).unwrap();

std::fs::write(omar_dir.join("config.toml"), "keep config").unwrap();
std::fs::write(omar_dir.join("eas.json"), "wipe registry").unwrap();
std::fs::write(omar_dir.join("eas.json.tmp"), "wipe registry tmp").unwrap();
std::fs::write(omar_dir.join("active_ea"), "7").unwrap();
std::fs::write(omar_dir.join("ea_next_id"), "8").unwrap();
std::fs::write(omar_dir.join("scheduled_events.json"), "wipe events").unwrap();
std::fs::write(omar_dir.join("scheduled_events.lock"), "wipe lock").unwrap();
std::fs::write(omar_dir.join("scheduled_events.tmp"), "wipe tmp").unwrap();
std::fs::write(omar_dir.join("ea/7/action_log.jsonl"), "keep action\n").unwrap();
std::fs::write(omar_dir.join("ea/7/tasks.md"), "wipe task").unwrap();
std::fs::write(omar_dir.join("manager_notes_ea7.md"), "keep notes\n").unwrap();
std::fs::write(omar_dir.join("mcp/state.json"), "wipe mcp").unwrap();
std::fs::write(omar_dir.join("slack_outbox/keep"), "keep slack").unwrap();
std::fs::write(omar_dir.join("logs/panics/keep.log"), "keep panic").unwrap();

purge_persisted_runtime_state_on_quit(omar_dir).unwrap();

for wiped in [
"eas.json",
"eas.json.tmp",
"active_ea",
"ea_next_id",
"scheduled_events.json",
"scheduled_events.lock",
"scheduled_events.tmp",
"ea",
"mcp",
"manager_notes_ea7.md",
] {
assert!(
!omar_dir.join(wiped).exists(),
"{wiped} should be removed by Q reset"
);
}

for kept in ["config.toml", "slack_outbox/keep", "logs/panics/keep.log"] {
assert!(
omar_dir.join(kept).exists(),
"{kept} should survive Q reset"
);
}

let action_logs: Vec<_> = std::fs::read_dir(omar_dir.join("logs/action_logs"))
.unwrap()
.map(|entry| entry.unwrap().path())
.collect();
assert_eq!(action_logs.len(), 1);
assert_eq!(
std::fs::read_to_string(&action_logs[0]).unwrap(),
"keep action\n"
);
assert!(action_logs[0]
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("ea-7-"));

let manager_notes: Vec<_> = std::fs::read_dir(omar_dir.join("logs/manager_notes"))
.unwrap()
.map(|entry| entry.unwrap().path())
.collect();
assert_eq!(manager_notes.len(), 1);
assert_eq!(
std::fs::read_to_string(&manager_notes[0]).unwrap(),
"keep notes\n"
);
assert!(manager_notes[0]
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("manager_notes_ea7-"));
}

/// Regression: `extended-keys always` forces tmux to emit modify-other-keys
/// sequences to every client, including omar's dashboard, which doesn't
/// push the kitty flag. Crossterm can't parse those sequences and silently
Expand Down
9 changes: 8 additions & 1 deletion src/ui/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1260,7 +1260,7 @@ fn render_help_popup(frame: &mut Frame) {
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(" Q Quit"),
Line::from(" Q Quit and reset runtime state"),
Line::from(" ←/→, h/l Switch panel (sidebar ↔ main)"),
Line::from(" ↑/↓, j/k Move selection up/down"),
Line::from(" Tab Drill into selected agent"),
Expand Down Expand Up @@ -1308,6 +1308,13 @@ fn render_confirm_dialog(frame: &mut Frame, app: &App, action: ConfirmAction) {
(" Confirm ", "Kill this agent?", name, String::new(), 40)
}
ConfirmAction::Quit => (
" Confirm Quit ",
"Quit omar?",
"This will kill ALL EA sessions and agents.".to_string(),
"Persisted EA state, projects, notes, and events will be kept.".to_string(),
62,
),
ConfirmAction::ResetQuit => (
" Confirm Quit ",
"Quit omar?",
"This will kill ALL EA sessions and agents.".to_string(),
Expand Down
Loading
Loading