Skip to content

Commit fd2c2b7

Browse files
fix(delete/codex): also remove SQLite threads row so deletes survive a rescan (#30)
Since the Codex scanner switched to reading from `state_*.sqlite` as its primary source, deleting a Codex session from the TUI removed only the rollout JSONL and the per-prompt entries in `~/.codex/history.jsonl`. The `threads` row stayed behind, so the next scan repopulated the session and it reappeared in the listing. `delete_codex_session` now also runs `DELETE FROM threads WHERE id = ?1` against every `state_*.sqlite` in `~/.codex/`. Walking every state db (rather than just the latest one) handles users who upgraded Codex across schema versions and still have older `state_N.sqlite` files around — a stale row in any of them would otherwise resurrect the session. Errors on a single db (open failure, missing `threads` table on older Codex CLI versions) are swallowed so one bad file cannot block the delete on the others. Tests cover the four relevant cases: target-only removal, multi-db walk, ignoring non-`state_*` siblings, and the missing-table fallback.
1 parent 53cdd47 commit fd2c2b7

1 file changed

Lines changed: 137 additions & 5 deletions

File tree

‎src/delete.rs‎

Lines changed: 137 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,19 +110,29 @@ fn remove_dirs_matching_name(base: &Path, name: &str) -> Result<(), io::Error> {
110110
// Codex
111111
// ---------------------------------------------------------------------------
112112

113-
/// Codex session files live under `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`.
114-
/// We find the file whose first line's `payload.id` matches and delete it.
115-
/// We also rewrite `~/.codex/history.jsonl` excluding matching `session_id` entries.
113+
/// Codex session data lives in three places (any combination may be present
114+
/// depending on Codex CLI version):
115+
/// * `state_*.sqlite` `threads` row (primary source for current Codex CLI),
116+
/// * `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` rollout file,
117+
/// * `~/.codex/history.jsonl` per-prompt summary entries.
118+
///
119+
/// We delete from all three so the session does not reappear after the next
120+
/// `agf` scan via the SQLite path.
116121
fn delete_codex_session(session: &Session) -> Result<(), io::Error> {
117122
let codex_dir = config::codex_dir().map_err(io::Error::other)?;
118123

119-
// 1. Find and delete the session rollout file
124+
// 1. Delete the SQLite row(s). Codex may have multiple `state_*.sqlite`
125+
// files (e.g. across CLI upgrades); remove the row from every one
126+
// that contains it so the session cannot be revived from a stale db.
127+
delete_codex_sqlite_rows(&codex_dir, &session.session_id)?;
128+
129+
// 2. Find and delete the session rollout file
120130
let sessions_dir = codex_dir.join("sessions");
121131
if sessions_dir.exists() {
122132
delete_codex_session_file(&sessions_dir, &session.session_id)?;
123133
}
124134

125-
// 2. Rewrite history.jsonl excluding lines with matching session_id
135+
// 3. Rewrite history.jsonl excluding lines with matching session_id
126136
let history_path = codex_dir.join("history.jsonl");
127137
if history_path.exists() {
128138
rewrite_jsonl_excluding(&history_path, "session_id", &session.session_id)?;
@@ -131,6 +141,36 @@ fn delete_codex_session(session: &Session) -> Result<(), io::Error> {
131141
Ok(())
132142
}
133143

144+
/// Remove the `threads` row matching `session_id` from every
145+
/// `state_*.sqlite` in `codex_dir`. Missing tables / open errors on a single
146+
/// file are swallowed so one corrupt or older-schema db cannot block the
147+
/// delete on the others.
148+
fn delete_codex_sqlite_rows(codex_dir: &Path, session_id: &str) -> Result<(), io::Error> {
149+
let entries = match fs::read_dir(codex_dir) {
150+
Ok(e) => e,
151+
Err(_) => return Ok(()),
152+
};
153+
for entry in entries.filter_map(|e| e.ok()) {
154+
let path = entry.path();
155+
let is_state_db = path
156+
.file_name()
157+
.and_then(|n| n.to_str())
158+
.map(|n| n.starts_with("state_") && n.ends_with(".sqlite"))
159+
.unwrap_or(false);
160+
if !is_state_db {
161+
continue;
162+
}
163+
let conn = match rusqlite::Connection::open(&path) {
164+
Ok(c) => c,
165+
Err(_) => continue,
166+
};
167+
// `threads` is the only table the Codex scanner reads from. Older
168+
// Codex CLI versions may not have this table — ignore the error.
169+
let _ = conn.execute("DELETE FROM threads WHERE id = ?1", [session_id]);
170+
}
171+
Ok(())
172+
}
173+
134174
/// Find and delete the Codex rollout JSONL file matching the given session ID.
135175
fn delete_codex_session_file(sessions_dir: &Path, session_id: &str) -> Result<(), io::Error> {
136176
for entry in WalkDir::new(sessions_dir)
@@ -355,3 +395,95 @@ fn delete_gemini_session(session: &Session) -> Result<(), io::Error> {
355395

356396
Ok(())
357397
}
398+
399+
#[cfg(test)]
400+
mod tests {
401+
use super::*;
402+
403+
fn make_codex_dir(name: &str) -> std::path::PathBuf {
404+
let dir = std::env::temp_dir().join(name);
405+
let _ = fs::remove_dir_all(&dir);
406+
fs::create_dir_all(&dir).unwrap();
407+
dir
408+
}
409+
410+
/// Create a minimal `state_*.sqlite` mirroring the Codex schema fields
411+
/// the scanner reads, then seed two `threads` rows.
412+
fn seed_state_db(path: &Path, ids: &[&str]) {
413+
let conn = rusqlite::Connection::open(path).unwrap();
414+
conn.execute_batch(
415+
"CREATE TABLE threads (
416+
id TEXT PRIMARY KEY,
417+
cwd TEXT NOT NULL,
418+
title TEXT NOT NULL DEFAULT '',
419+
updated_at INTEGER NOT NULL DEFAULT 0,
420+
archived INTEGER NOT NULL DEFAULT 0,
421+
git_branch TEXT,
422+
first_user_message TEXT NOT NULL DEFAULT ''
423+
);",
424+
)
425+
.unwrap();
426+
for id in ids {
427+
conn.execute("INSERT INTO threads (id, cwd) VALUES (?1, '/tmp/x')", [id])
428+
.unwrap();
429+
}
430+
}
431+
432+
fn count_thread(path: &Path, id: &str) -> i64 {
433+
let conn = rusqlite::Connection::open(path).unwrap();
434+
conn.query_row("SELECT COUNT(*) FROM threads WHERE id = ?1", [id], |row| {
435+
row.get(0)
436+
})
437+
.unwrap()
438+
}
439+
440+
#[test]
441+
fn delete_codex_sqlite_rows_removes_target_only() {
442+
let dir = make_codex_dir("agf-test-codex-delete-target");
443+
let db = dir.join("state_5.sqlite");
444+
seed_state_db(&db, &["target-id", "keep-id"]);
445+
446+
delete_codex_sqlite_rows(&dir, "target-id").unwrap();
447+
448+
assert_eq!(count_thread(&db, "target-id"), 0);
449+
assert_eq!(count_thread(&db, "keep-id"), 1);
450+
}
451+
452+
#[test]
453+
fn delete_codex_sqlite_rows_walks_every_state_db() {
454+
let dir = make_codex_dir("agf-test-codex-delete-multi");
455+
let db4 = dir.join("state_4.sqlite");
456+
let db5 = dir.join("state_5.sqlite");
457+
seed_state_db(&db4, &["dup-id"]);
458+
seed_state_db(&db5, &["dup-id"]);
459+
460+
delete_codex_sqlite_rows(&dir, "dup-id").unwrap();
461+
462+
assert_eq!(count_thread(&db4, "dup-id"), 0);
463+
assert_eq!(count_thread(&db5, "dup-id"), 0);
464+
}
465+
466+
#[test]
467+
fn delete_codex_sqlite_rows_ignores_non_state_files() {
468+
let dir = make_codex_dir("agf-test-codex-delete-ignore");
469+
// A non-matching file must not crash the walk.
470+
fs::write(dir.join("history.jsonl"), b"").unwrap();
471+
let db = dir.join("state_1.sqlite");
472+
seed_state_db(&db, &["a"]);
473+
474+
delete_codex_sqlite_rows(&dir, "a").unwrap();
475+
assert_eq!(count_thread(&db, "a"), 0);
476+
}
477+
478+
#[test]
479+
fn delete_codex_sqlite_rows_tolerates_missing_threads_table() {
480+
let dir = make_codex_dir("agf-test-codex-delete-missing-table");
481+
let db = dir.join("state_0.sqlite");
482+
// Empty db (no `threads` table) — Codex versions without the table
483+
// must not abort the delete.
484+
rusqlite::Connection::open(&db).unwrap();
485+
486+
// Should swallow the "no such table: threads" error.
487+
delete_codex_sqlite_rows(&dir, "anything").unwrap();
488+
}
489+
}

0 commit comments

Comments
 (0)