Skip to main content

ytil_agents/agent/session_deletion/
codex.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::fs::File;
4use std::io::BufReader;
5use std::path::Path;
6use std::path::PathBuf;
7
8use rootcause::prelude::ResultExt;
9use rootcause::report;
10
11use super::DeletionPlan;
12use crate::agent::session::SessionKey;
13use crate::agent::session_parser::codex::CodexSessionMetadata;
14
15pub(super) fn build_deletion_plan(
16    root: &Path,
17    key: &SessionKey,
18    selected_path: Option<&Path>,
19) -> rootcause::Result<DeletionPlan> {
20    let root = root
21        .canonicalize()
22        .context("failed to resolve Codex session store")
23        .attach_with(|| format!("path={}", root.display()))?;
24    let selected_path = selected_path
25        .map(|path| canonicalize_selected_path(&root, path))
26        .transpose()?;
27    let session_paths = crate::agent::session_loader::find_session_paths(
28        &root,
29        |entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"),
30        |_| false,
31    )?;
32
33    let (sessions, skipped_paths) = scan_deletion_sessions(&root, session_paths);
34    if let Some(selected_path) = selected_path.as_deref() {
35        validate_selected_path(&sessions, key, selected_path)?;
36    }
37    let children_by_parent = children_by_parent(&sessions);
38    let paths = collect_descendant_paths(key.id(), &children_by_parent, &sessions)?;
39    let related_session_count = paths.len().saturating_sub(1);
40    Ok(DeletionPlan::new(
41        key.clone(),
42        paths,
43        related_session_count,
44        skipped_paths,
45    ))
46}
47
48#[derive(Debug)]
49struct DeletionSession {
50    path: PathBuf,
51    parent_thread_id: Option<String>,
52    is_subagent: bool,
53}
54
55fn scan_deletion_sessions(
56    root: &Path,
57    session_paths: Vec<PathBuf>,
58) -> (HashMap<String, Vec<DeletionSession>>, Vec<PathBuf>) {
59    let mut sessions = HashMap::new();
60    let mut skipped_paths = Vec::new();
61    for session_path in session_paths {
62        let Ok(resolved_path) = session_path.canonicalize() else {
63            skipped_paths.push(session_path);
64            continue;
65        };
66        if !resolved_path.starts_with(root) {
67            skipped_paths.push(session_path);
68            continue;
69        }
70
71        let session_name = session_path
72            .file_stem()
73            .and_then(|name| name.to_str())
74            .unwrap_or_default();
75        let metadata = File::open(&session_path)
76            .map(BufReader::new)
77            .map_err(rootcause::Report::from)
78            .and_then(|file| crate::agent::session_parser::codex::parse_metadata_for_deletion(file, session_name));
79        let Ok(metadata) = metadata else {
80            skipped_paths.push(session_path);
81            continue;
82        };
83        let CodexSessionMetadata {
84            id,
85            parent_thread_id,
86            is_subagent,
87        } = metadata;
88        let deletion_session = DeletionSession {
89            path: resolved_path,
90            parent_thread_id,
91            is_subagent,
92        };
93        sessions.entry(id).or_insert_with(Vec::new).push(deletion_session);
94    }
95    (sessions, skipped_paths)
96}
97
98fn children_by_parent(sessions: &HashMap<String, Vec<DeletionSession>>) -> HashMap<&str, Vec<&str>> {
99    let mut children = HashMap::new();
100    for (session_id, matches) in sessions {
101        for session in matches {
102            if session.is_subagent
103                && let Some(parent_id) = &session.parent_thread_id
104            {
105                children
106                    .entry(parent_id.as_str())
107                    .or_insert_with(Vec::new)
108                    .push(session_id.as_str());
109            }
110        }
111    }
112    children
113}
114
115fn canonicalize_selected_path(root: &Path, selected_path: &Path) -> rootcause::Result<PathBuf> {
116    let selected_path = selected_path
117        .canonicalize()
118        .context("failed to resolve selected Codex session path")
119        .attach_with(|| format!("path={}", selected_path.display()))?;
120    if !selected_path.starts_with(root) {
121        return Err(report!("selected Codex session path is outside the session store")
122            .attach(format!("path={}", selected_path.display()))
123            .attach(format!("root={}", root.display())));
124    }
125    if !selected_path.is_file() || selected_path.extension().is_none_or(|extension| extension != "jsonl") {
126        return Err(report!("selected Codex session path is not a JSONL file")
127            .attach(format!("path={}", selected_path.display())));
128    }
129    Ok(selected_path)
130}
131
132fn validate_selected_path(
133    sessions: &HashMap<String, Vec<DeletionSession>>,
134    key: &SessionKey,
135    selected_path: &Path,
136) -> rootcause::Result<()> {
137    let Some(matches) = sessions.get(key.id()) else {
138        return Err(report!("selected Codex session path does not match the session id")
139            .attach(format!("path={}", selected_path.display()))
140            .attach(format!("session_id={}", key.id())));
141    };
142    if matches.iter().any(|session| session.path == selected_path) {
143        Ok(())
144    } else {
145        Err(report!("selected Codex session path does not match the session id")
146            .attach(format!("path={}", selected_path.display()))
147            .attach(format!("session_id={}", key.id())))
148    }
149}
150
151fn collect_descendant_paths(
152    session_id: &str,
153    children_by_parent: &HashMap<&str, Vec<&str>>,
154    sessions: &HashMap<String, Vec<DeletionSession>>,
155) -> rootcause::Result<Vec<PathBuf>> {
156    let mut paths = Vec::new();
157    let mut complete = HashSet::new();
158    let mut visiting = HashSet::new();
159    let mut steps = vec![(session_id, false)];
160    while let Some(step) = steps.pop() {
161        let (current_id, append) = step;
162        if append {
163            visiting.remove(current_id);
164            complete.insert(current_id);
165            let session = unique_session(current_id, sessions)?;
166            paths.push(session.path.clone());
167            continue;
168        }
169        if complete.contains(current_id) {
170            continue;
171        }
172        if !visiting.insert(current_id) {
173            return Err(report!("cyclic Codex session parent graph").attach(format!("session_id={current_id}")));
174        }
175        unique_session(current_id, sessions)?;
176        steps.push((current_id, true));
177        if let Some(children) = children_by_parent.get(current_id) {
178            for child_id in children {
179                steps.push((child_id, false));
180            }
181        }
182    }
183    Ok(paths)
184}
185
186fn unique_session<'a>(
187    session_id: &str,
188    sessions: &'a HashMap<String, Vec<DeletionSession>>,
189) -> rootcause::Result<&'a DeletionSession> {
190    let Some(matches) = sessions.get(session_id) else {
191        return Err(report!("selected Codex session was not found in the session store")
192            .attach(format!("session_id={session_id}")));
193    };
194    let [session] = matches.as_slice() else {
195        return Err(report!("duplicate Codex session ID in readable descendant closure")
196            .attach(format!("session_id={session_id}")));
197    };
198    Ok(session)
199}
200
201#[cfg(test)]
202mod tests {
203    use std::collections::HashMap;
204
205    use tempfile::tempdir;
206    use test_that::prelude::*;
207
208    use super::*;
209    use crate::agent::Agent;
210
211    #[test]
212    fn test_build_deletion_plan_when_selected_parent_has_nested_children_returns_descendant_first_paths() {
213        let dir = tempdir().expect("tempdir should be created");
214        let root = dir.path().join("sessions");
215        std::fs::create_dir_all(&root).expect("session root should be created");
216        let parent = write_deletion_session(&root, "parent", None, "parent.jsonl");
217        let child = write_deletion_session(&root, "child", Some("parent"), "child.jsonl");
218        let grandchild = write_deletion_session(&root, "grandchild", Some("child"), "grandchild.jsonl");
219        let unrelated = write_deletion_session(&root, "other", None, "other.jsonl");
220        let key = SessionKey::new(Agent::Codex, "parent");
221
222        let plan = build_deletion_plan(&root, &key, None).expect("plan should resolve");
223
224        assert_that!(plan.related_session_count(), eq(2));
225        assert_that!(
226            plan.paths,
227            eq([
228                grandchild.canonicalize().expect("path should resolve"),
229                child.canonicalize().expect("path should resolve"),
230                parent.canonicalize().expect("path should resolve"),
231            ])
232        );
233        assert_that!(plan.paths.contains(&unrelated), eq(false));
234    }
235
236    #[test]
237    fn test_build_deletion_plan_when_parent_id_lacks_subagent_marker_ignores_session() {
238        let dir = tempdir().expect("tempdir should be created");
239        let root = dir.path().join("sessions");
240        std::fs::create_dir_all(&root).expect("session root should be created");
241        let parent = write_deletion_session(&root, "parent", None, "parent.jsonl");
242        let unrelated = write_deletion_session_with_subagent(&root, "child", Some("parent"), "child.jsonl", false);
243        let key = SessionKey::new(Agent::Codex, "parent");
244
245        let plan = build_deletion_plan(&root, &key, None).expect("plan should resolve");
246
247        assert_that!(plan.related_session_count(), eq(0));
248        assert_that!(plan.paths, eq([parent.canonicalize().expect("path should resolve")]));
249        assert_that!(plan.paths.contains(&unrelated), eq(false));
250    }
251
252    #[test]
253    fn test_build_deletion_plan_when_selected_path_metadata_differs_from_key_rejects_path() {
254        let dir = tempdir().expect("tempdir should be created");
255        let root = dir.path().join("sessions");
256        std::fs::create_dir_all(&root).expect("session root should be created");
257        let selected_path = write_deletion_session(&root, "other", None, "selected.jsonl");
258        write_deletion_session(&root, "target", None, "target.jsonl");
259        let key = SessionKey::new(Agent::Codex, "target");
260
261        let result = build_deletion_plan(&root, &key, Some(&selected_path));
262
263        assert_that!(
264            result,
265            err(displays_as(contains_substring(
266                "selected Codex session path does not match the session id"
267            )))
268        );
269    }
270
271    #[test]
272    fn test_collect_descendant_paths_when_tree_is_deep_uses_iterative_post_order() {
273        let depth: usize = 10_000;
274        let mut sessions = HashMap::new();
275        for index in 0..depth {
276            let session_id = format!("session-{index}");
277            let parent_thread_id = (index > 0).then(|| format!("session-{}", index.saturating_sub(1)));
278            sessions.insert(
279                session_id.clone(),
280                vec![DeletionSession {
281                    path: PathBuf::from(&session_id),
282                    parent_thread_id,
283                    is_subagent: index > 0,
284                }],
285            );
286        }
287        let children = children_by_parent(&sessions);
288
289        let paths = collect_descendant_paths("session-0", &children, &sessions).expect("paths should resolve");
290
291        assert_that!(paths.len(), eq(depth));
292        assert_that!(paths.first(), eq(Some(&PathBuf::from("session-9999"))));
293        assert_that!(paths.last(), eq(Some(&PathBuf::from("session-0"))));
294    }
295
296    #[test]
297    fn test_build_deletion_plan_when_store_has_duplicate_ids_returns_error() {
298        let dir = tempdir().expect("tempdir should be created");
299        let root = dir.path().join("sessions");
300        std::fs::create_dir_all(&root).expect("session root should be created");
301        write_deletion_session(&root, "parent", None, "one.jsonl");
302        write_deletion_session(&root, "parent", None, "two.jsonl");
303        let key = SessionKey::new(Agent::Codex, "parent");
304
305        let result = build_deletion_plan(&root, &key, None);
306
307        assert_that!(
308            result,
309            err(displays_as(contains_substring("duplicate Codex session ID")))
310        );
311    }
312
313    #[test]
314    fn test_build_deletion_plan_when_parent_graph_is_cyclic_returns_error() {
315        let dir = tempdir().expect("tempdir should be created");
316        let root = dir.path().join("sessions");
317        std::fs::create_dir_all(&root).expect("session root should be created");
318        write_deletion_session(&root, "one", Some("two"), "one.jsonl");
319        write_deletion_session(&root, "two", Some("one"), "two.jsonl");
320        let key = SessionKey::new(Agent::Codex, "one");
321
322        let result = build_deletion_plan(&root, &key, None);
323
324        assert_that!(
325            result,
326            err(displays_as(contains_substring("cyclic Codex session parent graph")))
327        );
328    }
329
330    #[test]
331    fn test_build_deletion_plan_when_session_metadata_is_missing_skips_the_file() {
332        let dir = tempdir().expect("tempdir should be created");
333        let root = dir.path().join("sessions");
334        std::fs::create_dir_all(&root).expect("session root should be created");
335        let invalid = root.join("invalid.jsonl");
336        std::fs::write(&invalid, "{\"type\":\"other\"}\n").expect("fixture should be written");
337        let parent = write_deletion_session(&root, "parent", None, "parent.jsonl");
338        let key = SessionKey::new(Agent::Codex, "parent");
339
340        let plan = build_deletion_plan(&root, &key, None).expect("plan should resolve");
341
342        assert_that!(plan.paths, eq([parent.canonicalize().expect("path should resolve")]));
343        assert_that!(
344            plan.skipped_paths,
345            eq([invalid.canonicalize().expect("path should resolve")])
346        );
347    }
348
349    #[test]
350    fn test_build_deletion_plan_when_metadata_is_invalid_skips_the_file() {
351        let dir = tempdir().expect("tempdir should be created");
352        let root = dir.path().join("sessions");
353        std::fs::create_dir_all(&root).expect("session root should be created");
354        let invalid = root.join("invalid.jsonl");
355        std::fs::write(&invalid, "not json\n").expect("fixture should be written");
356        let parent = write_deletion_session(&root, "parent", None, "parent.jsonl");
357        let key = SessionKey::new(Agent::Codex, "parent");
358
359        let plan = build_deletion_plan(&root, &key, None).expect("plan should resolve");
360
361        assert_that!(plan.paths, eq([parent.canonicalize().expect("path should resolve")]));
362        assert_that!(
363            plan.skipped_paths,
364            eq([invalid.canonicalize().expect("path should resolve")])
365        );
366    }
367
368    #[test]
369    fn test_build_deletion_plan_when_jsonl_is_malformed_after_metadata_keeps_plan_valid() {
370        let dir = tempdir().expect("tempdir should be created");
371        let root = dir.path().join("sessions");
372        std::fs::create_dir_all(&root).expect("session root should be created");
373        let parent = write_deletion_session(&root, "parent", None, "parent.jsonl");
374        std::fs::write(
375            &parent,
376            format!(
377                "{}not json\n",
378                std::fs::read_to_string(&parent).expect("fixture should be read")
379            ),
380        )
381        .expect("fixture should be updated");
382        let key = SessionKey::new(Agent::Codex, "parent");
383
384        let plan = build_deletion_plan(&root, &key, None).expect("plan should resolve");
385
386        assert_that!(plan.paths, eq([parent.canonicalize().expect("path should resolve")]));
387    }
388
389    #[test]
390    fn test_scan_deletion_sessions_when_path_is_outside_store_skips_the_file() {
391        let dir = tempdir().expect("tempdir should be created");
392        let root = dir.path().join("sessions");
393        std::fs::create_dir_all(&root).expect("session root should be created");
394        let outside = write_deletion_session(dir.path(), "parent", None, "outside.jsonl");
395        let canonical_root = root.canonicalize().expect("root should resolve");
396
397        let (sessions, skipped_paths) = scan_deletion_sessions(&canonical_root, vec![outside.clone()]);
398
399        assert_that!(sessions.is_empty(), eq(true));
400        assert_that!(skipped_paths, eq([outside]));
401    }
402
403    #[test]
404    fn test_scan_deletion_sessions_when_a_session_path_is_unreadable_skips_the_file() {
405        let dir = tempdir().expect("tempdir should be created");
406        let root = dir.path().join("sessions");
407        std::fs::create_dir_all(root.join("unreadable.jsonl")).expect("fixture directory should be created");
408        let canonical_root = root.canonicalize().expect("root should resolve");
409        let unreadable = root.join("unreadable.jsonl");
410
411        let (sessions, skipped_paths) = scan_deletion_sessions(&canonical_root, vec![unreadable.clone()]);
412
413        assert_that!(sessions.is_empty(), eq(true));
414        assert_that!(skipped_paths, eq([unreadable]));
415    }
416
417    fn write_deletion_session(root: &Path, id: &str, parent_id: Option<&str>, filename: &str) -> PathBuf {
418        write_deletion_session_with_subagent(root, id, parent_id, filename, parent_id.is_some())
419    }
420
421    fn write_deletion_session_with_subagent(
422        root: &Path,
423        id: &str,
424        parent_id: Option<&str>,
425        filename: &str,
426        is_subagent: bool,
427    ) -> PathBuf {
428        let path = root.join(filename);
429        let parent = parent_id.map_or_else(String::new, |parent_id| {
430            format!(",\"parent_thread_id\":\"{parent_id}\"")
431        });
432        let source = if is_subagent {
433            ",\"source\":{\"subagent\":{}}".to_owned()
434        } else {
435            String::new()
436        };
437        let content = format!(
438            "{{\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\"{parent},\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"/tmp/workspace\"{source}}}}}\n"
439        );
440        std::fs::write(&path, content).expect("session fixture should be written");
441        path
442    }
443}