1use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14use crate::reduce::ReductionLog;
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
18#[non_exhaustive]
19pub struct SessionInfo {
20 pub name: String,
22 #[serde(default)]
24 pub title: String,
25 #[serde(default)]
27 pub archived: bool,
28 #[serde(default)]
32 pub reduced: bool,
33 #[serde(default)]
36 pub tier: String,
37 #[serde(default)]
39 pub full_bytes: u64,
40 #[serde(default)]
43 pub view_bytes: u64,
44 #[serde(default)]
47 pub stub_count: u32,
48 #[serde(default)]
50 pub escalations: u32,
51}
52
53pub struct SessionStore {
55 root: PathBuf,
56}
57
58impl SessionStore {
59 pub fn at(root: impl Into<PathBuf>) -> Self {
64 SessionStore { root: root.into() }
65 }
66
67 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
69 let root = root.into();
70 std::fs::create_dir_all(&root)?;
71 Ok(Self::at(root))
72 }
73
74 fn validate_name(name: &str) -> Result<()> {
79 let bad = name.is_empty()
80 || name.contains('/')
81 || name.contains('\\')
82 || name.contains('\0')
83 || name.split(['/', '\\']).any(|c| c == ".." || c == ".")
84 || std::path::Path::new(name).is_absolute()
85 || name.trim() != name;
86 if bad {
87 return Err(Error::Other(format!("invalid session name: `{name}`")));
88 }
89 Ok(())
90 }
91
92 fn transcript_path(&self, name: &str, archived: bool) -> PathBuf {
93 self.dir(archived).join(format!("{name}.jsonl"))
94 }
95 fn meta_path(&self, name: &str, archived: bool) -> PathBuf {
96 self.dir(archived).join(format!("{name}.meta.json"))
97 }
98 fn sidecar_path_in(&self, name: &str, archived: bool) -> PathBuf {
101 self.dir(archived).join(format!("{name}.sidecar.jsonl"))
102 }
103 fn reduction_path(&self, name: &str, archived: bool) -> PathBuf {
106 self.dir(archived).join(format!("{name}.reduction.json"))
107 }
108 fn events_path(&self, name: &str, archived: bool) -> PathBuf {
112 self.dir(archived).join(format!("{name}.events.jsonl"))
113 }
114 fn usage_path(&self, name: &str, archived: bool) -> PathBuf {
118 self.dir(archived).join(format!("{name}.usage.jsonl"))
119 }
120 fn model_change_path(&self, name: &str, archived: bool) -> PathBuf {
125 self.dir(archived)
126 .join(format!("{name}.model_change.jsonl"))
127 }
128 fn git_metadata_path(&self, name: &str, archived: bool) -> PathBuf {
134 self.dir(archived).join(format!("{name}.git.json"))
135 }
136 fn fork_path(&self, name: &str, archived: bool) -> PathBuf {
142 self.dir(archived).join(format!("{name}.fork.json"))
143 }
144 fn tree_path(&self, name: &str, archived: bool) -> PathBuf {
154 self.dir(archived).join(format!("{name}.tree.json"))
155 }
156 fn claude_runtime_path(&self, name: &str, archived: bool) -> PathBuf {
160 self.dir(archived)
161 .join(format!("{name}.claude-runtime.json"))
162 }
163 fn subagents_dir(&self, parent_name: &str, archived: bool) -> PathBuf {
172 self.dir(archived).join(format!("{parent_name}.subagents"))
173 }
174 fn subagent_transcript_path(
175 &self,
176 parent_name: &str,
177 child_id: &str,
178 archived: bool,
179 ) -> PathBuf {
180 self.subagents_dir(parent_name, archived)
181 .join(format!("{child_id}.sidecar.jsonl"))
182 }
183 fn subagent_lineage_path(&self, parent_name: &str, child_id: &str, archived: bool) -> PathBuf {
184 self.subagents_dir(parent_name, archived)
185 .join(format!("{child_id}.lineage.json"))
186 }
187 fn dir(&self, archived: bool) -> PathBuf {
188 if archived {
189 self.root.join("archived")
190 } else {
191 self.root.clone()
192 }
193 }
194
195 pub fn sidecar_path(&self, name: &str) -> PathBuf {
202 self.sidecar_path_in(name, false)
203 }
204
205 pub fn session_path(&self, name: &str) -> Result<PathBuf> {
209 Self::validate_name(name)?;
210 Ok(self.transcript_path(name, false))
211 }
212
213 pub fn session_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
216 Self::validate_name(name)?;
217 Ok(self.transcript_path(name, archived))
218 }
219
220 pub fn sidecar_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
222 Self::validate_name(name)?;
223 Ok(self.sidecar_path_in(name, archived))
224 }
225
226 pub fn subagent_transcript_path_for(
228 &self,
229 parent_name: &str,
230 child_id: &str,
231 archived: bool,
232 ) -> Result<PathBuf> {
233 Self::validate_name(parent_name)?;
234 Self::validate_name(child_id)?;
235 Ok(self.subagent_transcript_path(parent_name, child_id, archived))
236 }
237
238 pub fn save_sidecar(&self, name: &str, sidecar_jsonl: &str) -> Result<()> {
241 Self::validate_name(name)?;
242 std::fs::create_dir_all(self.dir(false))?;
243 std::fs::write(self.sidecar_path_in(name, false), sidecar_jsonl)?;
244 Ok(())
245 }
246
247 pub fn load_sidecar(&self, name: &str) -> Result<Option<String>> {
251 Self::validate_name(name)?;
252 let active = self.sidecar_path_in(name, false);
253 let path = if active.exists() {
254 active
255 } else {
256 self.sidecar_path_in(name, true)
257 };
258 if !path.exists() {
259 return Ok(None);
260 }
261 Ok(Some(std::fs::read_to_string(path)?))
262 }
263
264 pub fn load_sidecar_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
266 Self::validate_name(name)?;
267 let path = self.sidecar_path_in(name, archived);
268 if !path.exists() {
269 return Ok(None);
270 }
271 Ok(Some(std::fs::read_to_string(path)?))
272 }
273
274 pub fn save_reduction_log(&self, name: &str, log: &ReductionLog) -> Result<()> {
277 Self::validate_name(name)?;
278 std::fs::create_dir_all(self.dir(false))?;
279 let json = serde_json::to_string(log).map_err(Error::Decode)?;
280 std::fs::write(self.reduction_path(name, false), json)?;
281 Ok(())
282 }
283
284 pub fn load_reduction_log(&self, name: &str) -> Result<Option<ReductionLog>> {
287 Self::validate_name(name)?;
288 let active = self.reduction_path(name, false);
289 let path = if active.exists() {
290 active
291 } else {
292 self.reduction_path(name, true)
293 };
294 if !path.exists() {
295 return Ok(None);
296 }
297 let text = std::fs::read_to_string(path)?;
298 Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
299 }
300
301 pub fn load_reduction_log_from(
303 &self,
304 name: &str,
305 archived: bool,
306 ) -> Result<Option<ReductionLog>> {
307 Self::validate_name(name)?;
308 let path = self.reduction_path(name, archived);
309 if !path.exists() {
310 return Ok(None);
311 }
312 let text = std::fs::read_to_string(path)?;
313 Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
314 }
315
316 pub fn save_usage_log(
322 &self,
323 name: &str,
324 records: &[crate::usage_log::UsageRecord],
325 ) -> Result<()> {
326 Self::validate_name(name)?;
327 std::fs::create_dir_all(self.dir(false))?;
328 let jsonl = crate::usage_log::to_jsonl(records)?;
329 std::fs::write(self.usage_path(name, false), jsonl)?;
330 Ok(())
331 }
332
333 pub fn load_usage_log(&self, name: &str) -> Result<Vec<crate::usage_log::UsageRecord>> {
336 Self::validate_name(name)?;
337 let active = self.usage_path(name, false);
338 let path = if active.exists() {
339 active
340 } else {
341 self.usage_path(name, true)
342 };
343 if !path.exists() {
344 return Ok(Vec::new());
345 }
346 crate::usage_log::from_jsonl(&std::fs::read_to_string(path)?)
347 }
348
349 pub fn save_model_change_log(
354 &self,
355 name: &str,
356 records: &[crate::model_change::ModelChangeRecord],
357 ) -> Result<()> {
358 Self::validate_name(name)?;
359 std::fs::create_dir_all(self.dir(false))?;
360 let jsonl = crate::model_change::to_jsonl(records)?;
361 std::fs::write(self.model_change_path(name, false), jsonl)?;
362 Ok(())
363 }
364
365 pub fn load_model_change_log(
370 &self,
371 name: &str,
372 ) -> Result<Vec<crate::model_change::ModelChangeRecord>> {
373 Self::validate_name(name)?;
374 let active = self.model_change_path(name, false);
375 let path = if active.exists() {
376 active
377 } else {
378 self.model_change_path(name, true)
379 };
380 if !path.exists() {
381 return Ok(Vec::new());
382 }
383 crate::model_change::from_jsonl(&std::fs::read_to_string(path)?)
384 }
385
386 pub fn save_git_metadata(
390 &self,
391 name: &str,
392 record: &crate::git_metadata::GitMetadataRecord,
393 ) -> Result<()> {
394 Self::validate_name(name)?;
395 std::fs::create_dir_all(self.dir(false))?;
396 let json = crate::git_metadata::to_json(record)?;
397 std::fs::write(self.git_metadata_path(name, false), json)?;
398 Ok(())
399 }
400
401 pub fn load_git_metadata(
405 &self,
406 name: &str,
407 ) -> Result<Option<crate::git_metadata::GitMetadataRecord>> {
408 Self::validate_name(name)?;
409 let active = self.git_metadata_path(name, false);
410 let path = if active.exists() {
411 active
412 } else {
413 self.git_metadata_path(name, true)
414 };
415 if !path.exists() {
416 return Ok(None);
417 }
418 Ok(Some(crate::git_metadata::from_json(
419 &std::fs::read_to_string(path)?,
420 )?))
421 }
422
423 pub fn save(&self, name: &str, title: &str, transcript_jsonl: &str) -> Result<()> {
425 Self::validate_name(name)?;
426 std::fs::create_dir_all(self.dir(false))?;
427 std::fs::write(self.transcript_path(name, false), transcript_jsonl)?;
428 let info = SessionInfo {
429 name: name.to_string(),
430 title: title.to_string(),
431 archived: false,
432 ..Default::default()
433 };
434 std::fs::write(
435 self.meta_path(name, false),
436 serde_json::to_string(&info).map_err(Error::Decode)?,
437 )?;
438 Ok(())
439 }
440
441 pub fn load(&self, name: &str) -> Result<String> {
443 Self::validate_name(name)?;
444 let active = self.transcript_path(name, false);
445 let path = if active.exists() {
446 active
447 } else {
448 self.transcript_path(name, true)
449 };
450 Ok(std::fs::read_to_string(path)?)
451 }
452
453 pub fn load_from(&self, name: &str, archived: bool) -> Result<String> {
455 Self::validate_name(name)?;
456 Ok(std::fs::read_to_string(
457 self.transcript_path(name, archived),
458 )?)
459 }
460
461 pub fn load_if_present_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
464 Self::validate_name(name)?;
465 let path = self.transcript_path(name, archived);
466 match std::fs::symlink_metadata(&path) {
467 Ok(_) => Ok(Some(std::fs::read_to_string(path)?)),
468 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
469 Err(error) => Err(error.into()),
470 }
471 }
472
473 pub fn load_if_present(&self, name: &str) -> Result<Option<String>> {
481 Self::validate_name(name)?;
482 for archived in [false, true] {
483 let path = self.transcript_path(name, archived);
484 match std::fs::symlink_metadata(&path) {
485 Ok(_) => return Ok(Some(std::fs::read_to_string(path)?)),
486 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
487 Err(e) => return Err(e.into()),
488 }
489 }
490 Ok(None)
491 }
492
493 pub fn list(&self) -> Vec<SessionInfo> {
495 let mut out = Vec::new();
496 for archived in [false, true] {
497 let dir = self.dir(archived);
498 let Ok(rd) = std::fs::read_dir(&dir) else {
499 continue;
500 };
501 for entry in rd.flatten() {
502 let p = entry.path();
503 if p.extension().and_then(|e| e.to_str()) != Some("json") {
504 continue;
505 }
506 if !p.to_string_lossy().ends_with(".meta.json") {
508 continue;
509 }
510 if let Ok(text) = std::fs::read_to_string(&p) {
511 if let Ok(mut info) = serde_json::from_str::<SessionInfo>(&text) {
512 info.archived = archived;
513 out.push(info);
514 }
515 }
516 }
517 }
518 out.sort_by(|a, b| a.name.cmp(&b.name));
519 out
520 }
521
522 pub fn archive(&self, name: &str) -> Result<()> {
527 Self::validate_name(name)?;
528 std::fs::create_dir_all(self.dir(true))?;
529 for (from, to) in [
530 (
531 self.transcript_path(name, false),
532 self.transcript_path(name, true),
533 ),
534 (self.meta_path(name, false), self.meta_path(name, true)),
535 (
536 self.sidecar_path_in(name, false),
537 self.sidecar_path_in(name, true),
538 ),
539 (
540 self.reduction_path(name, false),
541 self.reduction_path(name, true),
542 ),
543 (self.events_path(name, false), self.events_path(name, true)),
544 (self.usage_path(name, false), self.usage_path(name, true)),
545 (
546 self.model_change_path(name, false),
547 self.model_change_path(name, true),
548 ),
549 (
550 self.git_metadata_path(name, false),
551 self.git_metadata_path(name, true),
552 ),
553 (self.fork_path(name, false), self.fork_path(name, true)),
554 (self.tree_path(name, false), self.tree_path(name, true)),
555 (
556 self.claude_runtime_path(name, false),
557 self.claude_runtime_path(name, true),
558 ),
559 ] {
560 if from.exists() {
561 std::fs::rename(&from, &to)?;
562 }
563 }
564 let subagents_from = self.subagents_dir(name, false);
569 if subagents_from.exists() {
570 std::fs::rename(&subagents_from, self.subagents_dir(name, true))?;
571 }
572 Ok(())
573 }
574
575 pub fn delete(&self, name: &str) -> Result<()> {
580 Self::validate_name(name)?;
581 for archived in [false, true] {
582 for p in [
583 self.transcript_path(name, archived),
584 self.meta_path(name, archived),
585 self.sidecar_path_in(name, archived),
586 self.reduction_path(name, archived),
587 self.events_path(name, archived),
588 self.usage_path(name, archived),
589 self.model_change_path(name, archived),
590 self.git_metadata_path(name, archived),
591 self.fork_path(name, archived),
592 self.tree_path(name, archived),
593 self.claude_runtime_path(name, archived),
594 ] {
595 if p.exists() {
596 std::fs::remove_file(p)?;
597 }
598 }
599 let subagents_dir = self.subagents_dir(name, archived);
602 if subagents_dir.exists() {
603 std::fs::remove_dir_all(&subagents_dir)?;
604 }
605 }
606 Ok(())
607 }
608
609 pub fn set_title(&self, name: &str, title: &str) -> Result<()> {
613 Self::validate_name(name)?;
614 for archived in [false, true] {
615 let mp = self.meta_path(name, archived);
616 if mp.exists() {
617 let mut info: SessionInfo = std::fs::read_to_string(&mp)
618 .ok()
619 .and_then(|t| serde_json::from_str(&t).ok())
620 .unwrap_or_else(|| SessionInfo {
621 name: name.to_string(),
622 ..Default::default()
623 });
624 info.title = title.to_string();
625 info.archived = archived;
626 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
627 return Ok(());
628 }
629 }
630 Err(Error::Other(format!("no session named `{name}`")))
631 }
632
633 pub fn root(&self) -> &Path {
635 &self.root
636 }
637
638 pub fn transcript_mtime(&self, name: &str) -> Option<std::time::SystemTime> {
646 Self::validate_name(name).ok()?;
647 let active = self.transcript_path(name, false);
648 let path = if active.exists() {
649 active
650 } else {
651 self.transcript_path(name, true)
652 };
653 std::fs::metadata(path).ok()?.modified().ok()
654 }
655
656 pub fn set_reduction_stats(
663 &self,
664 name: &str,
665 title: &str,
666 full_bytes: u64,
667 view_bytes: u64,
668 stub_count: u32,
669 ) -> Result<()> {
670 Self::validate_name(name)?;
671 std::fs::create_dir_all(self.dir(false))?;
672 let mp = self.meta_path(name, false);
673 let mut info: SessionInfo = std::fs::read_to_string(&mp)
674 .ok()
675 .and_then(|t| serde_json::from_str(&t).ok())
676 .unwrap_or_else(|| SessionInfo {
677 name: name.to_string(),
678 title: title.to_string(),
679 ..Default::default()
680 });
681 info.reduced = true;
682 info.full_bytes = full_bytes;
683 info.view_bytes = view_bytes;
684 info.stub_count = stub_count;
685 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
686 Ok(())
687 }
688
689 pub fn fork(
700 &self,
701 from: &str,
702 to: &str,
703 title: &str,
704 truncate_at_message: Option<usize>,
705 timestamp_ms: i64,
706 ) -> Result<ForkProvenance> {
707 Self::validate_name(from)?;
708 Self::validate_name(to)?;
709 let jsonl = self.load(from)?;
710 if let Some(n) = truncate_at_message {
711 Self::validate_safe_truncation(&jsonl, n)?;
712 }
713 let content = match truncate_at_message {
714 Some(n) => {
715 let lines: Vec<&str> = jsonl.lines().take(n).collect();
716 if lines.is_empty() {
717 String::new()
718 } else {
719 let mut s = lines.join("\n");
720 s.push('\n');
721 s
722 }
723 }
724 None => jsonl,
725 };
726 self.save(to, title, &content)?;
727
728 self.copy_family_member(from, to, Self::sidecar_path_in)?;
744 self.copy_family_member(from, to, Self::reduction_path)?;
745 self.copy_family_member(from, to, Self::usage_path)?;
746 self.copy_family_member(from, to, Self::model_change_path)?;
747 self.copy_family_member(from, to, Self::git_metadata_path)?;
748 self.copy_family_member(from, to, Self::tree_path)?;
753 self.copy_family_member(from, to, Self::claude_runtime_path)?;
754
755 let provenance = ForkProvenance {
756 forked_from: from.to_string(),
757 forked_at_message: truncate_at_message,
758 timestamp_ms,
759 };
760 self.save_fork_provenance(to, &provenance)?;
761 Ok(provenance)
762 }
763
764 fn copy_family_member(
772 &self,
773 from: &str,
774 to: &str,
775 path_of: impl Fn(&Self, &str, bool) -> PathBuf,
776 ) -> Result<()> {
777 let active = path_of(self, from, false);
778 let src = if active.exists() {
779 active
780 } else {
781 let archived = path_of(self, from, true);
782 if !archived.exists() {
783 return Ok(());
784 }
785 archived
786 };
787 std::fs::create_dir_all(self.dir(false))?;
788 std::fs::copy(&src, path_of(self, to, false))?;
789 Ok(())
790 }
791
792 fn validate_safe_truncation(jsonl: &str, n: usize) -> Result<()> {
802 let kept: Vec<crate::message::ChatMessage> = jsonl
803 .lines()
804 .take(n)
805 .filter(|l| !l.trim().is_empty())
806 .map(|l| serde_json::from_str(l).map_err(Error::Decode))
807 .collect::<Result<_>>()?;
808 let mut pending: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
809 for m in &kept {
810 if let Some(calls) = &m.tool_calls {
811 for c in calls {
812 pending.insert(c.id.clone());
813 }
814 }
815 if let Some(id) = &m.tool_call_id {
816 pending.remove(id);
817 }
818 }
819 if !pending.is_empty() {
820 return Err(Error::Other(format!(
821 "fork --at {n} would cut off {} unresolved tool_call result(s) ({}) — \
822 choose a boundary at or after the assistant's tool_calls message AND \
823 all of its tool results",
824 pending.len(),
825 pending.into_iter().collect::<Vec<_>>().join(", "),
826 )));
827 }
828 Ok(())
829 }
830
831 pub fn save_fork_provenance(&self, name: &str, provenance: &ForkProvenance) -> Result<()> {
834 Self::validate_name(name)?;
835 std::fs::create_dir_all(self.dir(false))?;
836 let json = serde_json::to_string(provenance).map_err(Error::Decode)?;
837 std::fs::write(self.fork_path(name, false), json)?;
838 Ok(())
839 }
840
841 pub fn load_fork_provenance(&self, name: &str) -> Result<Option<ForkProvenance>> {
844 Self::validate_name(name)?;
845 let active = self.fork_path(name, false);
846 let path = if active.exists() {
847 active
848 } else {
849 self.fork_path(name, true)
850 };
851 if !path.exists() {
852 return Ok(None);
853 }
854 Ok(Some(
855 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
856 ))
857 }
858
859 pub fn save_tree(&self, name: &str, tree: &crate::session_tree::SessionTree) -> Result<()> {
864 Self::validate_name(name)?;
865 std::fs::create_dir_all(self.dir(false))?;
866 let json = serde_json::to_string(tree).map_err(Error::Decode)?;
867 std::fs::write(self.tree_path(name, false), json)?;
868 Ok(())
869 }
870
871 pub fn save_claude_runtime_manifest(
874 &self,
875 name: &str,
876 manifest: &crate::claude_runtime_state::ClaudeRuntimeManifest,
877 ) -> Result<()> {
878 Self::validate_name(name)?;
879 std::fs::create_dir_all(self.dir(false))?;
880 let json = serde_json::to_string(manifest).map_err(Error::Decode)?;
881 std::fs::write(self.claude_runtime_path(name, false), json)?;
882 Ok(())
883 }
884
885 pub fn load_claude_runtime_manifest(
887 &self,
888 name: &str,
889 ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
890 Self::validate_name(name)?;
891 let active = self.claude_runtime_path(name, false);
892 let path = if active.exists() {
893 active
894 } else {
895 self.claude_runtime_path(name, true)
896 };
897 if !path.exists() {
898 return Ok(None);
899 }
900 Ok(Some(
901 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
902 ))
903 }
904
905 pub fn load_claude_runtime_manifest_from(
907 &self,
908 name: &str,
909 archived: bool,
910 ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
911 Self::validate_name(name)?;
912 let path = self.claude_runtime_path(name, archived);
913 if !path.exists() {
914 return Ok(None);
915 }
916 Ok(Some(
917 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
918 ))
919 }
920
921 pub fn load_tree(&self, name: &str) -> Result<Option<crate::session_tree::SessionTree>> {
926 Self::validate_name(name)?;
927 let active = self.tree_path(name, false);
928 let path = if active.exists() {
929 active
930 } else {
931 self.tree_path(name, true)
932 };
933 if !path.exists() {
934 return Ok(None);
935 }
936 Ok(Some(
937 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
938 ))
939 }
940
941 pub fn save_subagent_transcript(
949 &self,
950 parent_name: &str,
951 child_id: &str,
952 sidecar_jsonl: &str,
953 ) -> Result<()> {
954 Self::validate_name(parent_name)?;
955 Self::validate_name(child_id)?;
956 std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
957 std::fs::write(
958 self.subagent_transcript_path(parent_name, child_id, false),
959 sidecar_jsonl,
960 )?;
961 Ok(())
962 }
963
964 pub fn save_imported_subagents(
973 &self,
974 parent_name: &str,
975 subagents: &[crate::session::Session],
976 ) -> Result<usize> {
977 Self::validate_name(parent_name)?;
978
979 let mut seen = std::collections::BTreeSet::new();
980 for child in subagents {
981 let child_id = child.meta.agent_id.as_deref().ok_or_else(|| {
982 Error::Other(format!(
983 "cannot persist an imported subagent for `{parent_name}` without an agent id"
984 ))
985 })?;
986 Self::validate_name(child_id)?;
987 if !seen.insert(child_id.to_string()) {
988 return Err(Error::Other(format!(
989 "duplicate imported subagent id `{child_id}` for `{parent_name}`"
990 )));
991 }
992 }
993
994 for child in subagents {
999 let child_id = child.meta.agent_id.as_deref().expect("validated above");
1000 self.save_subagent_transcript(parent_name, child_id, &child.to_native_jsonl_v2(&[]))?;
1001 }
1002 Ok(subagents.len())
1003 }
1004
1005 pub fn load_subagent_transcript(
1008 &self,
1009 parent_name: &str,
1010 child_id: &str,
1011 ) -> Result<Option<String>> {
1012 Self::validate_name(parent_name)?;
1013 Self::validate_name(child_id)?;
1014 let active = self.subagent_transcript_path(parent_name, child_id, false);
1015 let path = if active.exists() {
1016 active
1017 } else {
1018 self.subagent_transcript_path(parent_name, child_id, true)
1019 };
1020 if !path.exists() {
1021 return Ok(None);
1022 }
1023 Ok(Some(std::fs::read_to_string(path)?))
1024 }
1025
1026 pub fn load_subagent_transcript_from(
1028 &self,
1029 parent_name: &str,
1030 child_id: &str,
1031 archived: bool,
1032 ) -> Result<Option<String>> {
1033 Self::validate_name(parent_name)?;
1034 Self::validate_name(child_id)?;
1035 let path = self.subagent_transcript_path(parent_name, child_id, archived);
1036 if !path.exists() {
1037 return Ok(None);
1038 }
1039 Ok(Some(std::fs::read_to_string(path)?))
1040 }
1041
1042 pub fn save_subagent_lineage(
1046 &self,
1047 parent_name: &str,
1048 child_id: &str,
1049 record: &crate::subagents::SubagentLineage,
1050 ) -> Result<()> {
1051 Self::validate_name(parent_name)?;
1052 Self::validate_name(child_id)?;
1053 std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
1054 let json = serde_json::to_string(record).map_err(Error::Decode)?;
1055 std::fs::write(
1056 self.subagent_lineage_path(parent_name, child_id, false),
1057 json,
1058 )?;
1059 Ok(())
1060 }
1061
1062 pub fn load_subagent_lineage(
1065 &self,
1066 parent_name: &str,
1067 child_id: &str,
1068 ) -> Result<Option<crate::subagents::SubagentLineage>> {
1069 Self::validate_name(parent_name)?;
1070 Self::validate_name(child_id)?;
1071 let active = self.subagent_lineage_path(parent_name, child_id, false);
1072 let path = if active.exists() {
1073 active
1074 } else {
1075 self.subagent_lineage_path(parent_name, child_id, true)
1076 };
1077 if !path.exists() {
1078 return Ok(None);
1079 }
1080 Ok(Some(
1081 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1082 ))
1083 }
1084
1085 pub fn list_subagent_ids(&self, parent_name: &str) -> Result<Vec<String>> {
1090 Self::validate_name(parent_name)?;
1091 let mut ids = std::collections::BTreeSet::new();
1092 for archived in [false, true] {
1093 let dir = self.subagents_dir(parent_name, archived);
1094 let Ok(rd) = std::fs::read_dir(&dir) else {
1095 continue;
1096 };
1097 for entry in rd.flatten() {
1098 let p = entry.path();
1099 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1100 if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1101 ids.insert(id.to_string());
1102 }
1103 }
1104 }
1105 }
1106 Ok(ids.into_iter().collect())
1107 }
1108
1109 pub fn list_subagent_ids_from(&self, parent_name: &str, archived: bool) -> Result<Vec<String>> {
1111 Self::validate_name(parent_name)?;
1112 let mut ids = std::collections::BTreeSet::new();
1113 let dir = self.subagents_dir(parent_name, archived);
1114 let Ok(rd) = std::fs::read_dir(&dir) else {
1115 return Ok(Vec::new());
1116 };
1117 for entry in rd.flatten() {
1118 let p = entry.path();
1119 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1120 if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1121 ids.insert(id.to_string());
1122 }
1123 }
1124 }
1125 Ok(ids.into_iter().collect())
1126 }
1127
1128 pub fn prune_expired(
1136 &self,
1137 retention_days: u32,
1138 now: std::time::SystemTime,
1139 ) -> Result<Vec<String>> {
1140 if retention_days == 0 {
1141 return Ok(Vec::new());
1142 }
1143 let Some(cutoff) = now.checked_sub(std::time::Duration::from_secs(
1144 retention_days as u64 * 86_400,
1145 )) else {
1146 return Ok(Vec::new());
1147 };
1148 let mut pruned = Vec::new();
1149 for info in self.list() {
1150 if !info.archived {
1151 continue;
1152 }
1153 let Some(mtime) = self.transcript_mtime(&info.name) else {
1154 continue;
1155 };
1156 if mtime < cutoff {
1157 self.delete(&info.name)?;
1158 pruned.push(info.name);
1159 }
1160 }
1161 Ok(pruned)
1162 }
1163}
1164
1165#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1168pub struct ForkProvenance {
1169 pub forked_from: String,
1171 #[serde(default)]
1174 pub forked_at_message: Option<usize>,
1175 #[serde(default)]
1177 pub timestamp_ms: i64,
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183
1184 #[test]
1185 fn addressing_a_store_does_not_create_its_root() {
1186 let tmp = std::env::temp_dir().join(format!(
1187 "sc-store-address-only-{}-{}",
1188 std::process::id(),
1189 std::time::SystemTime::now()
1190 .duration_since(std::time::UNIX_EPOCH)
1191 .unwrap()
1192 .as_nanos()
1193 ));
1194 let store = SessionStore::at(&tmp);
1195 assert_eq!(store.root(), tmp);
1196 assert!(!tmp.exists());
1197 assert!(store.list().is_empty());
1198 assert!(!tmp.exists());
1199 }
1200
1201 #[test]
1202 fn rejects_path_traversal_names() {
1203 let tmp = std::env::temp_dir().join(format!("sc-store-test-{}", std::process::id()));
1204 let store = SessionStore::open(&tmp).unwrap();
1205 for bad in ["../escape", "..", "a/b", "/abs", "", " ", ".", "x\0y"] {
1206 assert!(store.save(bad, "t", "{}").is_err(), "should reject `{bad}`");
1207 assert!(store.load(bad).is_err(), "should reject load `{bad}`");
1208 assert!(store.delete(bad).is_err(), "should reject delete `{bad}`");
1209 }
1210 store.save("ok-name", "t", "{}").unwrap();
1212 assert!(tmp.join("ok-name.jsonl").exists());
1213 assert!(!tmp.parent().unwrap().join("escape.jsonl").exists());
1215 let _ = std::fs::remove_dir_all(&tmp);
1216 }
1217
1218 fn temp_store() -> (SessionStore, std::path::PathBuf) {
1219 use std::sync::atomic::{AtomicU64, Ordering};
1220 static N: AtomicU64 = AtomicU64::new(0);
1221 let tmp = std::env::temp_dir().join(format!(
1222 "sc-store-model-change-{}-{}",
1223 std::process::id(),
1224 N.fetch_add(1, Ordering::SeqCst)
1225 ));
1226 (SessionStore::open(&tmp).unwrap(), tmp)
1227 }
1228
1229 #[test]
1233 fn model_change_log_round_trips_losslessly_through_the_store() {
1234 let (store, tmp) = temp_store();
1235 store.save("sess", "t", "[]").unwrap();
1236 let records = vec![
1237 crate::model_change::ModelChangeRecord::new(
1238 0,
1239 "vendor/model-a",
1240 "vendor/model-b",
1241 true,
1242 4,
1243 1_700_000_000_000,
1244 ),
1245 crate::model_change::ModelChangeRecord::new(
1246 5,
1247 "vendor/model-b",
1248 "vendor/model-c",
1249 true,
1250 0,
1251 1_700_000_050_000,
1252 ),
1253 ];
1254 store.save_model_change_log("sess", &records).unwrap();
1255 let loaded = store.load_model_change_log("sess").unwrap();
1256 assert_eq!(loaded, records);
1257 let _ = std::fs::remove_dir_all(&tmp);
1258 }
1259
1260 #[test]
1263 fn model_change_log_is_empty_when_never_saved() {
1264 let (store, tmp) = temp_store();
1265 store.save("sess", "t", "[]").unwrap();
1266 assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1267 let _ = std::fs::remove_dir_all(&tmp);
1268 }
1269
1270 #[test]
1274 fn model_change_log_travels_with_archive_and_is_removed_by_delete() {
1275 let (store, tmp) = temp_store();
1276 store.save("sess", "t", "[]").unwrap();
1277 let records = vec![crate::model_change::ModelChangeRecord::new(
1278 0, "a", "b", true, 1, 1,
1279 )];
1280 store.save_model_change_log("sess", &records).unwrap();
1281 assert!(tmp.join("sess.model_change.jsonl").exists());
1282
1283 store.archive("sess").unwrap();
1284 assert!(!tmp.join("sess.model_change.jsonl").exists());
1285 assert!(tmp.join("archived/sess.model_change.jsonl").exists());
1286 assert_eq!(store.load_model_change_log("sess").unwrap(), records);
1288
1289 store.delete("sess").unwrap();
1290 assert!(!tmp.join("archived/sess.model_change.jsonl").exists());
1291 assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1292 let _ = std::fs::remove_dir_all(&tmp);
1293 }
1294
1295 #[test]
1298 fn git_metadata_round_trips_losslessly_through_the_store() {
1299 let (store, tmp) = temp_store();
1300 store.save("sess", "t", "[]").unwrap();
1301 let record = crate::git_metadata::GitMetadataRecord {
1302 branch: Some("main".to_string()),
1303 sha: Some("deadbeef".to_string()),
1304 dirty: true,
1305 captured_at_ms: 1_700_000_000_000,
1306 };
1307 store.save_git_metadata("sess", &record).unwrap();
1308 assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1309 let _ = std::fs::remove_dir_all(&tmp);
1310 }
1311
1312 #[test]
1315 fn git_metadata_is_none_when_never_saved() {
1316 let (store, tmp) = temp_store();
1317 store.save("sess", "t", "[]").unwrap();
1318 assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1319 let _ = std::fs::remove_dir_all(&tmp);
1320 }
1321
1322 #[test]
1325 fn git_metadata_travels_with_archive_and_is_removed_by_delete() {
1326 let (store, tmp) = temp_store();
1327 store.save("sess", "t", "[]").unwrap();
1328 let record = crate::git_metadata::GitMetadataRecord {
1329 branch: Some("main".to_string()),
1330 sha: None,
1331 dirty: false,
1332 captured_at_ms: 1,
1333 };
1334 store.save_git_metadata("sess", &record).unwrap();
1335 assert!(tmp.join("sess.git.json").exists());
1336
1337 store.archive("sess").unwrap();
1338 assert!(!tmp.join("sess.git.json").exists());
1339 assert!(tmp.join("archived/sess.git.json").exists());
1340 assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1341
1342 store.delete("sess").unwrap();
1343 assert!(!tmp.join("archived/sess.git.json").exists());
1344 assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1345 let _ = std::fs::remove_dir_all(&tmp);
1346 }
1347
1348 fn sample_lineage(child_id: &str) -> crate::subagents::SubagentLineage {
1354 crate::subagents::SubagentLineage {
1355 child_agent_id: child_id.to_string(),
1356 parent_session_id: Some("parent-sess".to_string()),
1357 parent_tool_use_id: "call_1".to_string(),
1358 depth: 1,
1359 agent_type: Some("researcher".to_string()),
1360 task: "investigate the flaky test".to_string(),
1361 background: false,
1362 spawned_at_ms: 1_700_000_000_000,
1363 model: "vendor/model-a".to_string(),
1364 }
1365 }
1366
1367 #[test]
1370 fn subagent_lineage_round_trips_losslessly_through_the_store() {
1371 let (store, tmp) = temp_store();
1372 store.save("parent-sess", "t", "[]").unwrap();
1373 let record = sample_lineage("agent-1");
1374 store
1375 .save_subagent_lineage("parent-sess", "agent-1", &record)
1376 .unwrap();
1377 assert_eq!(
1378 store
1379 .load_subagent_lineage("parent-sess", "agent-1")
1380 .unwrap(),
1381 Some(record)
1382 );
1383 let _ = std::fs::remove_dir_all(&tmp);
1384 }
1385
1386 #[test]
1391 fn subagent_transcript_round_trips_and_parses_back_with_lineage() {
1392 let (store, tmp) = temp_store();
1393 store.save("parent-sess", "t", "[]").unwrap();
1394
1395 let mut session = crate::session::Session::from_claude_code_str("").unwrap();
1396 session.meta.agent_id = Some("agent-1".to_string());
1397 session.meta.parent_tool_use_id = Some("call_1".to_string());
1398 session
1399 .meta
1400 .lineage
1401 .insert("parent_thread_id".to_string(), "parent-sess".to_string());
1402 session
1403 .meta
1404 .lineage
1405 .insert("depth".to_string(), "1".to_string());
1406 let appended = vec![crate::message::ChatMessage::user("hello from the child")];
1407 let sidecar_jsonl = session.to_native_jsonl_v2(&appended);
1408
1409 store
1410 .save_subagent_transcript("parent-sess", "agent-1", &sidecar_jsonl)
1411 .unwrap();
1412 let loaded = store
1413 .load_subagent_transcript("parent-sess", "agent-1")
1414 .unwrap()
1415 .expect("just saved");
1416 assert_eq!(loaded, sidecar_jsonl);
1417
1418 let parsed = crate::session::Session::from_native_str(&loaded).unwrap();
1419 assert_eq!(parsed.meta.agent_id.as_deref(), Some("agent-1"));
1420 assert_eq!(parsed.meta.parent_tool_use_id.as_deref(), Some("call_1"));
1421 assert_eq!(
1422 parsed.meta.lineage.get("parent_thread_id"),
1423 Some(&"parent-sess".to_string())
1424 );
1425 assert_eq!(
1426 parsed.messages.last().and_then(|m| m.content.as_deref()),
1427 Some("hello from the child")
1428 );
1429
1430 assert_eq!(
1431 store.list_subagent_ids("parent-sess").unwrap(),
1432 vec!["agent-1".to_string()]
1433 );
1434 let _ = std::fs::remove_dir_all(&tmp);
1435 }
1436
1437 #[test]
1442 fn imported_subagents_survive_disk_reload_with_verbatim_source_bytes() {
1443 let (store, tmp) = temp_store();
1444 let original = concat!(
1445 "{\"type\":\"user\",\"sessionId\":\"parent\",\"agentId\":\"child-7\",\"uuid\":\"u1\",\"parentUuid\":null,\"message\":{\"role\":\"user\",\"content\":\"inspect it\"}} \r\n",
1446 "{\"type\":\"queue-operation\",\"operation\":\"dequeue\"}"
1447 );
1448 let mut child = crate::session::Session::from_claude_code_str(original).unwrap();
1449 child.meta.agent_id = Some("child-7".to_string());
1450 child.meta.parent_tool_use_id = Some("toolu_task_7".to_string());
1451
1452 assert_eq!(
1453 store
1454 .save_imported_subagents("parent-sess", &[child])
1455 .unwrap(),
1456 1
1457 );
1458 drop(store);
1459
1460 let reopened = SessionStore::open(&tmp).unwrap();
1461 let native = reopened
1462 .load_subagent_transcript("parent-sess", "child-7")
1463 .unwrap()
1464 .expect("imported child persisted");
1465 let loaded = crate::session::Session::from_sidecar_str(&native).unwrap();
1466 assert_eq!(
1467 loaded.meta.source,
1468 crate::session::SessionSource::ClaudeCode
1469 );
1470 assert_eq!(loaded.meta.agent_id.as_deref(), Some("child-7"));
1471 assert_eq!(
1472 loaded.meta.parent_tool_use_id.as_deref(),
1473 Some("toolu_task_7")
1474 );
1475 assert_eq!(loaded.raw_verbatim(), original);
1476 assert!(loaded.raw_is_verbatim);
1477
1478 let _ = std::fs::remove_dir_all(&tmp);
1479 }
1480
1481 #[test]
1484 fn imported_subagent_duplicate_ids_fail_before_any_write() {
1485 let (store, tmp) = temp_store();
1486 let mut first = crate::session::Session::from_claude_code_str("{}").unwrap();
1487 first.meta.agent_id = Some("same".to_string());
1488 let second = first.clone();
1489
1490 assert!(store
1491 .save_imported_subagents("parent-sess", &[first, second])
1492 .unwrap_err()
1493 .to_string()
1494 .contains("duplicate imported subagent id"));
1495 assert!(!tmp.join("parent-sess.subagents").exists());
1496
1497 let _ = std::fs::remove_dir_all(&tmp);
1498 }
1499
1500 #[test]
1504 fn subagent_family_travels_with_archive_and_is_removed_by_delete() {
1505 let (store, tmp) = temp_store();
1506 store.save("parent-sess", "t", "[]").unwrap();
1507 store
1508 .save_subagent_lineage("parent-sess", "agent-1", &sample_lineage("agent-1"))
1509 .unwrap();
1510 store
1511 .save_subagent_transcript("parent-sess", "agent-1", "{}\n")
1512 .unwrap();
1513 assert!(tmp
1514 .join("parent-sess.subagents/agent-1.lineage.json")
1515 .exists());
1516 assert!(tmp
1517 .join("parent-sess.subagents/agent-1.sidecar.jsonl")
1518 .exists());
1519
1520 store.archive("parent-sess").unwrap();
1521 assert!(!tmp.join("parent-sess.subagents").exists());
1522 assert!(tmp
1523 .join("archived/parent-sess.subagents/agent-1.lineage.json")
1524 .exists());
1525 assert_eq!(
1527 store.list_subagent_ids("parent-sess").unwrap(),
1528 vec!["agent-1".to_string()]
1529 );
1530 assert!(store
1531 .load_subagent_lineage("parent-sess", "agent-1")
1532 .unwrap()
1533 .is_some());
1534
1535 store.delete("parent-sess").unwrap();
1536 assert!(!tmp.join("archived/parent-sess.subagents").exists());
1537 assert_eq!(
1538 store
1539 .load_subagent_lineage("parent-sess", "agent-1")
1540 .unwrap(),
1541 None
1542 );
1543 assert!(store.list_subagent_ids("parent-sess").unwrap().is_empty());
1544 let _ = std::fs::remove_dir_all(&tmp);
1545 }
1546
1547 #[test]
1551 fn subagent_family_is_absent_by_default() {
1552 let (store, tmp) = temp_store();
1553 store.save("sess", "t", "[]").unwrap();
1554 assert_eq!(
1555 store.load_subagent_lineage("sess", "agent-1").unwrap(),
1556 None
1557 );
1558 assert_eq!(
1559 store.load_subagent_transcript("sess", "agent-1").unwrap(),
1560 None
1561 );
1562 assert!(store.list_subagent_ids("sess").unwrap().is_empty());
1563 assert!(!tmp.join("sess.subagents").exists());
1564 let _ = std::fs::remove_dir_all(&tmp);
1565 }
1566
1567 #[test]
1571 fn fork_full_copy_is_byte_identical_with_provenance() {
1572 let (store, tmp) = temp_store();
1573 let transcript = "{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n";
1574 store.save("orig", "t", transcript).unwrap();
1575 let provenance = store
1576 .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
1577 .unwrap();
1578 assert_eq!(store.load("copy").unwrap(), transcript);
1579 assert_eq!(provenance.forked_from, "orig");
1580 assert_eq!(provenance.forked_at_message, None);
1581 assert_eq!(
1582 store.load_fork_provenance("copy").unwrap(),
1583 Some(provenance)
1584 );
1585 assert_eq!(store.load("orig").unwrap(), transcript);
1587 let _ = std::fs::remove_dir_all(&tmp);
1588 }
1589
1590 #[test]
1594 fn fork_with_truncation_keeps_only_leading_messages() {
1595 let (store, tmp) = temp_store();
1596 let transcript = "{\"role\":\"system\"}\n{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n{\"role\":\"tool\"}\n";
1597 store.save("orig", "t", transcript).unwrap();
1598 let provenance = store
1599 .fork("orig", "partial", "partial fork", Some(2), 42)
1600 .unwrap();
1601 assert_eq!(
1602 store.load("partial").unwrap(),
1603 "{\"role\":\"system\"}\n{\"role\":\"user\"}\n"
1604 );
1605 assert_eq!(provenance.forked_at_message, Some(2));
1606 assert_eq!(store.load("orig").unwrap(), transcript);
1609 let _ = std::fs::remove_dir_all(&tmp);
1610 }
1611
1612 #[test]
1614 fn fork_provenance_is_none_for_a_plain_session() {
1615 let (store, tmp) = temp_store();
1616 store.save("sess", "t", "[]").unwrap();
1617 assert_eq!(store.load_fork_provenance("sess").unwrap(), None);
1618 let _ = std::fs::remove_dir_all(&tmp);
1619 }
1620
1621 #[test]
1624 fn prune_expired_deletes_only_old_archived_sessions() {
1625 let (store, tmp) = temp_store();
1626 store.save("old-archived", "t", "[]").unwrap();
1627 store.archive("old-archived").unwrap();
1628 store.save("fresh-archived", "t", "[]").unwrap();
1629 store.archive("fresh-archived").unwrap();
1630 store.save("active", "t", "[]").unwrap();
1631
1632 let old_path = tmp.join("archived/old-archived.jsonl");
1635 let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
1636 std::fs::OpenOptions::new()
1637 .write(true)
1638 .open(&old_path)
1639 .unwrap()
1640 .set_modified(ancient)
1641 .unwrap();
1642
1643 let pruned = store
1644 .prune_expired(30, std::time::SystemTime::now())
1645 .unwrap();
1646 assert_eq!(pruned, vec!["old-archived".to_string()]);
1647 assert!(!old_path.exists());
1648 assert!(tmp.join("archived/fresh-archived.jsonl").exists());
1649 assert!(tmp.join("active.jsonl").exists());
1650 let _ = std::fs::remove_dir_all(&tmp);
1651 }
1652
1653 #[test]
1656 fn prune_expired_zero_days_prunes_nothing() {
1657 let (store, tmp) = temp_store();
1658 store.save("sess", "t", "[]").unwrap();
1659 store.archive("sess").unwrap();
1660 let pruned = store
1661 .prune_expired(0, std::time::SystemTime::now())
1662 .unwrap();
1663 assert!(pruned.is_empty());
1664 assert!(tmp.join("archived/sess.jsonl").exists());
1665 let _ = std::fs::remove_dir_all(&tmp);
1666 }
1667
1668 #[test]
1671 fn prune_expired_never_touches_active_sessions() {
1672 let (store, tmp) = temp_store();
1673 store.save("active", "t", "[]").unwrap();
1674 let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
1675 std::fs::OpenOptions::new()
1676 .write(true)
1677 .open(tmp.join("active.jsonl"))
1678 .unwrap()
1679 .set_modified(ancient)
1680 .unwrap();
1681 let pruned = store
1682 .prune_expired(30, std::time::SystemTime::now())
1683 .unwrap();
1684 assert!(pruned.is_empty());
1685 assert!(tmp.join("active.jsonl").exists());
1686 let _ = std::fs::remove_dir_all(&tmp);
1687 }
1688
1689 fn sample_tree() -> crate::session_tree::SessionTree {
1695 let mut tree = crate::session_tree::SessionTree::from_linear(
1696 &[
1697 crate::message::ChatMessage::user("hello"),
1698 crate::message::ChatMessage::assistant("hi"),
1699 ],
1700 1_700_000_000_000,
1701 );
1702 tree.branch("n0", Some("side".to_string()), 1_700_000_001_000)
1703 .unwrap();
1704 tree.append_message(
1705 crate::message::ChatMessage::user("side turn"),
1706 1_700_000_002_000,
1707 );
1708 tree
1709 }
1710
1711 #[test]
1715 fn session_tree_round_trips_losslessly_through_the_store() {
1716 let (store, tmp) = temp_store();
1717 store.save("sess", "t", "[]").unwrap();
1718 let tree = sample_tree();
1719 store.save_tree("sess", &tree).unwrap();
1720 let loaded = store.load_tree("sess").unwrap().expect("just saved");
1721
1722 assert_eq!(loaded.root, tree.root);
1723 assert_eq!(loaded.active_branch, tree.active_branch);
1724 assert_eq!(loaded.nodes.len(), tree.nodes.len());
1725 assert_eq!(
1726 loaded.linear_projection_of("main").unwrap().len(),
1727 tree.linear_projection_of("main").unwrap().len()
1728 );
1729 assert_eq!(
1730 loaded.linear_projection().unwrap().len(),
1731 tree.linear_projection().unwrap().len()
1732 );
1733 let _ = std::fs::remove_dir_all(&tmp);
1734 }
1735
1736 #[test]
1745 fn attack_tree_sidecar_preserves_message_metadata() {
1746 let (store, tmp) = temp_store();
1747 let mut m = crate::message::ChatMessage::user("turn with provenance");
1748 m.metadata
1749 .insert("phase".to_string(), "commentary".to_string());
1750 m.metadata
1751 .insert("turn_id".to_string(), "cx-turn-42".to_string());
1752 m.metadata
1753 .insert("pi_entry_id".to_string(), "entry-7".to_string());
1754 let mut tree = crate::session_tree::SessionTree::from_linear(
1755 &[m.clone(), crate::message::ChatMessage::assistant("ok")],
1756 1,
1757 );
1758 let preserved = tree.rewind("n0", 2).unwrap().unwrap();
1761 store.save("s", "t", "").unwrap();
1762 store.save_tree("s", &tree).unwrap();
1763 let loaded = store.load_tree("s").unwrap().unwrap();
1764 let recovered = loaded.linear_projection_of(&preserved).unwrap();
1765 assert_eq!(recovered.len(), 2);
1766 assert_eq!(
1768 recovered[0].metadata.get("phase"),
1769 Some(&"commentary".to_string())
1770 );
1771 assert_eq!(
1772 recovered[0].metadata.get("turn_id"),
1773 Some(&"cx-turn-42".to_string())
1774 );
1775 assert_eq!(
1776 recovered[0].metadata.get("pi_entry_id"),
1777 Some(&"entry-7".to_string())
1778 );
1779 let _ = std::fs::remove_dir_all(&tmp);
1780 }
1781
1782 #[test]
1791 fn attack_tree_sidecar_preserves_content_alongside_content_parts() {
1792 let (store, tmp) = temp_store();
1793 let mut m = crate::message::ChatMessage::user("plain content");
1794 m.content_parts = Some(vec![serde_json::json!({"type":"text","text":"part"})]);
1795 let tree = crate::session_tree::SessionTree::from_linear(&[m], 1);
1796 store.save("s", "t", "").unwrap();
1797 store.save_tree("s", &tree).unwrap();
1798 let loaded = store.load_tree("s").unwrap().unwrap();
1799 let got = &loaded.node("n0").unwrap().message;
1800 assert_eq!(got.content.as_deref(), Some("plain content"));
1801 assert_eq!(
1802 got.content_parts.as_ref().unwrap()[0]["text"],
1803 serde_json::json!("part")
1804 );
1805 let _ = std::fs::remove_dir_all(&tmp);
1806 }
1807
1808 #[test]
1814 fn session_tree_round_trip_preserves_full_message_fidelity_not_just_length() {
1815 let (store, tmp) = temp_store();
1816 let mut m0 = crate::message::ChatMessage::user("hello");
1817 m0.metadata
1818 .insert("promptSource".to_string(), "cli".to_string());
1819 m0.metadata.insert("isMeta".to_string(), "true".to_string());
1820 let mut m1 = crate::message::ChatMessage::assistant("hi");
1821 m1.content_parts = Some(vec![
1822 serde_json::json!({"type": "text", "text": "hi"}),
1823 serde_json::json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}),
1824 ]);
1825 m1.metadata
1826 .insert("review_findings".to_string(), "none".to_string());
1827 let tree = crate::session_tree::SessionTree::from_linear(
1828 &[m0.clone(), m1.clone()],
1829 1_700_000_000_000,
1830 );
1831 store.save("s", "t", "").unwrap();
1832 store.save_tree("s", &tree).unwrap();
1833 let loaded = store.load_tree("s").unwrap().unwrap();
1834
1835 let got0 = &loaded.node("n0").unwrap().message;
1836 assert_eq!(got0.content, m0.content);
1837 assert_eq!(got0.metadata, m0.metadata);
1838 assert_eq!(got0.content_parts, m0.content_parts);
1839
1840 let got1 = &loaded.node("n1").unwrap().message;
1841 assert_eq!(got1.content, m1.content);
1842 assert_eq!(got1.metadata, m1.metadata);
1843 assert_eq!(got1.content_parts, m1.content_parts);
1844
1845 let _ = std::fs::remove_dir_all(&tmp);
1846 }
1847
1848 #[test]
1857 fn tree_sidecar_save_load_save_is_byte_identical_after_f1() {
1858 let (store, tmp) = temp_store();
1859 let mut tree = crate::session_tree::SessionTree::from_linear(
1860 &[
1861 crate::message::ChatMessage::user("a"),
1862 crate::message::ChatMessage::assistant("b"),
1863 ],
1864 1_700_000_000_000,
1865 );
1866 tree.branch("n0", Some("side".to_string()), 2).unwrap();
1867 tree.append_message(crate::message::ChatMessage::user("c"), 3);
1868 tree.label("n1", "checkpoint").unwrap();
1869 store.save("s", "t", "").unwrap();
1870 store.save_tree("s", &tree).unwrap();
1871 let bytes1 = std::fs::read(tmp.join("s.tree.json")).unwrap();
1872 let loaded = store.load_tree("s").unwrap().unwrap();
1873 store.save_tree("s", &loaded).unwrap();
1874 let bytes2 = std::fs::read(tmp.join("s.tree.json")).unwrap();
1875 assert_eq!(bytes1, bytes2);
1876 let _ = std::fs::remove_dir_all(&tmp);
1877 }
1878
1879 #[test]
1883 fn corrupt_tree_json_still_errors_on_load_after_the_fixes() {
1884 let (store, tmp) = temp_store();
1885 store.save("s", "t", "").unwrap();
1886 std::fs::write(tmp.join("s.tree.json"), "{not json").unwrap();
1887 assert!(store.load_tree("s").is_err());
1888 std::fs::write(tmp.join("s.tree.json"), "").unwrap();
1889 assert!(store.load_tree("s").is_err());
1890 let _ = std::fs::remove_dir_all(&tmp);
1891 }
1892
1893 #[test]
1897 fn session_tree_is_absent_by_default() {
1898 let (store, tmp) = temp_store();
1899 store.save("sess", "t", "[]").unwrap();
1900 assert!(store.load_tree("sess").unwrap().is_none());
1901 assert!(!tmp.join("sess.tree.json").exists());
1902 let _ = std::fs::remove_dir_all(&tmp);
1903 }
1904
1905 #[test]
1909 fn session_tree_travels_with_archive_and_is_removed_by_delete() {
1910 let (store, tmp) = temp_store();
1911 store.save("sess", "t", "[]").unwrap();
1912 store.save_tree("sess", &sample_tree()).unwrap();
1913 assert!(tmp.join("sess.tree.json").exists());
1914
1915 store.archive("sess").unwrap();
1916 assert!(!tmp.join("sess.tree.json").exists());
1917 assert!(tmp.join("archived/sess.tree.json").exists());
1918 assert!(store.load_tree("sess").unwrap().is_some());
1920
1921 store.delete("sess").unwrap();
1922 assert!(!tmp.join("archived/sess.tree.json").exists());
1923 assert!(store.load_tree("sess").unwrap().is_none());
1924 let _ = std::fs::remove_dir_all(&tmp);
1925 }
1926
1927 #[test]
1932 fn fork_copies_the_session_tree_sidecar() {
1933 let (store, tmp) = temp_store();
1934 let transcript = "{\"role\":\"user\"}\n";
1935 store.save("orig", "t", transcript).unwrap();
1936 store.save_tree("orig", &sample_tree()).unwrap();
1937
1938 store
1939 .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
1940 .unwrap();
1941 let copied = store.load_tree("copy").unwrap().expect("copied");
1942 let orig = store.load_tree("orig").unwrap().unwrap();
1943 assert_eq!(copied.nodes.len(), orig.nodes.len());
1944 assert_eq!(copied.branches.len(), orig.branches.len());
1945 let _ = std::fs::remove_dir_all(&tmp);
1946 }
1947
1948 #[test]
1949 fn claude_runtime_manifest_roundtrips_and_travels_with_family_lifecycle() {
1950 let (store, tmp) = temp_store();
1951 store.save("sess", "t", "[]").unwrap();
1952 let source = concat!(
1953 "{\"type\":\"permission-mode\",\"permissionMode\":\"bypassPermissions\",",
1954 "\"timestamp\":\"2026-07-14T00:00:00Z\"}\n"
1955 );
1956 let session = crate::Session::from_claude_code_str(source).unwrap();
1957 let manifest =
1958 crate::claude_runtime_state::ClaudeRuntimeManifest::from_session(&session).unwrap();
1959 store
1960 .save_claude_runtime_manifest("sess", &manifest)
1961 .unwrap();
1962 assert_eq!(
1963 store.load_claude_runtime_manifest("sess").unwrap(),
1964 Some(manifest.clone())
1965 );
1966
1967 store
1968 .fork("sess", "copy", "copy", None, 1_700_000_000_000)
1969 .unwrap();
1970 assert_eq!(
1971 store.load_claude_runtime_manifest("copy").unwrap(),
1972 Some(manifest.clone())
1973 );
1974 store.archive("sess").unwrap();
1975 assert!(!tmp.join("sess.claude-runtime.json").exists());
1976 assert!(tmp.join("archived/sess.claude-runtime.json").exists());
1977 assert_eq!(
1978 store.load_claude_runtime_manifest("sess").unwrap(),
1979 Some(manifest)
1980 );
1981 store.delete("sess").unwrap();
1982 assert!(store
1983 .load_claude_runtime_manifest("sess")
1984 .unwrap()
1985 .is_none());
1986 let _ = std::fs::remove_dir_all(&tmp);
1987 }
1988}