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 #[serde(default)]
57 pub format_version: u32,
58}
59
60pub struct SessionStore {
62 root: PathBuf,
63}
64
65impl SessionStore {
66 pub fn at(root: impl Into<PathBuf>) -> Self {
71 SessionStore { root: root.into() }
72 }
73
74 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
76 let root = root.into();
77 std::fs::create_dir_all(&root)?;
78 Ok(Self::at(root))
79 }
80
81 fn validate_name(name: &str) -> Result<()> {
86 let bad = name.is_empty()
87 || name.contains('/')
88 || name.contains('\\')
89 || name.contains('\0')
90 || name.split(['/', '\\']).any(|c| c == ".." || c == ".")
91 || std::path::Path::new(name).is_absolute()
92 || name.trim() != name;
93 if bad {
94 return Err(Error::Other(format!("invalid session name: `{name}`")));
95 }
96 Ok(())
97 }
98
99 fn transcript_path(&self, name: &str, archived: bool) -> PathBuf {
100 self.dir(archived).join(format!("{name}.jsonl"))
101 }
102 fn meta_path(&self, name: &str, archived: bool) -> PathBuf {
103 self.dir(archived).join(format!("{name}.meta.json"))
104 }
105 fn sidecar_path_in(&self, name: &str, archived: bool) -> PathBuf {
108 self.dir(archived).join(format!("{name}.sidecar.jsonl"))
109 }
110 fn reduction_path(&self, name: &str, archived: bool) -> PathBuf {
113 self.dir(archived).join(format!("{name}.reduction.json"))
114 }
115 fn events_path(&self, name: &str, archived: bool) -> PathBuf {
121 self.dir(archived).join(format!("{name}.events.jsonl"))
122 }
123 fn usage_path(&self, name: &str, archived: bool) -> PathBuf {
127 self.dir(archived).join(format!("{name}.usage.jsonl"))
128 }
129 fn model_change_path(&self, name: &str, archived: bool) -> PathBuf {
134 self.dir(archived)
135 .join(format!("{name}.model_change.jsonl"))
136 }
137 fn git_metadata_path(&self, name: &str, archived: bool) -> PathBuf {
143 self.dir(archived).join(format!("{name}.git.json"))
144 }
145 fn goal_path(&self, name: &str, archived: bool) -> PathBuf {
150 self.dir(archived).join(format!("{name}.goal.json"))
151 }
152 fn fork_path(&self, name: &str, archived: bool) -> PathBuf {
158 self.dir(archived).join(format!("{name}.fork.json"))
159 }
160 fn tree_path(&self, name: &str, archived: bool) -> PathBuf {
170 self.dir(archived).join(format!("{name}.tree.json"))
171 }
172 fn journal_path_in(&self, name: &str, archived: bool) -> PathBuf {
181 self.dir(archived).join(format!("{name}.journal.jsonl"))
182 }
183 fn plan_path_in(&self, name: &str, archived: bool) -> PathBuf {
188 self.dir(archived).join(format!("{name}.plan.json"))
189 }
190 fn claude_runtime_path(&self, name: &str, archived: bool) -> PathBuf {
194 self.dir(archived)
195 .join(format!("{name}.claude-runtime.json"))
196 }
197 fn subagents_dir(&self, parent_name: &str, archived: bool) -> PathBuf {
206 self.dir(archived).join(format!("{parent_name}.subagents"))
207 }
208 fn subagent_transcript_path(
209 &self,
210 parent_name: &str,
211 child_id: &str,
212 archived: bool,
213 ) -> PathBuf {
214 self.subagents_dir(parent_name, archived)
215 .join(format!("{child_id}.sidecar.jsonl"))
216 }
217 fn subagent_lineage_path(&self, parent_name: &str, child_id: &str, archived: bool) -> PathBuf {
218 self.subagents_dir(parent_name, archived)
219 .join(format!("{child_id}.lineage.json"))
220 }
221 fn dir(&self, archived: bool) -> PathBuf {
222 if archived {
223 self.root.join("archived")
224 } else {
225 self.root.clone()
226 }
227 }
228
229 pub fn sidecar_path(&self, name: &str) -> PathBuf {
236 self.sidecar_path_in(name, false)
237 }
238
239 pub fn reduction_log_path(&self, name: &str) -> Result<PathBuf> {
243 Self::validate_name(name)?;
244 Ok(self.reduction_path(name, false))
245 }
246
247 pub fn session_path(&self, name: &str) -> Result<PathBuf> {
251 Self::validate_name(name)?;
252 Ok(self.transcript_path(name, false))
253 }
254
255 pub fn session_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
258 Self::validate_name(name)?;
259 Ok(self.transcript_path(name, archived))
260 }
261
262 pub fn sidecar_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
264 Self::validate_name(name)?;
265 Ok(self.sidecar_path_in(name, archived))
266 }
267
268 pub fn subagent_transcript_path_for(
270 &self,
271 parent_name: &str,
272 child_id: &str,
273 archived: bool,
274 ) -> Result<PathBuf> {
275 Self::validate_name(parent_name)?;
276 Self::validate_name(child_id)?;
277 Ok(self.subagent_transcript_path(parent_name, child_id, archived))
278 }
279
280 pub fn save_sidecar(&self, name: &str, sidecar_jsonl: &str) -> Result<()> {
283 Self::validate_name(name)?;
284 std::fs::create_dir_all(self.dir(false))?;
285 std::fs::write(self.sidecar_path_in(name, false), sidecar_jsonl)?;
286 Ok(())
287 }
288
289 pub fn load_sidecar(&self, name: &str) -> Result<Option<String>> {
293 Self::validate_name(name)?;
294 let active = self.sidecar_path_in(name, false);
295 let path = if active.exists() {
296 active
297 } else {
298 self.sidecar_path_in(name, true)
299 };
300 if !path.exists() {
301 return Ok(None);
302 }
303 Ok(Some(std::fs::read_to_string(path)?))
304 }
305
306 pub fn load_sidecar_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
308 Self::validate_name(name)?;
309 let path = self.sidecar_path_in(name, archived);
310 if !path.exists() {
311 return Ok(None);
312 }
313 Ok(Some(std::fs::read_to_string(path)?))
314 }
315
316 pub fn save_reduction_log(&self, name: &str, log: &ReductionLog) -> Result<()> {
319 Self::validate_name(name)?;
320 std::fs::create_dir_all(self.dir(false))?;
321 let json = serde_json::to_string(log).map_err(Error::Decode)?;
322 std::fs::write(self.reduction_path(name, false), json)?;
323 Ok(())
324 }
325
326 pub fn load_reduction_log(&self, name: &str) -> Result<Option<ReductionLog>> {
329 Self::validate_name(name)?;
330 let active = self.reduction_path(name, false);
331 let path = if active.exists() {
332 active
333 } else {
334 self.reduction_path(name, true)
335 };
336 if !path.exists() {
337 return Ok(None);
338 }
339 let text = std::fs::read_to_string(path)?;
340 Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
341 }
342
343 pub fn load_reduction_log_from(
345 &self,
346 name: &str,
347 archived: bool,
348 ) -> Result<Option<ReductionLog>> {
349 Self::validate_name(name)?;
350 let path = self.reduction_path(name, archived);
351 if !path.exists() {
352 return Ok(None);
353 }
354 let text = std::fs::read_to_string(path)?;
355 Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
356 }
357
358 pub fn save_usage_log(
364 &self,
365 name: &str,
366 records: &[crate::usage_log::UsageRecord],
367 ) -> Result<()> {
368 Self::validate_name(name)?;
369 std::fs::create_dir_all(self.dir(false))?;
370 let jsonl = crate::usage_log::to_jsonl(records)?;
371 std::fs::write(self.usage_path(name, false), jsonl)?;
372 Ok(())
373 }
374
375 pub fn load_usage_log(&self, name: &str) -> Result<Vec<crate::usage_log::UsageRecord>> {
378 Self::validate_name(name)?;
379 let active = self.usage_path(name, false);
380 let path = if active.exists() {
381 active
382 } else {
383 self.usage_path(name, true)
384 };
385 if !path.exists() {
386 return Ok(Vec::new());
387 }
388 crate::usage_log::from_jsonl(&std::fs::read_to_string(path)?)
389 }
390
391 pub fn save_goal(&self, name: &str, goal: &crate::goals::GoalRecord) -> Result<()> {
393 Self::validate_name(name)?;
394 std::fs::create_dir_all(self.dir(false))?;
395 let json = serde_json::to_string_pretty(goal).map_err(crate::Error::Decode)?;
396 std::fs::write(self.goal_path(name, false), json)?;
397 Ok(())
398 }
399
400 pub fn load_goal(&self, name: &str) -> Result<Option<crate::goals::GoalRecord>> {
403 Self::validate_name(name)?;
404 let active = self.goal_path(name, false);
405 let path = if active.exists() {
406 active
407 } else {
408 self.goal_path(name, true)
409 };
410 if !path.exists() {
411 return Ok(None);
412 }
413 let text = std::fs::read_to_string(path)?;
414 if text.trim().is_empty() {
415 return Ok(None);
416 }
417 Ok(Some(
418 serde_json::from_str(&text).map_err(crate::Error::Decode)?,
419 ))
420 }
421
422 pub fn clear_goal(&self, name: &str) -> Result<()> {
425 Self::validate_name(name)?;
426 for archived in [false, true] {
427 let p = self.goal_path(name, archived);
428 if p.exists() {
429 std::fs::remove_file(p)?;
430 }
431 }
432 Ok(())
433 }
434
435 pub fn save_turn_records(
443 &self,
444 name: &str,
445 records: &[crate::turn_record::TurnRecord],
446 ) -> Result<()> {
447 Self::validate_name(name)?;
448 std::fs::create_dir_all(self.dir(false))?;
449 let jsonl = crate::turn_record::to_jsonl(records)?;
450 std::fs::write(self.events_path(name, false), jsonl)?;
451 Ok(())
452 }
453
454 pub fn load_turn_records(&self, name: &str) -> Result<Vec<crate::turn_record::TurnRecord>> {
457 Self::validate_name(name)?;
458 let active = self.events_path(name, false);
459 let path = if active.exists() {
460 active
461 } else {
462 self.events_path(name, true)
463 };
464 if !path.exists() {
465 return Ok(Vec::new());
466 }
467 crate::turn_record::from_jsonl(&std::fs::read_to_string(path)?)
468 }
469
470 pub fn save_model_change_log(
475 &self,
476 name: &str,
477 records: &[crate::model_change::ModelChangeRecord],
478 ) -> Result<()> {
479 Self::validate_name(name)?;
480 std::fs::create_dir_all(self.dir(false))?;
481 let jsonl = crate::model_change::to_jsonl(records)?;
482 std::fs::write(self.model_change_path(name, false), jsonl)?;
483 Ok(())
484 }
485
486 pub fn load_model_change_log(
491 &self,
492 name: &str,
493 ) -> Result<Vec<crate::model_change::ModelChangeRecord>> {
494 Self::validate_name(name)?;
495 let active = self.model_change_path(name, false);
496 let path = if active.exists() {
497 active
498 } else {
499 self.model_change_path(name, true)
500 };
501 if !path.exists() {
502 return Ok(Vec::new());
503 }
504 crate::model_change::from_jsonl(&std::fs::read_to_string(path)?)
505 }
506
507 pub fn save_git_metadata(
511 &self,
512 name: &str,
513 record: &crate::git_metadata::GitMetadataRecord,
514 ) -> Result<()> {
515 Self::validate_name(name)?;
516 std::fs::create_dir_all(self.dir(false))?;
517 let json = crate::git_metadata::to_json(record)?;
518 std::fs::write(self.git_metadata_path(name, false), json)?;
519 Ok(())
520 }
521
522 pub fn load_git_metadata(
526 &self,
527 name: &str,
528 ) -> Result<Option<crate::git_metadata::GitMetadataRecord>> {
529 Self::validate_name(name)?;
530 let active = self.git_metadata_path(name, false);
531 let path = if active.exists() {
532 active
533 } else {
534 self.git_metadata_path(name, true)
535 };
536 if !path.exists() {
537 return Ok(None);
538 }
539 Ok(Some(crate::git_metadata::from_json(
540 &std::fs::read_to_string(path)?,
541 )?))
542 }
543
544 pub fn save(&self, name: &str, title: &str, transcript_jsonl: &str) -> Result<()> {
546 Self::validate_name(name)?;
547 std::fs::create_dir_all(self.dir(false))?;
548 std::fs::write(self.transcript_path(name, false), transcript_jsonl)?;
549 let info = SessionInfo {
550 name: name.to_string(),
551 title: title.to_string(),
552 archived: false,
553 ..Default::default()
554 };
555 std::fs::write(
556 self.meta_path(name, false),
557 serde_json::to_string(&info).map_err(Error::Decode)?,
558 )?;
559 Ok(())
560 }
561
562 pub fn load(&self, name: &str) -> Result<String> {
564 Self::validate_name(name)?;
565 let active = self.transcript_path(name, false);
566 let path = if active.exists() {
567 active
568 } else {
569 self.transcript_path(name, true)
570 };
571 Ok(std::fs::read_to_string(path)?)
572 }
573
574 pub fn load_from(&self, name: &str, archived: bool) -> Result<String> {
576 Self::validate_name(name)?;
577 Ok(std::fs::read_to_string(
578 self.transcript_path(name, archived),
579 )?)
580 }
581
582 pub fn load_if_present_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
585 Self::validate_name(name)?;
586 let path = self.transcript_path(name, archived);
587 match std::fs::symlink_metadata(&path) {
588 Ok(_) => Ok(Some(std::fs::read_to_string(path)?)),
589 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
590 Err(error) => Err(error.into()),
591 }
592 }
593
594 pub fn load_if_present(&self, name: &str) -> Result<Option<String>> {
602 Self::validate_name(name)?;
603 for archived in [false, true] {
604 let path = self.transcript_path(name, archived);
605 match std::fs::symlink_metadata(&path) {
606 Ok(_) => return Ok(Some(std::fs::read_to_string(path)?)),
607 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
608 Err(e) => return Err(e.into()),
609 }
610 }
611 Ok(None)
612 }
613
614 pub fn list(&self) -> Vec<SessionInfo> {
616 let mut out = Vec::new();
617 for archived in [false, true] {
618 let dir = self.dir(archived);
619 let Ok(rd) = std::fs::read_dir(&dir) else {
620 continue;
621 };
622 for entry in rd.flatten() {
623 let p = entry.path();
624 if p.extension().and_then(|e| e.to_str()) != Some("json") {
625 continue;
626 }
627 if !p.to_string_lossy().ends_with(".meta.json") {
629 continue;
630 }
631 if let Ok(text) = std::fs::read_to_string(&p) {
632 if let Ok(mut info) = serde_json::from_str::<SessionInfo>(&text) {
633 info.archived = archived;
634 out.push(info);
635 }
636 }
637 }
638 }
639 out.sort_by(|a, b| a.name.cmp(&b.name));
640 out
641 }
642
643 pub fn archive(&self, name: &str) -> Result<()> {
648 Self::validate_name(name)?;
649 std::fs::create_dir_all(self.dir(true))?;
650 for (from, to) in [
651 (
652 self.transcript_path(name, false),
653 self.transcript_path(name, true),
654 ),
655 (self.meta_path(name, false), self.meta_path(name, true)),
656 (
657 self.sidecar_path_in(name, false),
658 self.sidecar_path_in(name, true),
659 ),
660 (
661 self.reduction_path(name, false),
662 self.reduction_path(name, true),
663 ),
664 (self.events_path(name, false), self.events_path(name, true)),
665 (self.usage_path(name, false), self.usage_path(name, true)),
666 (
667 self.model_change_path(name, false),
668 self.model_change_path(name, true),
669 ),
670 (
671 self.git_metadata_path(name, false),
672 self.git_metadata_path(name, true),
673 ),
674 (self.goal_path(name, false), self.goal_path(name, true)),
675 (self.fork_path(name, false), self.fork_path(name, true)),
676 (self.tree_path(name, false), self.tree_path(name, true)),
677 (
678 self.journal_path_in(name, false),
679 self.journal_path_in(name, true),
680 ),
681 (
682 self.plan_path_in(name, false),
683 self.plan_path_in(name, true),
684 ),
685 (
686 self.claude_runtime_path(name, false),
687 self.claude_runtime_path(name, true),
688 ),
689 ] {
690 if from.exists() {
691 std::fs::rename(&from, &to)?;
692 }
693 }
694 let subagents_from = self.subagents_dir(name, false);
699 if subagents_from.exists() {
700 std::fs::rename(&subagents_from, self.subagents_dir(name, true))?;
701 }
702 Ok(())
703 }
704
705 pub fn delete(&self, name: &str) -> Result<()> {
710 Self::validate_name(name)?;
711 for archived in [false, true] {
712 for p in [
713 self.transcript_path(name, archived),
714 self.meta_path(name, archived),
715 self.sidecar_path_in(name, archived),
716 self.reduction_path(name, archived),
717 self.events_path(name, archived),
718 self.usage_path(name, archived),
719 self.model_change_path(name, archived),
720 self.git_metadata_path(name, archived),
721 self.goal_path(name, archived),
722 self.fork_path(name, archived),
723 self.tree_path(name, archived),
724 self.journal_path_in(name, archived),
725 self.plan_path_in(name, archived),
726 self.claude_runtime_path(name, archived),
727 ] {
728 if p.exists() {
729 std::fs::remove_file(p)?;
730 }
731 }
732 let subagents_dir = self.subagents_dir(name, archived);
735 if subagents_dir.exists() {
736 std::fs::remove_dir_all(&subagents_dir)?;
737 }
738 }
739 Ok(())
740 }
741
742 pub fn set_title(&self, name: &str, title: &str) -> Result<()> {
746 Self::validate_name(name)?;
747 for archived in [false, true] {
748 let mp = self.meta_path(name, archived);
749 if mp.exists() {
750 let mut info: SessionInfo = std::fs::read_to_string(&mp)
751 .ok()
752 .and_then(|t| serde_json::from_str(&t).ok())
753 .unwrap_or_else(|| SessionInfo {
754 name: name.to_string(),
755 ..Default::default()
756 });
757 info.title = title.to_string();
758 info.archived = archived;
759 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
760 return Ok(());
761 }
762 }
763 Err(Error::Other(format!("no session named `{name}`")))
764 }
765
766 pub fn root(&self) -> &Path {
768 &self.root
769 }
770
771 pub fn transcript_mtime(&self, name: &str) -> Option<std::time::SystemTime> {
779 Self::validate_name(name).ok()?;
780 let active = self.transcript_path(name, false);
781 let path = if active.exists() {
782 active
783 } else {
784 self.transcript_path(name, true)
785 };
786 std::fs::metadata(path).ok()?.modified().ok()
787 }
788
789 pub fn set_reduction_stats(
796 &self,
797 name: &str,
798 title: &str,
799 full_bytes: u64,
800 view_bytes: u64,
801 stub_count: u32,
802 ) -> Result<()> {
803 Self::validate_name(name)?;
804 std::fs::create_dir_all(self.dir(false))?;
805 let mp = self.meta_path(name, false);
806 let mut info: SessionInfo = std::fs::read_to_string(&mp)
807 .ok()
808 .and_then(|t| serde_json::from_str(&t).ok())
809 .unwrap_or_else(|| SessionInfo {
810 name: name.to_string(),
811 title: title.to_string(),
812 ..Default::default()
813 });
814 info.reduced = true;
815 info.full_bytes = full_bytes;
816 info.view_bytes = view_bytes;
817 info.stub_count = stub_count;
818 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
819 Ok(())
820 }
821
822 pub fn fork(
833 &self,
834 from: &str,
835 to: &str,
836 title: &str,
837 truncate_at_message: Option<usize>,
838 timestamp_ms: i64,
839 ) -> Result<ForkProvenance> {
840 Self::validate_name(from)?;
841 Self::validate_name(to)?;
842 let jsonl = self.load(from)?;
843 if let Some(n) = truncate_at_message {
844 Self::validate_safe_truncation(&jsonl, n)?;
845 }
846 let content = match truncate_at_message {
847 Some(n) => {
848 let lines: Vec<&str> = jsonl.lines().take(n).collect();
849 if lines.is_empty() {
850 String::new()
851 } else {
852 let mut s = lines.join("\n");
853 s.push('\n');
854 s
855 }
856 }
857 None => jsonl,
858 };
859 self.save(to, title, &content)?;
860
861 self.copy_family_member(from, to, Self::sidecar_path_in)?;
877 self.copy_family_member(from, to, Self::reduction_path)?;
878 self.copy_family_member(from, to, Self::usage_path)?;
879 self.copy_family_member(from, to, Self::model_change_path)?;
880 self.copy_family_member(from, to, Self::git_metadata_path)?;
881 self.copy_family_member(from, to, Self::tree_path)?;
886 self.copy_family_member(from, to, Self::claude_runtime_path)?;
887
888 let provenance = ForkProvenance {
889 forked_from: from.to_string(),
890 forked_at_message: truncate_at_message,
891 timestamp_ms,
892 };
893 self.save_fork_provenance(to, &provenance)?;
894 Ok(provenance)
895 }
896
897 fn copy_family_member(
905 &self,
906 from: &str,
907 to: &str,
908 path_of: impl Fn(&Self, &str, bool) -> PathBuf,
909 ) -> Result<()> {
910 let active = path_of(self, from, false);
911 let src = if active.exists() {
912 active
913 } else {
914 let archived = path_of(self, from, true);
915 if !archived.exists() {
916 return Ok(());
917 }
918 archived
919 };
920 std::fs::create_dir_all(self.dir(false))?;
921 std::fs::copy(&src, path_of(self, to, false))?;
922 Ok(())
923 }
924
925 fn validate_safe_truncation(jsonl: &str, n: usize) -> Result<()> {
935 let kept: Vec<crate::message::ChatMessage> = jsonl
936 .lines()
937 .take(n)
938 .filter(|l| !l.trim().is_empty())
939 .map(|l| serde_json::from_str(l).map_err(Error::Decode))
940 .collect::<Result<_>>()?;
941 let mut pending: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
942 for m in &kept {
943 if let Some(calls) = &m.tool_calls {
944 for c in calls {
945 pending.insert(c.id.clone());
946 }
947 }
948 if let Some(id) = &m.tool_call_id {
949 pending.remove(id);
950 }
951 }
952 if !pending.is_empty() {
953 return Err(Error::Other(format!(
954 "fork --at {n} would cut off {} unresolved tool_call result(s) ({}) — \
955 choose a boundary at or after the assistant's tool_calls message AND \
956 all of its tool results",
957 pending.len(),
958 pending.into_iter().collect::<Vec<_>>().join(", "),
959 )));
960 }
961 Ok(())
962 }
963
964 pub fn save_fork_provenance(&self, name: &str, provenance: &ForkProvenance) -> Result<()> {
967 Self::validate_name(name)?;
968 std::fs::create_dir_all(self.dir(false))?;
969 let json = serde_json::to_string(provenance).map_err(Error::Decode)?;
970 std::fs::write(self.fork_path(name, false), json)?;
971 Ok(())
972 }
973
974 pub fn load_fork_provenance(&self, name: &str) -> Result<Option<ForkProvenance>> {
977 Self::validate_name(name)?;
978 let active = self.fork_path(name, false);
979 let path = if active.exists() {
980 active
981 } else {
982 self.fork_path(name, true)
983 };
984 if !path.exists() {
985 return Ok(None);
986 }
987 Ok(Some(
988 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
989 ))
990 }
991
992 pub fn save_tree(&self, name: &str, tree: &crate::session_tree::SessionTree) -> Result<()> {
997 Self::validate_name(name)?;
998 std::fs::create_dir_all(self.dir(false))?;
999 let json = serde_json::to_string(tree).map_err(Error::Decode)?;
1000 std::fs::write(self.tree_path(name, false), json)?;
1001 Ok(())
1002 }
1003
1004 pub fn save_claude_runtime_manifest(
1007 &self,
1008 name: &str,
1009 manifest: &crate::claude_runtime_state::ClaudeRuntimeManifest,
1010 ) -> Result<()> {
1011 Self::validate_name(name)?;
1012 std::fs::create_dir_all(self.dir(false))?;
1013 let json = serde_json::to_string(manifest).map_err(Error::Decode)?;
1014 std::fs::write(self.claude_runtime_path(name, false), json)?;
1015 Ok(())
1016 }
1017
1018 pub fn load_claude_runtime_manifest(
1020 &self,
1021 name: &str,
1022 ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
1023 Self::validate_name(name)?;
1024 let active = self.claude_runtime_path(name, false);
1025 let path = if active.exists() {
1026 active
1027 } else {
1028 self.claude_runtime_path(name, true)
1029 };
1030 if !path.exists() {
1031 return Ok(None);
1032 }
1033 Ok(Some(
1034 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1035 ))
1036 }
1037
1038 pub fn load_claude_runtime_manifest_from(
1040 &self,
1041 name: &str,
1042 archived: bool,
1043 ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
1044 Self::validate_name(name)?;
1045 let path = self.claude_runtime_path(name, archived);
1046 if !path.exists() {
1047 return Ok(None);
1048 }
1049 Ok(Some(
1050 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1051 ))
1052 }
1053
1054 pub fn load_tree(&self, name: &str) -> Result<Option<crate::session_tree::SessionTree>> {
1059 Self::validate_name(name)?;
1060 let active = self.tree_path(name, false);
1061 let path = if active.exists() {
1062 active
1063 } else {
1064 self.tree_path(name, true)
1065 };
1066 if !path.exists() {
1067 return Ok(None);
1068 }
1069 Ok(Some(
1070 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1071 ))
1072 }
1073
1074 pub fn save_subagent_transcript(
1082 &self,
1083 parent_name: &str,
1084 child_id: &str,
1085 sidecar_jsonl: &str,
1086 ) -> Result<()> {
1087 Self::validate_name(parent_name)?;
1088 Self::validate_name(child_id)?;
1089 std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
1090 std::fs::write(
1091 self.subagent_transcript_path(parent_name, child_id, false),
1092 sidecar_jsonl,
1093 )?;
1094 Ok(())
1095 }
1096
1097 pub fn save_imported_subagents(
1106 &self,
1107 parent_name: &str,
1108 subagents: &[crate::session::Session],
1109 ) -> Result<usize> {
1110 Self::validate_name(parent_name)?;
1111
1112 let mut seen = std::collections::BTreeSet::new();
1113 for child in subagents {
1114 let child_id = child.meta.agent_id.as_deref().ok_or_else(|| {
1115 Error::Other(format!(
1116 "cannot persist an imported subagent for `{parent_name}` without an agent id"
1117 ))
1118 })?;
1119 Self::validate_name(child_id)?;
1120 if !seen.insert(child_id.to_string()) {
1121 return Err(Error::Other(format!(
1122 "duplicate imported subagent id `{child_id}` for `{parent_name}`"
1123 )));
1124 }
1125 }
1126
1127 for child in subagents {
1132 let child_id = child.meta.agent_id.as_deref().expect("validated above");
1133 self.save_subagent_transcript(parent_name, child_id, &child.to_native_jsonl_v2(&[]))?;
1134 }
1135 Ok(subagents.len())
1136 }
1137
1138 pub fn load_subagent_transcript(
1141 &self,
1142 parent_name: &str,
1143 child_id: &str,
1144 ) -> Result<Option<String>> {
1145 Self::validate_name(parent_name)?;
1146 Self::validate_name(child_id)?;
1147 let active = self.subagent_transcript_path(parent_name, child_id, false);
1148 let path = if active.exists() {
1149 active
1150 } else {
1151 self.subagent_transcript_path(parent_name, child_id, true)
1152 };
1153 if !path.exists() {
1154 return Ok(None);
1155 }
1156 Ok(Some(std::fs::read_to_string(path)?))
1157 }
1158
1159 pub fn load_subagent_transcript_from(
1161 &self,
1162 parent_name: &str,
1163 child_id: &str,
1164 archived: bool,
1165 ) -> Result<Option<String>> {
1166 Self::validate_name(parent_name)?;
1167 Self::validate_name(child_id)?;
1168 let path = self.subagent_transcript_path(parent_name, child_id, archived);
1169 if !path.exists() {
1170 return Ok(None);
1171 }
1172 Ok(Some(std::fs::read_to_string(path)?))
1173 }
1174
1175 pub fn save_subagent_lineage(
1179 &self,
1180 parent_name: &str,
1181 child_id: &str,
1182 record: &crate::subagents::SubagentLineage,
1183 ) -> Result<()> {
1184 Self::validate_name(parent_name)?;
1185 Self::validate_name(child_id)?;
1186 std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
1187 let json = serde_json::to_string(record).map_err(Error::Decode)?;
1188 std::fs::write(
1189 self.subagent_lineage_path(parent_name, child_id, false),
1190 json,
1191 )?;
1192 Ok(())
1193 }
1194
1195 pub fn load_subagent_lineage(
1198 &self,
1199 parent_name: &str,
1200 child_id: &str,
1201 ) -> Result<Option<crate::subagents::SubagentLineage>> {
1202 Self::validate_name(parent_name)?;
1203 Self::validate_name(child_id)?;
1204 let active = self.subagent_lineage_path(parent_name, child_id, false);
1205 let path = if active.exists() {
1206 active
1207 } else {
1208 self.subagent_lineage_path(parent_name, child_id, true)
1209 };
1210 if !path.exists() {
1211 return Ok(None);
1212 }
1213 Ok(Some(
1214 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1215 ))
1216 }
1217
1218 pub fn list_subagent_ids(&self, parent_name: &str) -> Result<Vec<String>> {
1223 Self::validate_name(parent_name)?;
1224 let mut ids = std::collections::BTreeSet::new();
1225 for archived in [false, true] {
1226 let dir = self.subagents_dir(parent_name, archived);
1227 let Ok(rd) = std::fs::read_dir(&dir) else {
1228 continue;
1229 };
1230 for entry in rd.flatten() {
1231 let p = entry.path();
1232 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1233 if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1234 ids.insert(id.to_string());
1235 }
1236 }
1237 }
1238 }
1239 Ok(ids.into_iter().collect())
1240 }
1241
1242 pub fn list_subagent_ids_from(&self, parent_name: &str, archived: bool) -> Result<Vec<String>> {
1244 Self::validate_name(parent_name)?;
1245 let mut ids = std::collections::BTreeSet::new();
1246 let dir = self.subagents_dir(parent_name, archived);
1247 let Ok(rd) = std::fs::read_dir(&dir) else {
1248 return Ok(Vec::new());
1249 };
1250 for entry in rd.flatten() {
1251 let p = entry.path();
1252 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1253 if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1254 ids.insert(id.to_string());
1255 }
1256 }
1257 }
1258 Ok(ids.into_iter().collect())
1259 }
1260
1261 pub fn journal_path(&self, name: &str) -> Result<PathBuf> {
1265 Self::validate_name(name)?;
1266 Ok(self.journal_path_in(name, false))
1267 }
1268
1269 pub fn open_journal(&self, name: &str) -> Result<crate::session_journal::SessionJournal> {
1271 Self::validate_name(name)?;
1272 crate::session_journal::SessionJournal::open_append(&self.journal_path_in(name, false))
1273 }
1274
1275 pub fn load_journal(&self, name: &str) -> Result<Option<crate::session_journal::JournalState>> {
1279 Self::validate_name(name)?;
1280 for archived in [false, true] {
1281 let path = self.journal_path_in(name, archived);
1282 if path.exists() {
1283 return crate::session_journal::replay(&path);
1284 }
1285 }
1286 Ok(None)
1287 }
1288
1289 pub fn save_plan(&self, name: &str, plan: &[crate::session_journal::PlanEntry]) -> Result<()> {
1292 Self::validate_name(name)?;
1293 std::fs::create_dir_all(self.dir(false))?;
1294 std::fs::write(
1295 self.plan_path_in(name, false),
1296 serde_json::to_string(plan).map_err(Error::Decode)?,
1297 )?;
1298 Ok(())
1299 }
1300
1301 pub fn load_plan(&self, name: &str) -> Result<Option<Vec<crate::session_journal::PlanEntry>>> {
1303 Self::validate_name(name)?;
1304 for archived in [false, true] {
1305 let path = self.plan_path_in(name, archived);
1306 if path.exists() {
1307 let text = std::fs::read_to_string(path)?;
1308 return Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?));
1309 }
1310 }
1311 Ok(None)
1312 }
1313
1314 pub fn rename(&self, from: &str, to: &str) -> Result<()> {
1328 Self::validate_name(from)?;
1329 Self::validate_name(to)?;
1330 if from == to {
1331 return Ok(());
1332 }
1333 let mut moves: Vec<(PathBuf, PathBuf)> = Vec::new();
1334 let mut found = false;
1335 for archived in [false, true] {
1336 let dir = self.dir(archived);
1337 let Ok(rd) = std::fs::read_dir(&dir) else {
1338 continue;
1339 };
1340 for entry in rd.flatten() {
1341 let file = entry.file_name();
1342 let Some(file) = file.to_str() else { continue };
1343 let Some(suffix) = file.strip_prefix(&format!("{from}.")) else {
1347 continue;
1348 };
1349 found = true;
1350 let target = dir.join(format!("{to}.{suffix}"));
1351 if target.exists() {
1352 return Err(Error::Other(format!(
1353 "cannot rename `{from}` to `{to}`: `{}` already exists",
1354 target.display()
1355 )));
1356 }
1357 moves.push((entry.path(), target));
1358 }
1359 }
1360 if !found {
1361 return Err(Error::Other(format!("no session named `{from}`")));
1362 }
1363 for (src, dst) in &moves {
1364 std::fs::rename(src, dst)?;
1365 }
1366 for archived in [false, true] {
1369 let mp = self.meta_path(to, archived);
1370 if mp.exists() {
1371 if let Some(mut info) = std::fs::read_to_string(&mp)
1372 .ok()
1373 .and_then(|t| serde_json::from_str::<SessionInfo>(&t).ok())
1374 {
1375 info.name = to.to_string();
1376 info.archived = archived;
1377 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
1378 }
1379 }
1380 }
1381 if self.journal_path_in(to, false).exists() {
1384 let mut journal = self.open_journal(to)?;
1385 journal.append(crate::session_journal::JournalOp::Rename {
1386 from: from.to_string(),
1387 to: to.to_string(),
1388 })?;
1389 }
1390 self.invalidate_index();
1391 Ok(())
1392 }
1393
1394 pub const FORMAT_VERSION: u32 = 2;
1400
1401 pub fn format_version(&self, name: &str) -> u32 {
1404 self.list()
1405 .into_iter()
1406 .find(|s| s.name == name)
1407 .map(|s| s.format_version)
1408 .unwrap_or(0)
1409 }
1410
1411 pub fn upgrade_in_place(&self, name: &str) -> Result<Option<FormatUpgrade>> {
1427 Self::validate_name(name)?;
1428 let current = self.format_version(name);
1429 if current >= Self::FORMAT_VERSION {
1430 return Ok(None);
1431 }
1432 let (path, archived) = {
1433 let active = self.transcript_path(name, false);
1434 if active.exists() {
1435 (active, false)
1436 } else {
1437 let arch = self.transcript_path(name, true);
1438 if !arch.exists() {
1439 return Ok(None);
1440 }
1441 (arch, true)
1442 }
1443 };
1444 let original = std::fs::read_to_string(&path)?;
1445 let mut upgraded = String::with_capacity(original.len());
1446 let mut lines = 0usize;
1447 for line in original.lines() {
1448 if line.trim().is_empty() {
1449 continue;
1450 }
1451 let msg: crate::ChatMessage =
1452 serde_json::from_str(line.trim()).map_err(Error::Decode)?;
1453 if lines > 0 {
1454 upgraded.push('\n');
1455 }
1456 upgraded.push_str(&serde_json::to_string(&msg).map_err(Error::Decode)?);
1457 lines += 1;
1458 }
1459 let backup_name = format!("{name}.v{current}.jsonl");
1460 let backup = self.dir(archived).join(&backup_name);
1461 std::fs::write(&backup, original.as_bytes())?;
1464 let rewritten = upgraded != original;
1465 if rewritten {
1466 std::fs::write(&path, upgraded.as_bytes())?;
1467 }
1468 self.set_format_version(name, Self::FORMAT_VERSION)?;
1469 if self.journal_path_in(name, false).exists() {
1470 let mut journal = self.open_journal(name)?;
1471 journal.append(crate::session_journal::JournalOp::Upgrade {
1472 from_version: current,
1473 to_version: Self::FORMAT_VERSION,
1474 original: backup_name.clone(),
1475 })?;
1476 }
1477 self.invalidate_index();
1478 Ok(Some(FormatUpgrade {
1479 from_version: current,
1480 to_version: Self::FORMAT_VERSION,
1481 original: backup_name,
1482 messages: lines,
1483 rewritten,
1484 }))
1485 }
1486
1487 pub fn set_format_version(&self, name: &str, version: u32) -> Result<()> {
1490 Self::validate_name(name)?;
1491 for archived in [false, true] {
1492 let mp = self.meta_path(name, archived);
1493 if mp.exists() {
1494 let mut info: SessionInfo = std::fs::read_to_string(&mp)
1495 .ok()
1496 .and_then(|t| serde_json::from_str(&t).ok())
1497 .unwrap_or_else(|| SessionInfo {
1498 name: name.to_string(),
1499 ..Default::default()
1500 });
1501 info.format_version = version;
1502 info.archived = archived;
1503 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
1504 return Ok(());
1505 }
1506 }
1507 Err(Error::Other(format!("no session named `{name}`")))
1508 }
1509
1510 pub fn index_path(&self) -> PathBuf {
1516 self.root.join(".session-index.json")
1517 }
1518
1519 pub fn invalidate_index(&self) {
1523 let _ = std::fs::remove_file(self.index_path());
1524 }
1525
1526 pub fn index(&self) -> SessionIndex {
1538 let cached: std::collections::HashMap<String, SessionIndexEntry> =
1539 std::fs::read_to_string(self.index_path())
1540 .ok()
1541 .and_then(|t| serde_json::from_str::<SessionIndexFile>(&t).ok())
1542 .filter(|f| f.version == SESSION_INDEX_VERSION)
1543 .map(|f| f.entries.into_iter().map(|e| (e.name.clone(), e)).collect())
1544 .unwrap_or_default();
1545 let mut out = SessionIndex::default();
1546 for info in self.list() {
1547 let path = self.transcript_path(&info.name, info.archived);
1548 let (mtime_nanos, size) = match std::fs::metadata(&path) {
1549 Ok(m) => (
1550 m.modified()
1551 .ok()
1552 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1553 .map(|d| d.as_nanos() as u64)
1554 .unwrap_or(0),
1555 m.len(),
1556 ),
1557 Err(_) => (0, 0),
1558 };
1559 match cached.get(&info.name) {
1560 Some(hit)
1561 if hit.mtime_nanos == mtime_nanos
1562 && hit.size == size
1563 && hit.archived == info.archived
1564 && hit.title == info.title =>
1565 {
1566 out.reused += 1;
1567 out.entries.push(hit.clone());
1568 }
1569 _ => {
1570 out.rederived += 1;
1571 out.entries
1572 .push(self.derive_index_entry(&info, mtime_nanos, size));
1573 }
1574 }
1575 }
1576 out.entries
1577 .sort_by(|a, b| b.time_secs.cmp(&a.time_secs).then(a.name.cmp(&b.name)));
1578 let file = SessionIndexFile {
1579 version: SESSION_INDEX_VERSION,
1580 entries: out.entries.clone(),
1581 };
1582 if self.root.is_dir() {
1585 if let Ok(text) = serde_json::to_string(&file) {
1586 let _ = std::fs::write(self.index_path(), text);
1587 }
1588 }
1589 out
1590 }
1591
1592 fn derive_index_entry(
1593 &self,
1594 info: &SessionInfo,
1595 mtime_nanos: u64,
1596 size: u64,
1597 ) -> SessionIndexEntry {
1598 let text = self
1599 .load_if_present_from(&info.name, info.archived)
1600 .ok()
1601 .flatten()
1602 .unwrap_or_default();
1603 let mut messages = 0usize;
1604 let mut preview = String::new();
1605 for line in text.lines() {
1606 if line.trim().is_empty() {
1607 continue;
1608 }
1609 messages += 1;
1610 if preview.is_empty() {
1611 if let Ok(msg) = serde_json::from_str::<crate::ChatMessage>(line.trim()) {
1612 if msg.role == crate::Role::User {
1613 if let Some(c) = msg.content.as_deref() {
1614 preview = preview_line(c);
1615 }
1616 }
1617 }
1618 }
1619 }
1620 SessionIndexEntry {
1621 name: info.name.clone(),
1622 title: info.title.clone(),
1623 archived: info.archived,
1624 time_secs: derive_session_time(&info.name, mtime_nanos / 1_000_000),
1625 preview,
1626 messages,
1627 mtime_nanos,
1628 size,
1629 }
1630 }
1631
1632 pub fn prune_expired(
1640 &self,
1641 retention_days: u32,
1642 now: std::time::SystemTime,
1643 ) -> Result<Vec<String>> {
1644 if retention_days == 0 {
1645 return Ok(Vec::new());
1646 }
1647 let Some(cutoff) = now.checked_sub(std::time::Duration::from_secs(
1648 retention_days as u64 * 86_400,
1649 )) else {
1650 return Ok(Vec::new());
1651 };
1652 let mut pruned = Vec::new();
1653 for info in self.list() {
1654 if !info.archived {
1655 continue;
1656 }
1657 let Some(mtime) = self.transcript_mtime(&info.name) else {
1658 continue;
1659 };
1660 if mtime < cutoff {
1661 self.delete(&info.name)?;
1662 pruned.push(info.name);
1663 }
1664 }
1665 Ok(pruned)
1666 }
1667}
1668
1669#[derive(Debug, Clone, PartialEq, Eq)]
1671pub struct FormatUpgrade {
1672 pub from_version: u32,
1674 pub to_version: u32,
1676 pub original: String,
1678 pub messages: usize,
1680 pub rewritten: bool,
1683}
1684
1685pub const SESSION_INDEX_VERSION: u32 = 1;
1687
1688#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1690pub struct SessionIndexEntry {
1691 pub name: String,
1693 pub title: String,
1695 pub archived: bool,
1697 pub time_secs: u64,
1699 pub preview: String,
1701 pub messages: usize,
1703 pub mtime_nanos: u64,
1708 pub size: u64,
1710}
1711
1712#[derive(Debug, Clone, Default, PartialEq, Eq)]
1716pub struct SessionIndex {
1717 pub entries: Vec<SessionIndexEntry>,
1719 pub reused: usize,
1721 pub rederived: usize,
1723}
1724
1725#[derive(Serialize, Deserialize)]
1726struct SessionIndexFile {
1727 version: u32,
1728 entries: Vec<SessionIndexEntry>,
1729}
1730
1731pub fn derive_session_time(name: &str, mtime_ms: u64) -> u64 {
1736 if let Some(micros) = name.rsplit('-').next().and_then(|s| s.parse::<u128>().ok()) {
1737 return (micros / 1_000_000) as u64;
1738 }
1739 mtime_ms / 1000
1740}
1741
1742fn preview_line(content: &str) -> String {
1744 let line = content.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
1745 let line = line.trim();
1746 if line.chars().count() <= 80 {
1747 return line.to_string();
1748 }
1749 let truncated: String = line.chars().take(79).collect();
1750 format!("{truncated}…")
1751}
1752
1753#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1756pub struct ForkProvenance {
1757 pub forked_from: String,
1759 #[serde(default)]
1762 pub forked_at_message: Option<usize>,
1763 #[serde(default)]
1765 pub timestamp_ms: i64,
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770 use super::*;
1771
1772 #[test]
1773 fn addressing_a_store_does_not_create_its_root() {
1774 let tmp = std::env::temp_dir().join(format!(
1775 "sc-store-address-only-{}-{}",
1776 std::process::id(),
1777 std::time::SystemTime::now()
1778 .duration_since(std::time::UNIX_EPOCH)
1779 .unwrap()
1780 .as_nanos()
1781 ));
1782 let store = SessionStore::at(&tmp);
1783 assert_eq!(store.root(), tmp);
1784 assert!(!tmp.exists());
1785 assert!(store.list().is_empty());
1786 assert!(!tmp.exists());
1787 }
1788
1789 #[test]
1790 fn rejects_path_traversal_names() {
1791 let tmp = std::env::temp_dir().join(format!("sc-store-test-{}", std::process::id()));
1792 let store = SessionStore::open(&tmp).unwrap();
1793 for bad in ["../escape", "..", "a/b", "/abs", "", " ", ".", "x\0y"] {
1794 assert!(store.save(bad, "t", "{}").is_err(), "should reject `{bad}`");
1795 assert!(store.load(bad).is_err(), "should reject load `{bad}`");
1796 assert!(store.delete(bad).is_err(), "should reject delete `{bad}`");
1797 }
1798 store.save("ok-name", "t", "{}").unwrap();
1800 assert!(tmp.join("ok-name.jsonl").exists());
1801 assert!(!tmp.parent().unwrap().join("escape.jsonl").exists());
1803 let _ = std::fs::remove_dir_all(&tmp);
1804 }
1805
1806 fn temp_store() -> (SessionStore, std::path::PathBuf) {
1807 use std::sync::atomic::{AtomicU64, Ordering};
1808 static N: AtomicU64 = AtomicU64::new(0);
1809 let tmp = std::env::temp_dir().join(format!(
1810 "sc-store-model-change-{}-{}",
1811 std::process::id(),
1812 N.fetch_add(1, Ordering::SeqCst)
1813 ));
1814 (SessionStore::open(&tmp).unwrap(), tmp)
1815 }
1816
1817 #[test]
1821 fn model_change_log_round_trips_losslessly_through_the_store() {
1822 let (store, tmp) = temp_store();
1823 store.save("sess", "t", "[]").unwrap();
1824 let records = vec![
1825 crate::model_change::ModelChangeRecord::new(
1826 0,
1827 "vendor/model-a",
1828 "vendor/model-b",
1829 true,
1830 4,
1831 1_700_000_000_000,
1832 ),
1833 crate::model_change::ModelChangeRecord::new(
1834 5,
1835 "vendor/model-b",
1836 "vendor/model-c",
1837 true,
1838 0,
1839 1_700_000_050_000,
1840 ),
1841 ];
1842 store.save_model_change_log("sess", &records).unwrap();
1843 let loaded = store.load_model_change_log("sess").unwrap();
1844 assert_eq!(loaded, records);
1845 let _ = std::fs::remove_dir_all(&tmp);
1846 }
1847
1848 #[test]
1851 fn model_change_log_is_empty_when_never_saved() {
1852 let (store, tmp) = temp_store();
1853 store.save("sess", "t", "[]").unwrap();
1854 assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1855 let _ = std::fs::remove_dir_all(&tmp);
1856 }
1857
1858 #[test]
1862 fn model_change_log_travels_with_archive_and_is_removed_by_delete() {
1863 let (store, tmp) = temp_store();
1864 store.save("sess", "t", "[]").unwrap();
1865 let records = vec![crate::model_change::ModelChangeRecord::new(
1866 0, "a", "b", true, 1, 1,
1867 )];
1868 store.save_model_change_log("sess", &records).unwrap();
1869 assert!(tmp.join("sess.model_change.jsonl").exists());
1870
1871 store.archive("sess").unwrap();
1872 assert!(!tmp.join("sess.model_change.jsonl").exists());
1873 assert!(tmp.join("archived/sess.model_change.jsonl").exists());
1874 assert_eq!(store.load_model_change_log("sess").unwrap(), records);
1876
1877 store.delete("sess").unwrap();
1878 assert!(!tmp.join("archived/sess.model_change.jsonl").exists());
1879 assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1880 let _ = std::fs::remove_dir_all(&tmp);
1881 }
1882
1883 #[test]
1886 fn git_metadata_round_trips_losslessly_through_the_store() {
1887 let (store, tmp) = temp_store();
1888 store.save("sess", "t", "[]").unwrap();
1889 let record = crate::git_metadata::GitMetadataRecord {
1890 branch: Some("main".to_string()),
1891 sha: Some("deadbeef".to_string()),
1892 dirty: true,
1893 captured_at_ms: 1_700_000_000_000,
1894 };
1895 store.save_git_metadata("sess", &record).unwrap();
1896 assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1897 let _ = std::fs::remove_dir_all(&tmp);
1898 }
1899
1900 #[test]
1903 fn git_metadata_is_none_when_never_saved() {
1904 let (store, tmp) = temp_store();
1905 store.save("sess", "t", "[]").unwrap();
1906 assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1907 let _ = std::fs::remove_dir_all(&tmp);
1908 }
1909
1910 #[test]
1913 fn git_metadata_travels_with_archive_and_is_removed_by_delete() {
1914 let (store, tmp) = temp_store();
1915 store.save("sess", "t", "[]").unwrap();
1916 let record = crate::git_metadata::GitMetadataRecord {
1917 branch: Some("main".to_string()),
1918 sha: None,
1919 dirty: false,
1920 captured_at_ms: 1,
1921 };
1922 store.save_git_metadata("sess", &record).unwrap();
1923 assert!(tmp.join("sess.git.json").exists());
1924
1925 store.archive("sess").unwrap();
1926 assert!(!tmp.join("sess.git.json").exists());
1927 assert!(tmp.join("archived/sess.git.json").exists());
1928 assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1929
1930 store.delete("sess").unwrap();
1931 assert!(!tmp.join("archived/sess.git.json").exists());
1932 assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1933 let _ = std::fs::remove_dir_all(&tmp);
1934 }
1935
1936 fn sample_lineage(child_id: &str) -> crate::subagents::SubagentLineage {
1942 crate::subagents::SubagentLineage {
1943 child_agent_id: child_id.to_string(),
1944 parent_session_id: Some("parent-sess".to_string()),
1945 parent_tool_use_id: "call_1".to_string(),
1946 depth: 1,
1947 agent_type: Some("researcher".to_string()),
1948 task: "investigate the flaky test".to_string(),
1949 background: false,
1950 spawned_at_ms: 1_700_000_000_000,
1951 model: "vendor/model-a".to_string(),
1952 }
1953 }
1954
1955 #[test]
1958 fn subagent_lineage_round_trips_losslessly_through_the_store() {
1959 let (store, tmp) = temp_store();
1960 store.save("parent-sess", "t", "[]").unwrap();
1961 let record = sample_lineage("agent-1");
1962 store
1963 .save_subagent_lineage("parent-sess", "agent-1", &record)
1964 .unwrap();
1965 assert_eq!(
1966 store
1967 .load_subagent_lineage("parent-sess", "agent-1")
1968 .unwrap(),
1969 Some(record)
1970 );
1971 let _ = std::fs::remove_dir_all(&tmp);
1972 }
1973
1974 #[test]
1979 fn subagent_transcript_round_trips_and_parses_back_with_lineage() {
1980 let (store, tmp) = temp_store();
1981 store.save("parent-sess", "t", "[]").unwrap();
1982
1983 let mut session = crate::session::Session::from_claude_code_str("").unwrap();
1984 session.meta.agent_id = Some("agent-1".to_string());
1985 session.meta.parent_tool_use_id = Some("call_1".to_string());
1986 session
1987 .meta
1988 .lineage
1989 .insert("parent_thread_id".to_string(), "parent-sess".to_string());
1990 session
1991 .meta
1992 .lineage
1993 .insert("depth".to_string(), "1".to_string());
1994 let appended = vec![crate::message::ChatMessage::user("hello from the child")];
1995 let sidecar_jsonl = session.to_native_jsonl_v2(&appended);
1996
1997 store
1998 .save_subagent_transcript("parent-sess", "agent-1", &sidecar_jsonl)
1999 .unwrap();
2000 let loaded = store
2001 .load_subagent_transcript("parent-sess", "agent-1")
2002 .unwrap()
2003 .expect("just saved");
2004 assert_eq!(loaded, sidecar_jsonl);
2005
2006 let parsed = crate::session::Session::from_native_str(&loaded).unwrap();
2007 assert_eq!(parsed.meta.agent_id.as_deref(), Some("agent-1"));
2008 assert_eq!(parsed.meta.parent_tool_use_id.as_deref(), Some("call_1"));
2009 assert_eq!(
2010 parsed.meta.lineage.get("parent_thread_id"),
2011 Some(&"parent-sess".to_string())
2012 );
2013 assert_eq!(
2014 parsed.messages.last().and_then(|m| m.content.as_deref()),
2015 Some("hello from the child")
2016 );
2017
2018 assert_eq!(
2019 store.list_subagent_ids("parent-sess").unwrap(),
2020 vec!["agent-1".to_string()]
2021 );
2022 let _ = std::fs::remove_dir_all(&tmp);
2023 }
2024
2025 #[test]
2030 fn imported_subagents_survive_disk_reload_with_verbatim_source_bytes() {
2031 let (store, tmp) = temp_store();
2032 let original = concat!(
2033 "{\"type\":\"user\",\"sessionId\":\"parent\",\"agentId\":\"child-7\",\"uuid\":\"u1\",\"parentUuid\":null,\"message\":{\"role\":\"user\",\"content\":\"inspect it\"}} \r\n",
2034 "{\"type\":\"queue-operation\",\"operation\":\"dequeue\"}"
2035 );
2036 let mut child = crate::session::Session::from_claude_code_str(original).unwrap();
2037 child.meta.agent_id = Some("child-7".to_string());
2038 child.meta.parent_tool_use_id = Some("toolu_task_7".to_string());
2039
2040 assert_eq!(
2041 store
2042 .save_imported_subagents("parent-sess", &[child])
2043 .unwrap(),
2044 1
2045 );
2046 drop(store);
2047
2048 let reopened = SessionStore::open(&tmp).unwrap();
2049 let native = reopened
2050 .load_subagent_transcript("parent-sess", "child-7")
2051 .unwrap()
2052 .expect("imported child persisted");
2053 let loaded = crate::session::Session::from_sidecar_str(&native).unwrap();
2054 assert_eq!(
2055 loaded.meta.source,
2056 crate::session::SessionSource::ClaudeCode
2057 );
2058 assert_eq!(loaded.meta.agent_id.as_deref(), Some("child-7"));
2059 assert_eq!(
2060 loaded.meta.parent_tool_use_id.as_deref(),
2061 Some("toolu_task_7")
2062 );
2063 assert_eq!(loaded.raw_verbatim(), original);
2064 assert!(loaded.raw_is_verbatim);
2065
2066 let _ = std::fs::remove_dir_all(&tmp);
2067 }
2068
2069 #[test]
2072 fn imported_subagent_duplicate_ids_fail_before_any_write() {
2073 let (store, tmp) = temp_store();
2074 let mut first = crate::session::Session::from_claude_code_str("{}").unwrap();
2075 first.meta.agent_id = Some("same".to_string());
2076 let second = first.clone();
2077
2078 assert!(store
2079 .save_imported_subagents("parent-sess", &[first, second])
2080 .unwrap_err()
2081 .to_string()
2082 .contains("duplicate imported subagent id"));
2083 assert!(!tmp.join("parent-sess.subagents").exists());
2084
2085 let _ = std::fs::remove_dir_all(&tmp);
2086 }
2087
2088 #[test]
2092 fn subagent_family_travels_with_archive_and_is_removed_by_delete() {
2093 let (store, tmp) = temp_store();
2094 store.save("parent-sess", "t", "[]").unwrap();
2095 store
2096 .save_subagent_lineage("parent-sess", "agent-1", &sample_lineage("agent-1"))
2097 .unwrap();
2098 store
2099 .save_subagent_transcript("parent-sess", "agent-1", "{}\n")
2100 .unwrap();
2101 assert!(tmp
2102 .join("parent-sess.subagents/agent-1.lineage.json")
2103 .exists());
2104 assert!(tmp
2105 .join("parent-sess.subagents/agent-1.sidecar.jsonl")
2106 .exists());
2107
2108 store.archive("parent-sess").unwrap();
2109 assert!(!tmp.join("parent-sess.subagents").exists());
2110 assert!(tmp
2111 .join("archived/parent-sess.subagents/agent-1.lineage.json")
2112 .exists());
2113 assert_eq!(
2115 store.list_subagent_ids("parent-sess").unwrap(),
2116 vec!["agent-1".to_string()]
2117 );
2118 assert!(store
2119 .load_subagent_lineage("parent-sess", "agent-1")
2120 .unwrap()
2121 .is_some());
2122
2123 store.delete("parent-sess").unwrap();
2124 assert!(!tmp.join("archived/parent-sess.subagents").exists());
2125 assert_eq!(
2126 store
2127 .load_subagent_lineage("parent-sess", "agent-1")
2128 .unwrap(),
2129 None
2130 );
2131 assert!(store.list_subagent_ids("parent-sess").unwrap().is_empty());
2132 let _ = std::fs::remove_dir_all(&tmp);
2133 }
2134
2135 #[test]
2139 fn subagent_family_is_absent_by_default() {
2140 let (store, tmp) = temp_store();
2141 store.save("sess", "t", "[]").unwrap();
2142 assert_eq!(
2143 store.load_subagent_lineage("sess", "agent-1").unwrap(),
2144 None
2145 );
2146 assert_eq!(
2147 store.load_subagent_transcript("sess", "agent-1").unwrap(),
2148 None
2149 );
2150 assert!(store.list_subagent_ids("sess").unwrap().is_empty());
2151 assert!(!tmp.join("sess.subagents").exists());
2152 let _ = std::fs::remove_dir_all(&tmp);
2153 }
2154
2155 #[test]
2159 fn fork_full_copy_is_byte_identical_with_provenance() {
2160 let (store, tmp) = temp_store();
2161 let transcript = "{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n";
2162 store.save("orig", "t", transcript).unwrap();
2163 let provenance = store
2164 .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
2165 .unwrap();
2166 assert_eq!(store.load("copy").unwrap(), transcript);
2167 assert_eq!(provenance.forked_from, "orig");
2168 assert_eq!(provenance.forked_at_message, None);
2169 assert_eq!(
2170 store.load_fork_provenance("copy").unwrap(),
2171 Some(provenance)
2172 );
2173 assert_eq!(store.load("orig").unwrap(), transcript);
2175 let _ = std::fs::remove_dir_all(&tmp);
2176 }
2177
2178 #[test]
2182 fn fork_with_truncation_keeps_only_leading_messages() {
2183 let (store, tmp) = temp_store();
2184 let transcript = "{\"role\":\"system\"}\n{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n{\"role\":\"tool\"}\n";
2185 store.save("orig", "t", transcript).unwrap();
2186 let provenance = store
2187 .fork("orig", "partial", "partial fork", Some(2), 42)
2188 .unwrap();
2189 assert_eq!(
2190 store.load("partial").unwrap(),
2191 "{\"role\":\"system\"}\n{\"role\":\"user\"}\n"
2192 );
2193 assert_eq!(provenance.forked_at_message, Some(2));
2194 assert_eq!(store.load("orig").unwrap(), transcript);
2197 let _ = std::fs::remove_dir_all(&tmp);
2198 }
2199
2200 #[test]
2202 fn fork_provenance_is_none_for_a_plain_session() {
2203 let (store, tmp) = temp_store();
2204 store.save("sess", "t", "[]").unwrap();
2205 assert_eq!(store.load_fork_provenance("sess").unwrap(), None);
2206 let _ = std::fs::remove_dir_all(&tmp);
2207 }
2208
2209 #[test]
2212 fn prune_expired_deletes_only_old_archived_sessions() {
2213 let (store, tmp) = temp_store();
2214 store.save("old-archived", "t", "[]").unwrap();
2215 store.archive("old-archived").unwrap();
2216 store.save("fresh-archived", "t", "[]").unwrap();
2217 store.archive("fresh-archived").unwrap();
2218 store.save("active", "t", "[]").unwrap();
2219
2220 let old_path = tmp.join("archived/old-archived.jsonl");
2223 let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
2224 std::fs::OpenOptions::new()
2225 .write(true)
2226 .open(&old_path)
2227 .unwrap()
2228 .set_modified(ancient)
2229 .unwrap();
2230
2231 let pruned = store
2232 .prune_expired(30, std::time::SystemTime::now())
2233 .unwrap();
2234 assert_eq!(pruned, vec!["old-archived".to_string()]);
2235 assert!(!old_path.exists());
2236 assert!(tmp.join("archived/fresh-archived.jsonl").exists());
2237 assert!(tmp.join("active.jsonl").exists());
2238 let _ = std::fs::remove_dir_all(&tmp);
2239 }
2240
2241 #[test]
2244 fn prune_expired_zero_days_prunes_nothing() {
2245 let (store, tmp) = temp_store();
2246 store.save("sess", "t", "[]").unwrap();
2247 store.archive("sess").unwrap();
2248 let pruned = store
2249 .prune_expired(0, std::time::SystemTime::now())
2250 .unwrap();
2251 assert!(pruned.is_empty());
2252 assert!(tmp.join("archived/sess.jsonl").exists());
2253 let _ = std::fs::remove_dir_all(&tmp);
2254 }
2255
2256 #[test]
2259 fn prune_expired_never_touches_active_sessions() {
2260 let (store, tmp) = temp_store();
2261 store.save("active", "t", "[]").unwrap();
2262 let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
2263 std::fs::OpenOptions::new()
2264 .write(true)
2265 .open(tmp.join("active.jsonl"))
2266 .unwrap()
2267 .set_modified(ancient)
2268 .unwrap();
2269 let pruned = store
2270 .prune_expired(30, std::time::SystemTime::now())
2271 .unwrap();
2272 assert!(pruned.is_empty());
2273 assert!(tmp.join("active.jsonl").exists());
2274 let _ = std::fs::remove_dir_all(&tmp);
2275 }
2276
2277 fn sample_tree() -> crate::session_tree::SessionTree {
2283 let mut tree = crate::session_tree::SessionTree::from_linear(
2284 &[
2285 crate::message::ChatMessage::user("hello"),
2286 crate::message::ChatMessage::assistant("hi"),
2287 ],
2288 1_700_000_000_000,
2289 );
2290 tree.branch("n0", Some("side".to_string()), 1_700_000_001_000)
2291 .unwrap();
2292 tree.append_message(
2293 crate::message::ChatMessage::user("side turn"),
2294 1_700_000_002_000,
2295 );
2296 tree
2297 }
2298
2299 #[test]
2303 fn session_tree_round_trips_losslessly_through_the_store() {
2304 let (store, tmp) = temp_store();
2305 store.save("sess", "t", "[]").unwrap();
2306 let tree = sample_tree();
2307 store.save_tree("sess", &tree).unwrap();
2308 let loaded = store.load_tree("sess").unwrap().expect("just saved");
2309
2310 assert_eq!(loaded.root, tree.root);
2311 assert_eq!(loaded.active_branch, tree.active_branch);
2312 assert_eq!(loaded.nodes.len(), tree.nodes.len());
2313 assert_eq!(
2314 loaded.linear_projection_of("main").unwrap().len(),
2315 tree.linear_projection_of("main").unwrap().len()
2316 );
2317 assert_eq!(
2318 loaded.linear_projection().unwrap().len(),
2319 tree.linear_projection().unwrap().len()
2320 );
2321 let _ = std::fs::remove_dir_all(&tmp);
2322 }
2323
2324 #[test]
2333 fn attack_tree_sidecar_preserves_message_metadata() {
2334 let (store, tmp) = temp_store();
2335 let mut m = crate::message::ChatMessage::user("turn with provenance");
2336 m.metadata
2337 .insert("phase".to_string(), "commentary".to_string());
2338 m.metadata
2339 .insert("turn_id".to_string(), "cx-turn-42".to_string());
2340 m.metadata
2341 .insert("pi_entry_id".to_string(), "entry-7".to_string());
2342 let mut tree = crate::session_tree::SessionTree::from_linear(
2343 &[m.clone(), crate::message::ChatMessage::assistant("ok")],
2344 1,
2345 );
2346 let preserved = tree.rewind("n0", 2).unwrap().unwrap();
2349 store.save("s", "t", "").unwrap();
2350 store.save_tree("s", &tree).unwrap();
2351 let loaded = store.load_tree("s").unwrap().unwrap();
2352 let recovered = loaded.linear_projection_of(&preserved).unwrap();
2353 assert_eq!(recovered.len(), 2);
2354 assert_eq!(
2356 recovered[0].metadata.get("phase"),
2357 Some(&"commentary".to_string())
2358 );
2359 assert_eq!(
2360 recovered[0].metadata.get("turn_id"),
2361 Some(&"cx-turn-42".to_string())
2362 );
2363 assert_eq!(
2364 recovered[0].metadata.get("pi_entry_id"),
2365 Some(&"entry-7".to_string())
2366 );
2367 let _ = std::fs::remove_dir_all(&tmp);
2368 }
2369
2370 #[test]
2379 fn attack_tree_sidecar_preserves_content_alongside_content_parts() {
2380 let (store, tmp) = temp_store();
2381 let mut m = crate::message::ChatMessage::user("plain content");
2382 m.content_parts = Some(vec![serde_json::json!({"type":"text","text":"part"})]);
2383 let tree = crate::session_tree::SessionTree::from_linear(&[m], 1);
2384 store.save("s", "t", "").unwrap();
2385 store.save_tree("s", &tree).unwrap();
2386 let loaded = store.load_tree("s").unwrap().unwrap();
2387 let got = &loaded.node("n0").unwrap().message;
2388 assert_eq!(got.content.as_deref(), Some("plain content"));
2389 assert_eq!(
2390 got.content_parts.as_ref().unwrap()[0]["text"],
2391 serde_json::json!("part")
2392 );
2393 let _ = std::fs::remove_dir_all(&tmp);
2394 }
2395
2396 #[test]
2402 fn session_tree_round_trip_preserves_full_message_fidelity_not_just_length() {
2403 let (store, tmp) = temp_store();
2404 let mut m0 = crate::message::ChatMessage::user("hello");
2405 m0.metadata
2406 .insert("promptSource".to_string(), "cli".to_string());
2407 m0.metadata.insert("isMeta".to_string(), "true".to_string());
2408 let mut m1 = crate::message::ChatMessage::assistant("hi");
2409 m1.content_parts = Some(vec![
2410 serde_json::json!({"type": "text", "text": "hi"}),
2411 serde_json::json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}),
2412 ]);
2413 m1.metadata
2414 .insert("review_findings".to_string(), "none".to_string());
2415 let tree = crate::session_tree::SessionTree::from_linear(
2416 &[m0.clone(), m1.clone()],
2417 1_700_000_000_000,
2418 );
2419 store.save("s", "t", "").unwrap();
2420 store.save_tree("s", &tree).unwrap();
2421 let loaded = store.load_tree("s").unwrap().unwrap();
2422
2423 let got0 = &loaded.node("n0").unwrap().message;
2424 assert_eq!(got0.content, m0.content);
2425 assert_eq!(got0.metadata, m0.metadata);
2426 assert_eq!(got0.content_parts, m0.content_parts);
2427
2428 let got1 = &loaded.node("n1").unwrap().message;
2429 assert_eq!(got1.content, m1.content);
2430 assert_eq!(got1.metadata, m1.metadata);
2431 assert_eq!(got1.content_parts, m1.content_parts);
2432
2433 let _ = std::fs::remove_dir_all(&tmp);
2434 }
2435
2436 #[test]
2445 fn tree_sidecar_save_load_save_is_byte_identical_after_f1() {
2446 let (store, tmp) = temp_store();
2447 let mut tree = crate::session_tree::SessionTree::from_linear(
2448 &[
2449 crate::message::ChatMessage::user("a"),
2450 crate::message::ChatMessage::assistant("b"),
2451 ],
2452 1_700_000_000_000,
2453 );
2454 tree.branch("n0", Some("side".to_string()), 2).unwrap();
2455 tree.append_message(crate::message::ChatMessage::user("c"), 3);
2456 tree.label("n1", "checkpoint").unwrap();
2457 store.save("s", "t", "").unwrap();
2458 store.save_tree("s", &tree).unwrap();
2459 let bytes1 = std::fs::read(tmp.join("s.tree.json")).unwrap();
2460 let loaded = store.load_tree("s").unwrap().unwrap();
2461 store.save_tree("s", &loaded).unwrap();
2462 let bytes2 = std::fs::read(tmp.join("s.tree.json")).unwrap();
2463 assert_eq!(bytes1, bytes2);
2464 let _ = std::fs::remove_dir_all(&tmp);
2465 }
2466
2467 #[test]
2471 fn corrupt_tree_json_still_errors_on_load_after_the_fixes() {
2472 let (store, tmp) = temp_store();
2473 store.save("s", "t", "").unwrap();
2474 std::fs::write(tmp.join("s.tree.json"), "{not json").unwrap();
2475 assert!(store.load_tree("s").is_err());
2476 std::fs::write(tmp.join("s.tree.json"), "").unwrap();
2477 assert!(store.load_tree("s").is_err());
2478 let _ = std::fs::remove_dir_all(&tmp);
2479 }
2480
2481 #[test]
2485 fn session_tree_is_absent_by_default() {
2486 let (store, tmp) = temp_store();
2487 store.save("sess", "t", "[]").unwrap();
2488 assert!(store.load_tree("sess").unwrap().is_none());
2489 assert!(!tmp.join("sess.tree.json").exists());
2490 let _ = std::fs::remove_dir_all(&tmp);
2491 }
2492
2493 #[test]
2497 fn session_tree_travels_with_archive_and_is_removed_by_delete() {
2498 let (store, tmp) = temp_store();
2499 store.save("sess", "t", "[]").unwrap();
2500 store.save_tree("sess", &sample_tree()).unwrap();
2501 assert!(tmp.join("sess.tree.json").exists());
2502
2503 store.archive("sess").unwrap();
2504 assert!(!tmp.join("sess.tree.json").exists());
2505 assert!(tmp.join("archived/sess.tree.json").exists());
2506 assert!(store.load_tree("sess").unwrap().is_some());
2508
2509 store.delete("sess").unwrap();
2510 assert!(!tmp.join("archived/sess.tree.json").exists());
2511 assert!(store.load_tree("sess").unwrap().is_none());
2512 let _ = std::fs::remove_dir_all(&tmp);
2513 }
2514
2515 #[test]
2520 fn fork_copies_the_session_tree_sidecar() {
2521 let (store, tmp) = temp_store();
2522 let transcript = "{\"role\":\"user\"}\n";
2523 store.save("orig", "t", transcript).unwrap();
2524 store.save_tree("orig", &sample_tree()).unwrap();
2525
2526 store
2527 .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
2528 .unwrap();
2529 let copied = store.load_tree("copy").unwrap().expect("copied");
2530 let orig = store.load_tree("orig").unwrap().unwrap();
2531 assert_eq!(copied.nodes.len(), orig.nodes.len());
2532 assert_eq!(copied.branches.len(), orig.branches.len());
2533 let _ = std::fs::remove_dir_all(&tmp);
2534 }
2535
2536 #[test]
2537 fn claude_runtime_manifest_roundtrips_and_travels_with_family_lifecycle() {
2538 let (store, tmp) = temp_store();
2539 store.save("sess", "t", "[]").unwrap();
2540 let source = concat!(
2541 "{\"type\":\"permission-mode\",\"permissionMode\":\"bypassPermissions\",",
2542 "\"timestamp\":\"2026-07-14T00:00:00Z\"}\n"
2543 );
2544 let session = crate::Session::from_claude_code_str(source).unwrap();
2545 let manifest =
2546 crate::claude_runtime_state::ClaudeRuntimeManifest::from_session(&session).unwrap();
2547 store
2548 .save_claude_runtime_manifest("sess", &manifest)
2549 .unwrap();
2550 assert_eq!(
2551 store.load_claude_runtime_manifest("sess").unwrap(),
2552 Some(manifest.clone())
2553 );
2554
2555 store
2556 .fork("sess", "copy", "copy", None, 1_700_000_000_000)
2557 .unwrap();
2558 assert_eq!(
2559 store.load_claude_runtime_manifest("copy").unwrap(),
2560 Some(manifest.clone())
2561 );
2562 store.archive("sess").unwrap();
2563 assert!(!tmp.join("sess.claude-runtime.json").exists());
2564 assert!(tmp.join("archived/sess.claude-runtime.json").exists());
2565 assert_eq!(
2566 store.load_claude_runtime_manifest("sess").unwrap(),
2567 Some(manifest)
2568 );
2569 store.delete("sess").unwrap();
2570 assert!(store
2571 .load_claude_runtime_manifest("sess")
2572 .unwrap()
2573 .is_none());
2574 let _ = std::fs::remove_dir_all(&tmp);
2575 }
2576}