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}