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 reduction_log_path(&self, name: &str) -> Result<PathBuf> {
209 Self::validate_name(name)?;
210 Ok(self.reduction_path(name, false))
211 }
212
213 pub fn session_path(&self, name: &str) -> Result<PathBuf> {
217 Self::validate_name(name)?;
218 Ok(self.transcript_path(name, false))
219 }
220
221 pub fn session_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
224 Self::validate_name(name)?;
225 Ok(self.transcript_path(name, archived))
226 }
227
228 pub fn sidecar_path_for(&self, name: &str, archived: bool) -> Result<PathBuf> {
230 Self::validate_name(name)?;
231 Ok(self.sidecar_path_in(name, archived))
232 }
233
234 pub fn subagent_transcript_path_for(
236 &self,
237 parent_name: &str,
238 child_id: &str,
239 archived: bool,
240 ) -> Result<PathBuf> {
241 Self::validate_name(parent_name)?;
242 Self::validate_name(child_id)?;
243 Ok(self.subagent_transcript_path(parent_name, child_id, archived))
244 }
245
246 pub fn save_sidecar(&self, name: &str, sidecar_jsonl: &str) -> Result<()> {
249 Self::validate_name(name)?;
250 std::fs::create_dir_all(self.dir(false))?;
251 std::fs::write(self.sidecar_path_in(name, false), sidecar_jsonl)?;
252 Ok(())
253 }
254
255 pub fn load_sidecar(&self, name: &str) -> Result<Option<String>> {
259 Self::validate_name(name)?;
260 let active = self.sidecar_path_in(name, false);
261 let path = if active.exists() {
262 active
263 } else {
264 self.sidecar_path_in(name, true)
265 };
266 if !path.exists() {
267 return Ok(None);
268 }
269 Ok(Some(std::fs::read_to_string(path)?))
270 }
271
272 pub fn load_sidecar_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
274 Self::validate_name(name)?;
275 let path = self.sidecar_path_in(name, archived);
276 if !path.exists() {
277 return Ok(None);
278 }
279 Ok(Some(std::fs::read_to_string(path)?))
280 }
281
282 pub fn save_reduction_log(&self, name: &str, log: &ReductionLog) -> Result<()> {
285 Self::validate_name(name)?;
286 std::fs::create_dir_all(self.dir(false))?;
287 let json = serde_json::to_string(log).map_err(Error::Decode)?;
288 std::fs::write(self.reduction_path(name, false), json)?;
289 Ok(())
290 }
291
292 pub fn load_reduction_log(&self, name: &str) -> Result<Option<ReductionLog>> {
295 Self::validate_name(name)?;
296 let active = self.reduction_path(name, false);
297 let path = if active.exists() {
298 active
299 } else {
300 self.reduction_path(name, true)
301 };
302 if !path.exists() {
303 return Ok(None);
304 }
305 let text = std::fs::read_to_string(path)?;
306 Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
307 }
308
309 pub fn load_reduction_log_from(
311 &self,
312 name: &str,
313 archived: bool,
314 ) -> Result<Option<ReductionLog>> {
315 Self::validate_name(name)?;
316 let path = self.reduction_path(name, archived);
317 if !path.exists() {
318 return Ok(None);
319 }
320 let text = std::fs::read_to_string(path)?;
321 Ok(Some(serde_json::from_str(&text).map_err(Error::Decode)?))
322 }
323
324 pub fn save_usage_log(
330 &self,
331 name: &str,
332 records: &[crate::usage_log::UsageRecord],
333 ) -> Result<()> {
334 Self::validate_name(name)?;
335 std::fs::create_dir_all(self.dir(false))?;
336 let jsonl = crate::usage_log::to_jsonl(records)?;
337 std::fs::write(self.usage_path(name, false), jsonl)?;
338 Ok(())
339 }
340
341 pub fn load_usage_log(&self, name: &str) -> Result<Vec<crate::usage_log::UsageRecord>> {
344 Self::validate_name(name)?;
345 let active = self.usage_path(name, false);
346 let path = if active.exists() {
347 active
348 } else {
349 self.usage_path(name, true)
350 };
351 if !path.exists() {
352 return Ok(Vec::new());
353 }
354 crate::usage_log::from_jsonl(&std::fs::read_to_string(path)?)
355 }
356
357 pub fn save_model_change_log(
362 &self,
363 name: &str,
364 records: &[crate::model_change::ModelChangeRecord],
365 ) -> Result<()> {
366 Self::validate_name(name)?;
367 std::fs::create_dir_all(self.dir(false))?;
368 let jsonl = crate::model_change::to_jsonl(records)?;
369 std::fs::write(self.model_change_path(name, false), jsonl)?;
370 Ok(())
371 }
372
373 pub fn load_model_change_log(
378 &self,
379 name: &str,
380 ) -> Result<Vec<crate::model_change::ModelChangeRecord>> {
381 Self::validate_name(name)?;
382 let active = self.model_change_path(name, false);
383 let path = if active.exists() {
384 active
385 } else {
386 self.model_change_path(name, true)
387 };
388 if !path.exists() {
389 return Ok(Vec::new());
390 }
391 crate::model_change::from_jsonl(&std::fs::read_to_string(path)?)
392 }
393
394 pub fn save_git_metadata(
398 &self,
399 name: &str,
400 record: &crate::git_metadata::GitMetadataRecord,
401 ) -> Result<()> {
402 Self::validate_name(name)?;
403 std::fs::create_dir_all(self.dir(false))?;
404 let json = crate::git_metadata::to_json(record)?;
405 std::fs::write(self.git_metadata_path(name, false), json)?;
406 Ok(())
407 }
408
409 pub fn load_git_metadata(
413 &self,
414 name: &str,
415 ) -> Result<Option<crate::git_metadata::GitMetadataRecord>> {
416 Self::validate_name(name)?;
417 let active = self.git_metadata_path(name, false);
418 let path = if active.exists() {
419 active
420 } else {
421 self.git_metadata_path(name, true)
422 };
423 if !path.exists() {
424 return Ok(None);
425 }
426 Ok(Some(crate::git_metadata::from_json(
427 &std::fs::read_to_string(path)?,
428 )?))
429 }
430
431 pub fn save(&self, name: &str, title: &str, transcript_jsonl: &str) -> Result<()> {
433 Self::validate_name(name)?;
434 std::fs::create_dir_all(self.dir(false))?;
435 std::fs::write(self.transcript_path(name, false), transcript_jsonl)?;
436 let info = SessionInfo {
437 name: name.to_string(),
438 title: title.to_string(),
439 archived: false,
440 ..Default::default()
441 };
442 std::fs::write(
443 self.meta_path(name, false),
444 serde_json::to_string(&info).map_err(Error::Decode)?,
445 )?;
446 Ok(())
447 }
448
449 pub fn load(&self, name: &str) -> Result<String> {
451 Self::validate_name(name)?;
452 let active = self.transcript_path(name, false);
453 let path = if active.exists() {
454 active
455 } else {
456 self.transcript_path(name, true)
457 };
458 Ok(std::fs::read_to_string(path)?)
459 }
460
461 pub fn load_from(&self, name: &str, archived: bool) -> Result<String> {
463 Self::validate_name(name)?;
464 Ok(std::fs::read_to_string(
465 self.transcript_path(name, archived),
466 )?)
467 }
468
469 pub fn load_if_present_from(&self, name: &str, archived: bool) -> Result<Option<String>> {
472 Self::validate_name(name)?;
473 let path = self.transcript_path(name, archived);
474 match std::fs::symlink_metadata(&path) {
475 Ok(_) => Ok(Some(std::fs::read_to_string(path)?)),
476 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
477 Err(error) => Err(error.into()),
478 }
479 }
480
481 pub fn load_if_present(&self, name: &str) -> Result<Option<String>> {
489 Self::validate_name(name)?;
490 for archived in [false, true] {
491 let path = self.transcript_path(name, archived);
492 match std::fs::symlink_metadata(&path) {
493 Ok(_) => return Ok(Some(std::fs::read_to_string(path)?)),
494 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
495 Err(e) => return Err(e.into()),
496 }
497 }
498 Ok(None)
499 }
500
501 pub fn list(&self) -> Vec<SessionInfo> {
503 let mut out = Vec::new();
504 for archived in [false, true] {
505 let dir = self.dir(archived);
506 let Ok(rd) = std::fs::read_dir(&dir) else {
507 continue;
508 };
509 for entry in rd.flatten() {
510 let p = entry.path();
511 if p.extension().and_then(|e| e.to_str()) != Some("json") {
512 continue;
513 }
514 if !p.to_string_lossy().ends_with(".meta.json") {
516 continue;
517 }
518 if let Ok(text) = std::fs::read_to_string(&p) {
519 if let Ok(mut info) = serde_json::from_str::<SessionInfo>(&text) {
520 info.archived = archived;
521 out.push(info);
522 }
523 }
524 }
525 }
526 out.sort_by(|a, b| a.name.cmp(&b.name));
527 out
528 }
529
530 pub fn archive(&self, name: &str) -> Result<()> {
535 Self::validate_name(name)?;
536 std::fs::create_dir_all(self.dir(true))?;
537 for (from, to) in [
538 (
539 self.transcript_path(name, false),
540 self.transcript_path(name, true),
541 ),
542 (self.meta_path(name, false), self.meta_path(name, true)),
543 (
544 self.sidecar_path_in(name, false),
545 self.sidecar_path_in(name, true),
546 ),
547 (
548 self.reduction_path(name, false),
549 self.reduction_path(name, true),
550 ),
551 (self.events_path(name, false), self.events_path(name, true)),
552 (self.usage_path(name, false), self.usage_path(name, true)),
553 (
554 self.model_change_path(name, false),
555 self.model_change_path(name, true),
556 ),
557 (
558 self.git_metadata_path(name, false),
559 self.git_metadata_path(name, true),
560 ),
561 (self.fork_path(name, false), self.fork_path(name, true)),
562 (self.tree_path(name, false), self.tree_path(name, true)),
563 (
564 self.claude_runtime_path(name, false),
565 self.claude_runtime_path(name, true),
566 ),
567 ] {
568 if from.exists() {
569 std::fs::rename(&from, &to)?;
570 }
571 }
572 let subagents_from = self.subagents_dir(name, false);
577 if subagents_from.exists() {
578 std::fs::rename(&subagents_from, self.subagents_dir(name, true))?;
579 }
580 Ok(())
581 }
582
583 pub fn delete(&self, name: &str) -> Result<()> {
588 Self::validate_name(name)?;
589 for archived in [false, true] {
590 for p in [
591 self.transcript_path(name, archived),
592 self.meta_path(name, archived),
593 self.sidecar_path_in(name, archived),
594 self.reduction_path(name, archived),
595 self.events_path(name, archived),
596 self.usage_path(name, archived),
597 self.model_change_path(name, archived),
598 self.git_metadata_path(name, archived),
599 self.fork_path(name, archived),
600 self.tree_path(name, archived),
601 self.claude_runtime_path(name, archived),
602 ] {
603 if p.exists() {
604 std::fs::remove_file(p)?;
605 }
606 }
607 let subagents_dir = self.subagents_dir(name, archived);
610 if subagents_dir.exists() {
611 std::fs::remove_dir_all(&subagents_dir)?;
612 }
613 }
614 Ok(())
615 }
616
617 pub fn set_title(&self, name: &str, title: &str) -> Result<()> {
621 Self::validate_name(name)?;
622 for archived in [false, true] {
623 let mp = self.meta_path(name, archived);
624 if mp.exists() {
625 let mut info: SessionInfo = std::fs::read_to_string(&mp)
626 .ok()
627 .and_then(|t| serde_json::from_str(&t).ok())
628 .unwrap_or_else(|| SessionInfo {
629 name: name.to_string(),
630 ..Default::default()
631 });
632 info.title = title.to_string();
633 info.archived = archived;
634 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
635 return Ok(());
636 }
637 }
638 Err(Error::Other(format!("no session named `{name}`")))
639 }
640
641 pub fn root(&self) -> &Path {
643 &self.root
644 }
645
646 pub fn transcript_mtime(&self, name: &str) -> Option<std::time::SystemTime> {
654 Self::validate_name(name).ok()?;
655 let active = self.transcript_path(name, false);
656 let path = if active.exists() {
657 active
658 } else {
659 self.transcript_path(name, true)
660 };
661 std::fs::metadata(path).ok()?.modified().ok()
662 }
663
664 pub fn set_reduction_stats(
671 &self,
672 name: &str,
673 title: &str,
674 full_bytes: u64,
675 view_bytes: u64,
676 stub_count: u32,
677 ) -> Result<()> {
678 Self::validate_name(name)?;
679 std::fs::create_dir_all(self.dir(false))?;
680 let mp = self.meta_path(name, false);
681 let mut info: SessionInfo = std::fs::read_to_string(&mp)
682 .ok()
683 .and_then(|t| serde_json::from_str(&t).ok())
684 .unwrap_or_else(|| SessionInfo {
685 name: name.to_string(),
686 title: title.to_string(),
687 ..Default::default()
688 });
689 info.reduced = true;
690 info.full_bytes = full_bytes;
691 info.view_bytes = view_bytes;
692 info.stub_count = stub_count;
693 std::fs::write(&mp, serde_json::to_string(&info).map_err(Error::Decode)?)?;
694 Ok(())
695 }
696
697 pub fn fork(
708 &self,
709 from: &str,
710 to: &str,
711 title: &str,
712 truncate_at_message: Option<usize>,
713 timestamp_ms: i64,
714 ) -> Result<ForkProvenance> {
715 Self::validate_name(from)?;
716 Self::validate_name(to)?;
717 let jsonl = self.load(from)?;
718 if let Some(n) = truncate_at_message {
719 Self::validate_safe_truncation(&jsonl, n)?;
720 }
721 let content = match truncate_at_message {
722 Some(n) => {
723 let lines: Vec<&str> = jsonl.lines().take(n).collect();
724 if lines.is_empty() {
725 String::new()
726 } else {
727 let mut s = lines.join("\n");
728 s.push('\n');
729 s
730 }
731 }
732 None => jsonl,
733 };
734 self.save(to, title, &content)?;
735
736 self.copy_family_member(from, to, Self::sidecar_path_in)?;
752 self.copy_family_member(from, to, Self::reduction_path)?;
753 self.copy_family_member(from, to, Self::usage_path)?;
754 self.copy_family_member(from, to, Self::model_change_path)?;
755 self.copy_family_member(from, to, Self::git_metadata_path)?;
756 self.copy_family_member(from, to, Self::tree_path)?;
761 self.copy_family_member(from, to, Self::claude_runtime_path)?;
762
763 let provenance = ForkProvenance {
764 forked_from: from.to_string(),
765 forked_at_message: truncate_at_message,
766 timestamp_ms,
767 };
768 self.save_fork_provenance(to, &provenance)?;
769 Ok(provenance)
770 }
771
772 fn copy_family_member(
780 &self,
781 from: &str,
782 to: &str,
783 path_of: impl Fn(&Self, &str, bool) -> PathBuf,
784 ) -> Result<()> {
785 let active = path_of(self, from, false);
786 let src = if active.exists() {
787 active
788 } else {
789 let archived = path_of(self, from, true);
790 if !archived.exists() {
791 return Ok(());
792 }
793 archived
794 };
795 std::fs::create_dir_all(self.dir(false))?;
796 std::fs::copy(&src, path_of(self, to, false))?;
797 Ok(())
798 }
799
800 fn validate_safe_truncation(jsonl: &str, n: usize) -> Result<()> {
810 let kept: Vec<crate::message::ChatMessage> = jsonl
811 .lines()
812 .take(n)
813 .filter(|l| !l.trim().is_empty())
814 .map(|l| serde_json::from_str(l).map_err(Error::Decode))
815 .collect::<Result<_>>()?;
816 let mut pending: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
817 for m in &kept {
818 if let Some(calls) = &m.tool_calls {
819 for c in calls {
820 pending.insert(c.id.clone());
821 }
822 }
823 if let Some(id) = &m.tool_call_id {
824 pending.remove(id);
825 }
826 }
827 if !pending.is_empty() {
828 return Err(Error::Other(format!(
829 "fork --at {n} would cut off {} unresolved tool_call result(s) ({}) — \
830 choose a boundary at or after the assistant's tool_calls message AND \
831 all of its tool results",
832 pending.len(),
833 pending.into_iter().collect::<Vec<_>>().join(", "),
834 )));
835 }
836 Ok(())
837 }
838
839 pub fn save_fork_provenance(&self, name: &str, provenance: &ForkProvenance) -> Result<()> {
842 Self::validate_name(name)?;
843 std::fs::create_dir_all(self.dir(false))?;
844 let json = serde_json::to_string(provenance).map_err(Error::Decode)?;
845 std::fs::write(self.fork_path(name, false), json)?;
846 Ok(())
847 }
848
849 pub fn load_fork_provenance(&self, name: &str) -> Result<Option<ForkProvenance>> {
852 Self::validate_name(name)?;
853 let active = self.fork_path(name, false);
854 let path = if active.exists() {
855 active
856 } else {
857 self.fork_path(name, true)
858 };
859 if !path.exists() {
860 return Ok(None);
861 }
862 Ok(Some(
863 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
864 ))
865 }
866
867 pub fn save_tree(&self, name: &str, tree: &crate::session_tree::SessionTree) -> Result<()> {
872 Self::validate_name(name)?;
873 std::fs::create_dir_all(self.dir(false))?;
874 let json = serde_json::to_string(tree).map_err(Error::Decode)?;
875 std::fs::write(self.tree_path(name, false), json)?;
876 Ok(())
877 }
878
879 pub fn save_claude_runtime_manifest(
882 &self,
883 name: &str,
884 manifest: &crate::claude_runtime_state::ClaudeRuntimeManifest,
885 ) -> Result<()> {
886 Self::validate_name(name)?;
887 std::fs::create_dir_all(self.dir(false))?;
888 let json = serde_json::to_string(manifest).map_err(Error::Decode)?;
889 std::fs::write(self.claude_runtime_path(name, false), json)?;
890 Ok(())
891 }
892
893 pub fn load_claude_runtime_manifest(
895 &self,
896 name: &str,
897 ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
898 Self::validate_name(name)?;
899 let active = self.claude_runtime_path(name, false);
900 let path = if active.exists() {
901 active
902 } else {
903 self.claude_runtime_path(name, true)
904 };
905 if !path.exists() {
906 return Ok(None);
907 }
908 Ok(Some(
909 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
910 ))
911 }
912
913 pub fn load_claude_runtime_manifest_from(
915 &self,
916 name: &str,
917 archived: bool,
918 ) -> Result<Option<crate::claude_runtime_state::ClaudeRuntimeManifest>> {
919 Self::validate_name(name)?;
920 let path = self.claude_runtime_path(name, archived);
921 if !path.exists() {
922 return Ok(None);
923 }
924 Ok(Some(
925 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
926 ))
927 }
928
929 pub fn load_tree(&self, name: &str) -> Result<Option<crate::session_tree::SessionTree>> {
934 Self::validate_name(name)?;
935 let active = self.tree_path(name, false);
936 let path = if active.exists() {
937 active
938 } else {
939 self.tree_path(name, true)
940 };
941 if !path.exists() {
942 return Ok(None);
943 }
944 Ok(Some(
945 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
946 ))
947 }
948
949 pub fn save_subagent_transcript(
957 &self,
958 parent_name: &str,
959 child_id: &str,
960 sidecar_jsonl: &str,
961 ) -> Result<()> {
962 Self::validate_name(parent_name)?;
963 Self::validate_name(child_id)?;
964 std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
965 std::fs::write(
966 self.subagent_transcript_path(parent_name, child_id, false),
967 sidecar_jsonl,
968 )?;
969 Ok(())
970 }
971
972 pub fn save_imported_subagents(
981 &self,
982 parent_name: &str,
983 subagents: &[crate::session::Session],
984 ) -> Result<usize> {
985 Self::validate_name(parent_name)?;
986
987 let mut seen = std::collections::BTreeSet::new();
988 for child in subagents {
989 let child_id = child.meta.agent_id.as_deref().ok_or_else(|| {
990 Error::Other(format!(
991 "cannot persist an imported subagent for `{parent_name}` without an agent id"
992 ))
993 })?;
994 Self::validate_name(child_id)?;
995 if !seen.insert(child_id.to_string()) {
996 return Err(Error::Other(format!(
997 "duplicate imported subagent id `{child_id}` for `{parent_name}`"
998 )));
999 }
1000 }
1001
1002 for child in subagents {
1007 let child_id = child.meta.agent_id.as_deref().expect("validated above");
1008 self.save_subagent_transcript(parent_name, child_id, &child.to_native_jsonl_v2(&[]))?;
1009 }
1010 Ok(subagents.len())
1011 }
1012
1013 pub fn load_subagent_transcript(
1016 &self,
1017 parent_name: &str,
1018 child_id: &str,
1019 ) -> Result<Option<String>> {
1020 Self::validate_name(parent_name)?;
1021 Self::validate_name(child_id)?;
1022 let active = self.subagent_transcript_path(parent_name, child_id, false);
1023 let path = if active.exists() {
1024 active
1025 } else {
1026 self.subagent_transcript_path(parent_name, child_id, true)
1027 };
1028 if !path.exists() {
1029 return Ok(None);
1030 }
1031 Ok(Some(std::fs::read_to_string(path)?))
1032 }
1033
1034 pub fn load_subagent_transcript_from(
1036 &self,
1037 parent_name: &str,
1038 child_id: &str,
1039 archived: bool,
1040 ) -> Result<Option<String>> {
1041 Self::validate_name(parent_name)?;
1042 Self::validate_name(child_id)?;
1043 let path = self.subagent_transcript_path(parent_name, child_id, archived);
1044 if !path.exists() {
1045 return Ok(None);
1046 }
1047 Ok(Some(std::fs::read_to_string(path)?))
1048 }
1049
1050 pub fn save_subagent_lineage(
1054 &self,
1055 parent_name: &str,
1056 child_id: &str,
1057 record: &crate::subagents::SubagentLineage,
1058 ) -> Result<()> {
1059 Self::validate_name(parent_name)?;
1060 Self::validate_name(child_id)?;
1061 std::fs::create_dir_all(self.subagents_dir(parent_name, false))?;
1062 let json = serde_json::to_string(record).map_err(Error::Decode)?;
1063 std::fs::write(
1064 self.subagent_lineage_path(parent_name, child_id, false),
1065 json,
1066 )?;
1067 Ok(())
1068 }
1069
1070 pub fn load_subagent_lineage(
1073 &self,
1074 parent_name: &str,
1075 child_id: &str,
1076 ) -> Result<Option<crate::subagents::SubagentLineage>> {
1077 Self::validate_name(parent_name)?;
1078 Self::validate_name(child_id)?;
1079 let active = self.subagent_lineage_path(parent_name, child_id, false);
1080 let path = if active.exists() {
1081 active
1082 } else {
1083 self.subagent_lineage_path(parent_name, child_id, true)
1084 };
1085 if !path.exists() {
1086 return Ok(None);
1087 }
1088 Ok(Some(
1089 serde_json::from_str(&std::fs::read_to_string(path)?).map_err(Error::Decode)?,
1090 ))
1091 }
1092
1093 pub fn list_subagent_ids(&self, parent_name: &str) -> Result<Vec<String>> {
1098 Self::validate_name(parent_name)?;
1099 let mut ids = std::collections::BTreeSet::new();
1100 for archived in [false, true] {
1101 let dir = self.subagents_dir(parent_name, archived);
1102 let Ok(rd) = std::fs::read_dir(&dir) else {
1103 continue;
1104 };
1105 for entry in rd.flatten() {
1106 let p = entry.path();
1107 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1108 if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1109 ids.insert(id.to_string());
1110 }
1111 }
1112 }
1113 }
1114 Ok(ids.into_iter().collect())
1115 }
1116
1117 pub fn list_subagent_ids_from(&self, parent_name: &str, archived: bool) -> Result<Vec<String>> {
1119 Self::validate_name(parent_name)?;
1120 let mut ids = std::collections::BTreeSet::new();
1121 let dir = self.subagents_dir(parent_name, archived);
1122 let Ok(rd) = std::fs::read_dir(&dir) else {
1123 return Ok(Vec::new());
1124 };
1125 for entry in rd.flatten() {
1126 let p = entry.path();
1127 if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1128 if let Some(id) = name.strip_suffix(".sidecar.jsonl") {
1129 ids.insert(id.to_string());
1130 }
1131 }
1132 }
1133 Ok(ids.into_iter().collect())
1134 }
1135
1136 pub fn prune_expired(
1144 &self,
1145 retention_days: u32,
1146 now: std::time::SystemTime,
1147 ) -> Result<Vec<String>> {
1148 if retention_days == 0 {
1149 return Ok(Vec::new());
1150 }
1151 let Some(cutoff) = now.checked_sub(std::time::Duration::from_secs(
1152 retention_days as u64 * 86_400,
1153 )) else {
1154 return Ok(Vec::new());
1155 };
1156 let mut pruned = Vec::new();
1157 for info in self.list() {
1158 if !info.archived {
1159 continue;
1160 }
1161 let Some(mtime) = self.transcript_mtime(&info.name) else {
1162 continue;
1163 };
1164 if mtime < cutoff {
1165 self.delete(&info.name)?;
1166 pruned.push(info.name);
1167 }
1168 }
1169 Ok(pruned)
1170 }
1171}
1172
1173#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1176pub struct ForkProvenance {
1177 pub forked_from: String,
1179 #[serde(default)]
1182 pub forked_at_message: Option<usize>,
1183 #[serde(default)]
1185 pub timestamp_ms: i64,
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191
1192 #[test]
1193 fn addressing_a_store_does_not_create_its_root() {
1194 let tmp = std::env::temp_dir().join(format!(
1195 "sc-store-address-only-{}-{}",
1196 std::process::id(),
1197 std::time::SystemTime::now()
1198 .duration_since(std::time::UNIX_EPOCH)
1199 .unwrap()
1200 .as_nanos()
1201 ));
1202 let store = SessionStore::at(&tmp);
1203 assert_eq!(store.root(), tmp);
1204 assert!(!tmp.exists());
1205 assert!(store.list().is_empty());
1206 assert!(!tmp.exists());
1207 }
1208
1209 #[test]
1210 fn rejects_path_traversal_names() {
1211 let tmp = std::env::temp_dir().join(format!("sc-store-test-{}", std::process::id()));
1212 let store = SessionStore::open(&tmp).unwrap();
1213 for bad in ["../escape", "..", "a/b", "/abs", "", " ", ".", "x\0y"] {
1214 assert!(store.save(bad, "t", "{}").is_err(), "should reject `{bad}`");
1215 assert!(store.load(bad).is_err(), "should reject load `{bad}`");
1216 assert!(store.delete(bad).is_err(), "should reject delete `{bad}`");
1217 }
1218 store.save("ok-name", "t", "{}").unwrap();
1220 assert!(tmp.join("ok-name.jsonl").exists());
1221 assert!(!tmp.parent().unwrap().join("escape.jsonl").exists());
1223 let _ = std::fs::remove_dir_all(&tmp);
1224 }
1225
1226 fn temp_store() -> (SessionStore, std::path::PathBuf) {
1227 use std::sync::atomic::{AtomicU64, Ordering};
1228 static N: AtomicU64 = AtomicU64::new(0);
1229 let tmp = std::env::temp_dir().join(format!(
1230 "sc-store-model-change-{}-{}",
1231 std::process::id(),
1232 N.fetch_add(1, Ordering::SeqCst)
1233 ));
1234 (SessionStore::open(&tmp).unwrap(), tmp)
1235 }
1236
1237 #[test]
1241 fn model_change_log_round_trips_losslessly_through_the_store() {
1242 let (store, tmp) = temp_store();
1243 store.save("sess", "t", "[]").unwrap();
1244 let records = vec![
1245 crate::model_change::ModelChangeRecord::new(
1246 0,
1247 "vendor/model-a",
1248 "vendor/model-b",
1249 true,
1250 4,
1251 1_700_000_000_000,
1252 ),
1253 crate::model_change::ModelChangeRecord::new(
1254 5,
1255 "vendor/model-b",
1256 "vendor/model-c",
1257 true,
1258 0,
1259 1_700_000_050_000,
1260 ),
1261 ];
1262 store.save_model_change_log("sess", &records).unwrap();
1263 let loaded = store.load_model_change_log("sess").unwrap();
1264 assert_eq!(loaded, records);
1265 let _ = std::fs::remove_dir_all(&tmp);
1266 }
1267
1268 #[test]
1271 fn model_change_log_is_empty_when_never_saved() {
1272 let (store, tmp) = temp_store();
1273 store.save("sess", "t", "[]").unwrap();
1274 assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1275 let _ = std::fs::remove_dir_all(&tmp);
1276 }
1277
1278 #[test]
1282 fn model_change_log_travels_with_archive_and_is_removed_by_delete() {
1283 let (store, tmp) = temp_store();
1284 store.save("sess", "t", "[]").unwrap();
1285 let records = vec![crate::model_change::ModelChangeRecord::new(
1286 0, "a", "b", true, 1, 1,
1287 )];
1288 store.save_model_change_log("sess", &records).unwrap();
1289 assert!(tmp.join("sess.model_change.jsonl").exists());
1290
1291 store.archive("sess").unwrap();
1292 assert!(!tmp.join("sess.model_change.jsonl").exists());
1293 assert!(tmp.join("archived/sess.model_change.jsonl").exists());
1294 assert_eq!(store.load_model_change_log("sess").unwrap(), records);
1296
1297 store.delete("sess").unwrap();
1298 assert!(!tmp.join("archived/sess.model_change.jsonl").exists());
1299 assert_eq!(store.load_model_change_log("sess").unwrap(), Vec::new());
1300 let _ = std::fs::remove_dir_all(&tmp);
1301 }
1302
1303 #[test]
1306 fn git_metadata_round_trips_losslessly_through_the_store() {
1307 let (store, tmp) = temp_store();
1308 store.save("sess", "t", "[]").unwrap();
1309 let record = crate::git_metadata::GitMetadataRecord {
1310 branch: Some("main".to_string()),
1311 sha: Some("deadbeef".to_string()),
1312 dirty: true,
1313 captured_at_ms: 1_700_000_000_000,
1314 };
1315 store.save_git_metadata("sess", &record).unwrap();
1316 assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1317 let _ = std::fs::remove_dir_all(&tmp);
1318 }
1319
1320 #[test]
1323 fn git_metadata_is_none_when_never_saved() {
1324 let (store, tmp) = temp_store();
1325 store.save("sess", "t", "[]").unwrap();
1326 assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1327 let _ = std::fs::remove_dir_all(&tmp);
1328 }
1329
1330 #[test]
1333 fn git_metadata_travels_with_archive_and_is_removed_by_delete() {
1334 let (store, tmp) = temp_store();
1335 store.save("sess", "t", "[]").unwrap();
1336 let record = crate::git_metadata::GitMetadataRecord {
1337 branch: Some("main".to_string()),
1338 sha: None,
1339 dirty: false,
1340 captured_at_ms: 1,
1341 };
1342 store.save_git_metadata("sess", &record).unwrap();
1343 assert!(tmp.join("sess.git.json").exists());
1344
1345 store.archive("sess").unwrap();
1346 assert!(!tmp.join("sess.git.json").exists());
1347 assert!(tmp.join("archived/sess.git.json").exists());
1348 assert_eq!(store.load_git_metadata("sess").unwrap(), Some(record));
1349
1350 store.delete("sess").unwrap();
1351 assert!(!tmp.join("archived/sess.git.json").exists());
1352 assert_eq!(store.load_git_metadata("sess").unwrap(), None);
1353 let _ = std::fs::remove_dir_all(&tmp);
1354 }
1355
1356 fn sample_lineage(child_id: &str) -> crate::subagents::SubagentLineage {
1362 crate::subagents::SubagentLineage {
1363 child_agent_id: child_id.to_string(),
1364 parent_session_id: Some("parent-sess".to_string()),
1365 parent_tool_use_id: "call_1".to_string(),
1366 depth: 1,
1367 agent_type: Some("researcher".to_string()),
1368 task: "investigate the flaky test".to_string(),
1369 background: false,
1370 spawned_at_ms: 1_700_000_000_000,
1371 model: "vendor/model-a".to_string(),
1372 }
1373 }
1374
1375 #[test]
1378 fn subagent_lineage_round_trips_losslessly_through_the_store() {
1379 let (store, tmp) = temp_store();
1380 store.save("parent-sess", "t", "[]").unwrap();
1381 let record = sample_lineage("agent-1");
1382 store
1383 .save_subagent_lineage("parent-sess", "agent-1", &record)
1384 .unwrap();
1385 assert_eq!(
1386 store
1387 .load_subagent_lineage("parent-sess", "agent-1")
1388 .unwrap(),
1389 Some(record)
1390 );
1391 let _ = std::fs::remove_dir_all(&tmp);
1392 }
1393
1394 #[test]
1399 fn subagent_transcript_round_trips_and_parses_back_with_lineage() {
1400 let (store, tmp) = temp_store();
1401 store.save("parent-sess", "t", "[]").unwrap();
1402
1403 let mut session = crate::session::Session::from_claude_code_str("").unwrap();
1404 session.meta.agent_id = Some("agent-1".to_string());
1405 session.meta.parent_tool_use_id = Some("call_1".to_string());
1406 session
1407 .meta
1408 .lineage
1409 .insert("parent_thread_id".to_string(), "parent-sess".to_string());
1410 session
1411 .meta
1412 .lineage
1413 .insert("depth".to_string(), "1".to_string());
1414 let appended = vec![crate::message::ChatMessage::user("hello from the child")];
1415 let sidecar_jsonl = session.to_native_jsonl_v2(&appended);
1416
1417 store
1418 .save_subagent_transcript("parent-sess", "agent-1", &sidecar_jsonl)
1419 .unwrap();
1420 let loaded = store
1421 .load_subagent_transcript("parent-sess", "agent-1")
1422 .unwrap()
1423 .expect("just saved");
1424 assert_eq!(loaded, sidecar_jsonl);
1425
1426 let parsed = crate::session::Session::from_native_str(&loaded).unwrap();
1427 assert_eq!(parsed.meta.agent_id.as_deref(), Some("agent-1"));
1428 assert_eq!(parsed.meta.parent_tool_use_id.as_deref(), Some("call_1"));
1429 assert_eq!(
1430 parsed.meta.lineage.get("parent_thread_id"),
1431 Some(&"parent-sess".to_string())
1432 );
1433 assert_eq!(
1434 parsed.messages.last().and_then(|m| m.content.as_deref()),
1435 Some("hello from the child")
1436 );
1437
1438 assert_eq!(
1439 store.list_subagent_ids("parent-sess").unwrap(),
1440 vec!["agent-1".to_string()]
1441 );
1442 let _ = std::fs::remove_dir_all(&tmp);
1443 }
1444
1445 #[test]
1450 fn imported_subagents_survive_disk_reload_with_verbatim_source_bytes() {
1451 let (store, tmp) = temp_store();
1452 let original = concat!(
1453 "{\"type\":\"user\",\"sessionId\":\"parent\",\"agentId\":\"child-7\",\"uuid\":\"u1\",\"parentUuid\":null,\"message\":{\"role\":\"user\",\"content\":\"inspect it\"}} \r\n",
1454 "{\"type\":\"queue-operation\",\"operation\":\"dequeue\"}"
1455 );
1456 let mut child = crate::session::Session::from_claude_code_str(original).unwrap();
1457 child.meta.agent_id = Some("child-7".to_string());
1458 child.meta.parent_tool_use_id = Some("toolu_task_7".to_string());
1459
1460 assert_eq!(
1461 store
1462 .save_imported_subagents("parent-sess", &[child])
1463 .unwrap(),
1464 1
1465 );
1466 drop(store);
1467
1468 let reopened = SessionStore::open(&tmp).unwrap();
1469 let native = reopened
1470 .load_subagent_transcript("parent-sess", "child-7")
1471 .unwrap()
1472 .expect("imported child persisted");
1473 let loaded = crate::session::Session::from_sidecar_str(&native).unwrap();
1474 assert_eq!(
1475 loaded.meta.source,
1476 crate::session::SessionSource::ClaudeCode
1477 );
1478 assert_eq!(loaded.meta.agent_id.as_deref(), Some("child-7"));
1479 assert_eq!(
1480 loaded.meta.parent_tool_use_id.as_deref(),
1481 Some("toolu_task_7")
1482 );
1483 assert_eq!(loaded.raw_verbatim(), original);
1484 assert!(loaded.raw_is_verbatim);
1485
1486 let _ = std::fs::remove_dir_all(&tmp);
1487 }
1488
1489 #[test]
1492 fn imported_subagent_duplicate_ids_fail_before_any_write() {
1493 let (store, tmp) = temp_store();
1494 let mut first = crate::session::Session::from_claude_code_str("{}").unwrap();
1495 first.meta.agent_id = Some("same".to_string());
1496 let second = first.clone();
1497
1498 assert!(store
1499 .save_imported_subagents("parent-sess", &[first, second])
1500 .unwrap_err()
1501 .to_string()
1502 .contains("duplicate imported subagent id"));
1503 assert!(!tmp.join("parent-sess.subagents").exists());
1504
1505 let _ = std::fs::remove_dir_all(&tmp);
1506 }
1507
1508 #[test]
1512 fn subagent_family_travels_with_archive_and_is_removed_by_delete() {
1513 let (store, tmp) = temp_store();
1514 store.save("parent-sess", "t", "[]").unwrap();
1515 store
1516 .save_subagent_lineage("parent-sess", "agent-1", &sample_lineage("agent-1"))
1517 .unwrap();
1518 store
1519 .save_subagent_transcript("parent-sess", "agent-1", "{}\n")
1520 .unwrap();
1521 assert!(tmp
1522 .join("parent-sess.subagents/agent-1.lineage.json")
1523 .exists());
1524 assert!(tmp
1525 .join("parent-sess.subagents/agent-1.sidecar.jsonl")
1526 .exists());
1527
1528 store.archive("parent-sess").unwrap();
1529 assert!(!tmp.join("parent-sess.subagents").exists());
1530 assert!(tmp
1531 .join("archived/parent-sess.subagents/agent-1.lineage.json")
1532 .exists());
1533 assert_eq!(
1535 store.list_subagent_ids("parent-sess").unwrap(),
1536 vec!["agent-1".to_string()]
1537 );
1538 assert!(store
1539 .load_subagent_lineage("parent-sess", "agent-1")
1540 .unwrap()
1541 .is_some());
1542
1543 store.delete("parent-sess").unwrap();
1544 assert!(!tmp.join("archived/parent-sess.subagents").exists());
1545 assert_eq!(
1546 store
1547 .load_subagent_lineage("parent-sess", "agent-1")
1548 .unwrap(),
1549 None
1550 );
1551 assert!(store.list_subagent_ids("parent-sess").unwrap().is_empty());
1552 let _ = std::fs::remove_dir_all(&tmp);
1553 }
1554
1555 #[test]
1559 fn subagent_family_is_absent_by_default() {
1560 let (store, tmp) = temp_store();
1561 store.save("sess", "t", "[]").unwrap();
1562 assert_eq!(
1563 store.load_subagent_lineage("sess", "agent-1").unwrap(),
1564 None
1565 );
1566 assert_eq!(
1567 store.load_subagent_transcript("sess", "agent-1").unwrap(),
1568 None
1569 );
1570 assert!(store.list_subagent_ids("sess").unwrap().is_empty());
1571 assert!(!tmp.join("sess.subagents").exists());
1572 let _ = std::fs::remove_dir_all(&tmp);
1573 }
1574
1575 #[test]
1579 fn fork_full_copy_is_byte_identical_with_provenance() {
1580 let (store, tmp) = temp_store();
1581 let transcript = "{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n";
1582 store.save("orig", "t", transcript).unwrap();
1583 let provenance = store
1584 .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
1585 .unwrap();
1586 assert_eq!(store.load("copy").unwrap(), transcript);
1587 assert_eq!(provenance.forked_from, "orig");
1588 assert_eq!(provenance.forked_at_message, None);
1589 assert_eq!(
1590 store.load_fork_provenance("copy").unwrap(),
1591 Some(provenance)
1592 );
1593 assert_eq!(store.load("orig").unwrap(), transcript);
1595 let _ = std::fs::remove_dir_all(&tmp);
1596 }
1597
1598 #[test]
1602 fn fork_with_truncation_keeps_only_leading_messages() {
1603 let (store, tmp) = temp_store();
1604 let transcript = "{\"role\":\"system\"}\n{\"role\":\"user\"}\n{\"role\":\"assistant\"}\n{\"role\":\"tool\"}\n";
1605 store.save("orig", "t", transcript).unwrap();
1606 let provenance = store
1607 .fork("orig", "partial", "partial fork", Some(2), 42)
1608 .unwrap();
1609 assert_eq!(
1610 store.load("partial").unwrap(),
1611 "{\"role\":\"system\"}\n{\"role\":\"user\"}\n"
1612 );
1613 assert_eq!(provenance.forked_at_message, Some(2));
1614 assert_eq!(store.load("orig").unwrap(), transcript);
1617 let _ = std::fs::remove_dir_all(&tmp);
1618 }
1619
1620 #[test]
1622 fn fork_provenance_is_none_for_a_plain_session() {
1623 let (store, tmp) = temp_store();
1624 store.save("sess", "t", "[]").unwrap();
1625 assert_eq!(store.load_fork_provenance("sess").unwrap(), None);
1626 let _ = std::fs::remove_dir_all(&tmp);
1627 }
1628
1629 #[test]
1632 fn prune_expired_deletes_only_old_archived_sessions() {
1633 let (store, tmp) = temp_store();
1634 store.save("old-archived", "t", "[]").unwrap();
1635 store.archive("old-archived").unwrap();
1636 store.save("fresh-archived", "t", "[]").unwrap();
1637 store.archive("fresh-archived").unwrap();
1638 store.save("active", "t", "[]").unwrap();
1639
1640 let old_path = tmp.join("archived/old-archived.jsonl");
1643 let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
1644 std::fs::OpenOptions::new()
1645 .write(true)
1646 .open(&old_path)
1647 .unwrap()
1648 .set_modified(ancient)
1649 .unwrap();
1650
1651 let pruned = store
1652 .prune_expired(30, std::time::SystemTime::now())
1653 .unwrap();
1654 assert_eq!(pruned, vec!["old-archived".to_string()]);
1655 assert!(!old_path.exists());
1656 assert!(tmp.join("archived/fresh-archived.jsonl").exists());
1657 assert!(tmp.join("active.jsonl").exists());
1658 let _ = std::fs::remove_dir_all(&tmp);
1659 }
1660
1661 #[test]
1664 fn prune_expired_zero_days_prunes_nothing() {
1665 let (store, tmp) = temp_store();
1666 store.save("sess", "t", "[]").unwrap();
1667 store.archive("sess").unwrap();
1668 let pruned = store
1669 .prune_expired(0, std::time::SystemTime::now())
1670 .unwrap();
1671 assert!(pruned.is_empty());
1672 assert!(tmp.join("archived/sess.jsonl").exists());
1673 let _ = std::fs::remove_dir_all(&tmp);
1674 }
1675
1676 #[test]
1679 fn prune_expired_never_touches_active_sessions() {
1680 let (store, tmp) = temp_store();
1681 store.save("active", "t", "[]").unwrap();
1682 let ancient = std::time::SystemTime::now() - std::time::Duration::from_secs(400 * 86_400);
1683 std::fs::OpenOptions::new()
1684 .write(true)
1685 .open(tmp.join("active.jsonl"))
1686 .unwrap()
1687 .set_modified(ancient)
1688 .unwrap();
1689 let pruned = store
1690 .prune_expired(30, std::time::SystemTime::now())
1691 .unwrap();
1692 assert!(pruned.is_empty());
1693 assert!(tmp.join("active.jsonl").exists());
1694 let _ = std::fs::remove_dir_all(&tmp);
1695 }
1696
1697 fn sample_tree() -> crate::session_tree::SessionTree {
1703 let mut tree = crate::session_tree::SessionTree::from_linear(
1704 &[
1705 crate::message::ChatMessage::user("hello"),
1706 crate::message::ChatMessage::assistant("hi"),
1707 ],
1708 1_700_000_000_000,
1709 );
1710 tree.branch("n0", Some("side".to_string()), 1_700_000_001_000)
1711 .unwrap();
1712 tree.append_message(
1713 crate::message::ChatMessage::user("side turn"),
1714 1_700_000_002_000,
1715 );
1716 tree
1717 }
1718
1719 #[test]
1723 fn session_tree_round_trips_losslessly_through_the_store() {
1724 let (store, tmp) = temp_store();
1725 store.save("sess", "t", "[]").unwrap();
1726 let tree = sample_tree();
1727 store.save_tree("sess", &tree).unwrap();
1728 let loaded = store.load_tree("sess").unwrap().expect("just saved");
1729
1730 assert_eq!(loaded.root, tree.root);
1731 assert_eq!(loaded.active_branch, tree.active_branch);
1732 assert_eq!(loaded.nodes.len(), tree.nodes.len());
1733 assert_eq!(
1734 loaded.linear_projection_of("main").unwrap().len(),
1735 tree.linear_projection_of("main").unwrap().len()
1736 );
1737 assert_eq!(
1738 loaded.linear_projection().unwrap().len(),
1739 tree.linear_projection().unwrap().len()
1740 );
1741 let _ = std::fs::remove_dir_all(&tmp);
1742 }
1743
1744 #[test]
1753 fn attack_tree_sidecar_preserves_message_metadata() {
1754 let (store, tmp) = temp_store();
1755 let mut m = crate::message::ChatMessage::user("turn with provenance");
1756 m.metadata
1757 .insert("phase".to_string(), "commentary".to_string());
1758 m.metadata
1759 .insert("turn_id".to_string(), "cx-turn-42".to_string());
1760 m.metadata
1761 .insert("pi_entry_id".to_string(), "entry-7".to_string());
1762 let mut tree = crate::session_tree::SessionTree::from_linear(
1763 &[m.clone(), crate::message::ChatMessage::assistant("ok")],
1764 1,
1765 );
1766 let preserved = tree.rewind("n0", 2).unwrap().unwrap();
1769 store.save("s", "t", "").unwrap();
1770 store.save_tree("s", &tree).unwrap();
1771 let loaded = store.load_tree("s").unwrap().unwrap();
1772 let recovered = loaded.linear_projection_of(&preserved).unwrap();
1773 assert_eq!(recovered.len(), 2);
1774 assert_eq!(
1776 recovered[0].metadata.get("phase"),
1777 Some(&"commentary".to_string())
1778 );
1779 assert_eq!(
1780 recovered[0].metadata.get("turn_id"),
1781 Some(&"cx-turn-42".to_string())
1782 );
1783 assert_eq!(
1784 recovered[0].metadata.get("pi_entry_id"),
1785 Some(&"entry-7".to_string())
1786 );
1787 let _ = std::fs::remove_dir_all(&tmp);
1788 }
1789
1790 #[test]
1799 fn attack_tree_sidecar_preserves_content_alongside_content_parts() {
1800 let (store, tmp) = temp_store();
1801 let mut m = crate::message::ChatMessage::user("plain content");
1802 m.content_parts = Some(vec![serde_json::json!({"type":"text","text":"part"})]);
1803 let tree = crate::session_tree::SessionTree::from_linear(&[m], 1);
1804 store.save("s", "t", "").unwrap();
1805 store.save_tree("s", &tree).unwrap();
1806 let loaded = store.load_tree("s").unwrap().unwrap();
1807 let got = &loaded.node("n0").unwrap().message;
1808 assert_eq!(got.content.as_deref(), Some("plain content"));
1809 assert_eq!(
1810 got.content_parts.as_ref().unwrap()[0]["text"],
1811 serde_json::json!("part")
1812 );
1813 let _ = std::fs::remove_dir_all(&tmp);
1814 }
1815
1816 #[test]
1822 fn session_tree_round_trip_preserves_full_message_fidelity_not_just_length() {
1823 let (store, tmp) = temp_store();
1824 let mut m0 = crate::message::ChatMessage::user("hello");
1825 m0.metadata
1826 .insert("promptSource".to_string(), "cli".to_string());
1827 m0.metadata.insert("isMeta".to_string(), "true".to_string());
1828 let mut m1 = crate::message::ChatMessage::assistant("hi");
1829 m1.content_parts = Some(vec![
1830 serde_json::json!({"type": "text", "text": "hi"}),
1831 serde_json::json!({"type": "image_url", "image_url": {"url": "data:image/png;base64,AA"}}),
1832 ]);
1833 m1.metadata
1834 .insert("review_findings".to_string(), "none".to_string());
1835 let tree = crate::session_tree::SessionTree::from_linear(
1836 &[m0.clone(), m1.clone()],
1837 1_700_000_000_000,
1838 );
1839 store.save("s", "t", "").unwrap();
1840 store.save_tree("s", &tree).unwrap();
1841 let loaded = store.load_tree("s").unwrap().unwrap();
1842
1843 let got0 = &loaded.node("n0").unwrap().message;
1844 assert_eq!(got0.content, m0.content);
1845 assert_eq!(got0.metadata, m0.metadata);
1846 assert_eq!(got0.content_parts, m0.content_parts);
1847
1848 let got1 = &loaded.node("n1").unwrap().message;
1849 assert_eq!(got1.content, m1.content);
1850 assert_eq!(got1.metadata, m1.metadata);
1851 assert_eq!(got1.content_parts, m1.content_parts);
1852
1853 let _ = std::fs::remove_dir_all(&tmp);
1854 }
1855
1856 #[test]
1865 fn tree_sidecar_save_load_save_is_byte_identical_after_f1() {
1866 let (store, tmp) = temp_store();
1867 let mut tree = crate::session_tree::SessionTree::from_linear(
1868 &[
1869 crate::message::ChatMessage::user("a"),
1870 crate::message::ChatMessage::assistant("b"),
1871 ],
1872 1_700_000_000_000,
1873 );
1874 tree.branch("n0", Some("side".to_string()), 2).unwrap();
1875 tree.append_message(crate::message::ChatMessage::user("c"), 3);
1876 tree.label("n1", "checkpoint").unwrap();
1877 store.save("s", "t", "").unwrap();
1878 store.save_tree("s", &tree).unwrap();
1879 let bytes1 = std::fs::read(tmp.join("s.tree.json")).unwrap();
1880 let loaded = store.load_tree("s").unwrap().unwrap();
1881 store.save_tree("s", &loaded).unwrap();
1882 let bytes2 = std::fs::read(tmp.join("s.tree.json")).unwrap();
1883 assert_eq!(bytes1, bytes2);
1884 let _ = std::fs::remove_dir_all(&tmp);
1885 }
1886
1887 #[test]
1891 fn corrupt_tree_json_still_errors_on_load_after_the_fixes() {
1892 let (store, tmp) = temp_store();
1893 store.save("s", "t", "").unwrap();
1894 std::fs::write(tmp.join("s.tree.json"), "{not json").unwrap();
1895 assert!(store.load_tree("s").is_err());
1896 std::fs::write(tmp.join("s.tree.json"), "").unwrap();
1897 assert!(store.load_tree("s").is_err());
1898 let _ = std::fs::remove_dir_all(&tmp);
1899 }
1900
1901 #[test]
1905 fn session_tree_is_absent_by_default() {
1906 let (store, tmp) = temp_store();
1907 store.save("sess", "t", "[]").unwrap();
1908 assert!(store.load_tree("sess").unwrap().is_none());
1909 assert!(!tmp.join("sess.tree.json").exists());
1910 let _ = std::fs::remove_dir_all(&tmp);
1911 }
1912
1913 #[test]
1917 fn session_tree_travels_with_archive_and_is_removed_by_delete() {
1918 let (store, tmp) = temp_store();
1919 store.save("sess", "t", "[]").unwrap();
1920 store.save_tree("sess", &sample_tree()).unwrap();
1921 assert!(tmp.join("sess.tree.json").exists());
1922
1923 store.archive("sess").unwrap();
1924 assert!(!tmp.join("sess.tree.json").exists());
1925 assert!(tmp.join("archived/sess.tree.json").exists());
1926 assert!(store.load_tree("sess").unwrap().is_some());
1928
1929 store.delete("sess").unwrap();
1930 assert!(!tmp.join("archived/sess.tree.json").exists());
1931 assert!(store.load_tree("sess").unwrap().is_none());
1932 let _ = std::fs::remove_dir_all(&tmp);
1933 }
1934
1935 #[test]
1940 fn fork_copies_the_session_tree_sidecar() {
1941 let (store, tmp) = temp_store();
1942 let transcript = "{\"role\":\"user\"}\n";
1943 store.save("orig", "t", transcript).unwrap();
1944 store.save_tree("orig", &sample_tree()).unwrap();
1945
1946 store
1947 .fork("orig", "copy", "fork of t", None, 1_700_000_000_000)
1948 .unwrap();
1949 let copied = store.load_tree("copy").unwrap().expect("copied");
1950 let orig = store.load_tree("orig").unwrap().unwrap();
1951 assert_eq!(copied.nodes.len(), orig.nodes.len());
1952 assert_eq!(copied.branches.len(), orig.branches.len());
1953 let _ = std::fs::remove_dir_all(&tmp);
1954 }
1955
1956 #[test]
1957 fn claude_runtime_manifest_roundtrips_and_travels_with_family_lifecycle() {
1958 let (store, tmp) = temp_store();
1959 store.save("sess", "t", "[]").unwrap();
1960 let source = concat!(
1961 "{\"type\":\"permission-mode\",\"permissionMode\":\"bypassPermissions\",",
1962 "\"timestamp\":\"2026-07-14T00:00:00Z\"}\n"
1963 );
1964 let session = crate::Session::from_claude_code_str(source).unwrap();
1965 let manifest =
1966 crate::claude_runtime_state::ClaudeRuntimeManifest::from_session(&session).unwrap();
1967 store
1968 .save_claude_runtime_manifest("sess", &manifest)
1969 .unwrap();
1970 assert_eq!(
1971 store.load_claude_runtime_manifest("sess").unwrap(),
1972 Some(manifest.clone())
1973 );
1974
1975 store
1976 .fork("sess", "copy", "copy", None, 1_700_000_000_000)
1977 .unwrap();
1978 assert_eq!(
1979 store.load_claude_runtime_manifest("copy").unwrap(),
1980 Some(manifest.clone())
1981 );
1982 store.archive("sess").unwrap();
1983 assert!(!tmp.join("sess.claude-runtime.json").exists());
1984 assert!(tmp.join("archived/sess.claude-runtime.json").exists());
1985 assert_eq!(
1986 store.load_claude_runtime_manifest("sess").unwrap(),
1987 Some(manifest)
1988 );
1989 store.delete("sess").unwrap();
1990 assert!(store
1991 .load_claude_runtime_manifest("sess")
1992 .unwrap()
1993 .is_none());
1994 let _ = std::fs::remove_dir_all(&tmp);
1995 }
1996}