1mod files;
7mod lease;
8mod list;
9mod repair;
10mod tree;
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::atomic::AtomicBool;
15
16use oino_ai::{
17 AgentMessage, JsonValue, SystemMessage, TextContent, Usage, UserContent, UserContentField,
18};
19use uuid::Uuid;
20
21pub use files::{
22 LEGACY_DIR_NAME, MigratedSession, cwd_session_dir, default_agent_dir, default_sessions_root,
23 encode_cwd, migrate_legacy_file, resolve_path, session_file_name, validate_session_id,
24};
25pub use lease::{
26 LeaseInfo, SessionLease, TakeoverRequest, clear_takeover_request, lock_file_path,
27 lock_metadata_path, read_takeover_request, takeover_path, write_takeover_request,
28};
29pub use list::{ProgressSink, SessionInfo, session_info};
30pub use repair::{INTERRUPTED_TOOL_TEXT, RESUME_REPAIR_TYPE, RepairReport, TOOL_DISPATCH_TYPE};
31pub use tree::SessionTreeNode;
32
33use crate::context::{
34 EntryIndex, SessionContext, SessionProjection, build_context_entries, build_session_context,
35 build_session_projection, current_system_message, walk_up,
36};
37use crate::entry::{
38 BranchSummaryEntry, CURRENT_SESSION_VERSION, CompactionEntry, ContextReplacement,
39 EditableContent, EntryId, EntryKind, IsoTimestamp, SessionEntry, SessionHeader,
40};
41use crate::error::SessionError;
42use crate::migrate::parse_session_text;
43
44#[derive(Clone, Debug, Default, PartialEq, Eq)]
46pub struct CreateOptions {
47 pub id: Option<String>,
49 pub parent_session: Option<PathBuf>,
51}
52
53#[derive(Clone, Debug, Default, PartialEq)]
55pub struct CompactionDraft {
56 pub summary: String,
58 pub first_kept_entry_id: Option<EntryId>,
60 pub tokens_before: u64,
62 pub details: Option<JsonValue>,
64 pub from_hook: Option<bool>,
66 pub usage: Option<Usage>,
68 pub system_message: Option<SystemMessage>,
70}
71
72#[derive(Clone, Debug, PartialEq)]
74pub struct BranchSummaryDraft {
75 pub from_id: EntryId,
77 pub summary: String,
79 pub details: Option<JsonValue>,
81 pub from_hook: Option<bool>,
83 pub usage: Option<Usage>,
85}
86
87pub trait ReadonlySession {
89 fn cwd(&self) -> &Path;
91 fn session_id(&self) -> &str;
93 fn session_file(&self) -> Option<&Path>;
95 fn header(&self) -> &SessionHeader;
97 fn session_name(&self) -> Option<String>;
99 fn leaf_id(&self) -> Option<&EntryId>;
101 fn entry(&self, id: &EntryId) -> Option<&SessionEntry>;
103 fn children(&self, parent: Option<&EntryId>) -> Vec<&SessionEntry>;
105 fn label(&self, id: &EntryId) -> Option<&str>;
107 fn branch(&self, from: Option<&EntryId>) -> Vec<&SessionEntry>;
109 fn entries(&self) -> &[SessionEntry];
111 fn tree(&self) -> Vec<SessionTreeNode>;
113 fn build_context_entries(&self) -> Vec<SessionEntry>;
115 fn build_session_projection(&self) -> SessionProjection;
117 fn build_session_context(&self) -> SessionContext;
119}
120
121#[derive(Debug)]
124struct SessionWriter {
125 file: std::fs::File,
126 path: PathBuf,
127}
128
129#[allow(
131 clippy::struct_excessive_bools,
132 reason = "flat bookkeeping flags; a state machine would obscure the format-parity fields"
133)]
134#[derive(Debug)]
135pub struct SessionManager {
136 cwd: PathBuf,
137 session_dir: PathBuf,
138 persist: bool,
139 session_file: Option<PathBuf>,
140 flushed: bool,
141 has_conversation: bool,
142 header: SessionHeader,
143 index: EntryIndex,
144 labels: HashMap<EntryId, (String, IsoTimestamp)>,
145 leaf: Option<EntryId>,
146 writer: Option<SessionWriter>,
147 dir_synced: bool,
148}
149
150impl Clone for SessionManager {
151 fn clone(&self) -> Self {
152 Self {
153 cwd: self.cwd.clone(),
154 session_dir: self.session_dir.clone(),
155 persist: self.persist,
156 session_file: self.session_file.clone(),
157 flushed: self.flushed,
158 has_conversation: self.has_conversation,
159 header: self.header.clone(),
160 index: self.index.clone(),
161 labels: self.labels.clone(),
162 leaf: self.leaf.clone(),
163 writer: None,
165 dir_synced: self.dir_synced,
166 }
167 }
168}
169
170fn new_header(cwd: &Path, opts: &CreateOptions) -> Result<SessionHeader, SessionError> {
171 if let Some(id) = &opts.id {
172 validate_session_id(id)?;
173 }
174 Ok(header_with(
175 cwd,
176 opts.id.clone(),
177 opts.parent_session.as_deref(),
178 ))
179}
180
181fn header_with(cwd: &Path, id: Option<String>, parent_session: Option<&Path>) -> SessionHeader {
182 SessionHeader {
183 version: CURRENT_SESSION_VERSION,
184 id: id.unwrap_or_else(|| Uuid::now_v7().hyphenated().to_string()),
185 timestamp: IsoTimestamp::now(),
186 cwd: cwd.to_string_lossy().into_owned(),
187 parent_session: parent_session.map(|path| path.to_string_lossy().into_owned()),
188 }
189}
190
191fn is_conversation(entry: &SessionEntry) -> bool {
192 matches!(
193 entry.message(),
194 Some(AgentMessage::User(_) | AgentMessage::Assistant(_))
195 )
196}
197
198fn sanitize_name(name: &str) -> String {
199 let mut out = String::with_capacity(name.len());
200 let mut in_break = false;
201 for ch in name.chars() {
202 if matches!(ch, '\r' | '\n') {
203 if !in_break {
204 out.push(' ');
205 }
206 in_break = true;
207 } else {
208 out.push(ch);
209 in_break = false;
210 }
211 }
212 out.trim().to_owned()
213}
214
215impl SessionManager {
216 fn blank(cwd: &Path, session_dir: &Path, persist: bool, header: SessionHeader) -> Self {
217 Self {
218 cwd: resolve_path(cwd),
219 session_dir: session_dir.to_path_buf(),
220 persist,
221 session_file: None,
222 flushed: false,
223 has_conversation: false,
224 header,
225 index: EntryIndex::default(),
226 labels: HashMap::new(),
227 leaf: None,
228 writer: None,
229 dir_synced: false,
230 }
231 }
232
233 pub fn create(
239 cwd: &Path,
240 session_dir: &Path,
241 opts: CreateOptions,
242 ) -> Result<Self, SessionError> {
243 let cwd = resolve_path(cwd);
244 let mut manager = Self::blank(&cwd, session_dir, true, new_header(&cwd, &opts)?);
245 manager.new_session(opts)?;
246 Ok(manager)
247 }
248
249 pub fn open(
257 path: &Path,
258 session_dir: &Path,
259 cwd_override: Option<&Path>,
260 ) -> Result<Self, SessionError> {
261 let path = Self::migrate_if_legacy(&resolve_path(path), session_dir)?;
262 let cwd = match cwd_override {
263 Some(cwd) => resolve_path(cwd),
264 None if path.exists() => match files::read_header(&path) {
265 Some(header) if header.cwd.is_empty() => {
266 return Err(SessionError::MissingCwd(path));
267 }
268 Some(header) => PathBuf::from(header.cwd),
269 None => std::env::current_dir().map_err(|e| SessionError::io(&path, e))?,
270 },
271 None => std::env::current_dir().map_err(|e| SessionError::io(&path, e))?,
272 };
273 let mut manager = Self::blank(
274 &cwd,
275 session_dir,
276 true,
277 new_header(&cwd, &CreateOptions::default())?,
278 );
279 manager.set_session_file(&path)?;
280 Ok(manager)
281 }
282
283 fn migrate_if_legacy(path: &Path, session_dir: &Path) -> Result<PathBuf, SessionError> {
284 if !path.is_file() || !files::is_legacy_file(path) {
285 return Ok(path.to_path_buf());
286 }
287 let root = path.parent().unwrap_or(session_dir);
288 let migrated = migrate_legacy_file(path, root)?;
289 Ok(migrated.path.unwrap_or(migrated.legacy_path))
290 }
291
292 pub fn continue_recent(cwd: &Path, session_dir: &Path) -> Result<Self, SessionError> {
298 match Self::find_most_recent(cwd, session_dir)? {
299 Some(path) => Self::open(&path, session_dir, Some(cwd)),
300 None => Self::create(cwd, session_dir, CreateOptions::default()),
301 }
302 }
303
304 #[must_use]
307 pub fn in_memory(cwd: &Path, opts: CreateOptions, entries: Vec<SessionEntry>) -> Self {
308 let cwd = resolve_path(cwd);
309 let id = opts.id.filter(|id| validate_session_id(id).is_ok());
310 let header = header_with(&cwd, id, opts.parent_session.as_deref());
311 let mut manager = Self::blank(&cwd, Path::new(""), false, header);
312 manager.load_entries(entries);
313 manager
314 }
315
316 pub fn fork_from(
323 source: &Path,
324 target_cwd: &Path,
325 session_dir: &Path,
326 opts: CreateOptions,
327 ) -> Result<Self, SessionError> {
328 let source = Self::migrate_if_legacy(&resolve_path(source), session_dir)?;
329 let parsed = parse_session_text(&files::read_to_string(&source)?)?
330 .ok_or_else(|| SessionError::MissingHeader(source.clone()))?;
331 let target_cwd = resolve_path(target_cwd);
332 let header = new_header(
333 &target_cwd,
334 &CreateOptions {
335 parent_session: Some(source),
336 ..opts
337 },
338 )?;
339 let path = cwd_session_dir(session_dir, &target_cwd).join(session_file_name(&header));
340 files::write_new(&path, &files::serialize_lines(&header, &parsed.entries)?)?;
341 Self::open(&path, session_dir, Some(&target_cwd))
342 }
343
344 pub fn find_by_id(
350 cwd: &Path,
351 id: &str,
352 session_dir: &Path,
353 ) -> Result<Option<PathBuf>, SessionError> {
354 list::find_by_id(cwd, id, session_dir)
355 }
356
357 pub fn list(
363 cwd: &Path,
364 session_dir: &Path,
365 progress: Option<&dyn ProgressSink>,
366 cancel: Option<&AtomicBool>,
367 ) -> Result<Vec<SessionInfo>, SessionError> {
368 list::list(cwd, session_dir, progress, cancel)
369 }
370
371 pub fn list_all(
377 session_dir: &Path,
378 progress: Option<&dyn ProgressSink>,
379 cancel: Option<&AtomicBool>,
380 ) -> Result<Vec<SessionInfo>, SessionError> {
381 list::list_all(session_dir, progress, cancel)
382 }
383
384 pub fn find_most_recent(
390 cwd: &Path,
391 session_dir: &Path,
392 ) -> Result<Option<PathBuf>, SessionError> {
393 Ok(list::find_most_recent(cwd, session_dir))
394 }
395
396 pub fn migrate_legacy_dir(sessions_root: &Path) -> Result<Vec<MigratedSession>, SessionError> {
402 let mut migrated = Vec::new();
403 let mut candidates = list::jsonl_files(sessions_root);
404 candidates.sort();
405 for path in candidates {
406 if files::is_legacy_file(&path) {
407 migrated.push(migrate_legacy_file(&path, sessions_root)?);
408 }
409 }
410 Ok(migrated)
411 }
412
413 #[allow(
419 clippy::needless_pass_by_value,
420 reason = "contract signature (session-v3 §6.2) mirrors newSession(options)"
421 )]
422 pub fn new_session(&mut self, opts: CreateOptions) -> Result<(), SessionError> {
423 self.header = new_header(&self.cwd, &opts)?;
424 self.index = EntryIndex::default();
425 self.labels.clear();
426 self.leaf = None;
427 self.flushed = false;
428 self.has_conversation = false;
429 self.writer = None;
430 self.dir_synced = false;
431 if self.persist {
432 self.session_file = Some(
433 cwd_session_dir(&self.session_dir, &self.cwd).join(session_file_name(&self.header)),
434 );
435 }
436 Ok(())
437 }
438
439 pub fn set_session_file(&mut self, path: &Path) -> Result<(), SessionError> {
446 self.writer = None;
447 self.dir_synced = false;
448 let path = Self::migrate_if_legacy(&resolve_path(path), &self.session_dir)?;
449 if !path.exists() {
450 self.new_session(CreateOptions::default())?;
451 self.session_file = Some(path);
452 return Ok(());
453 }
454 let text = files::read_to_string(&path)?;
455 match parse_session_text(&text)? {
456 None if !text.is_empty() => Err(SessionError::MissingHeader(path)),
457 None => {
458 self.new_session(CreateOptions::default())?;
459 self.session_file = Some(path.clone());
460 files::write_atomic(&path, &files::serialize_lines(&self.header, &[])?)?;
461 self.flushed = true;
462 Ok(())
463 }
464 Some(parsed) => {
465 self.header = parsed.header;
466 self.session_file = Some(path.clone());
467 self.load_entries(parsed.entries);
468 self.flushed = true;
469 if parsed.migrated {
470 files::write_atomic(
471 &path,
472 &files::serialize_lines(&self.header, self.index.entries())?,
473 )?;
474 }
475 Ok(())
476 }
477 }
478 }
479
480 fn load_entries(&mut self, entries: Vec<SessionEntry>) {
481 self.index = EntryIndex::new(entries);
482 self.labels.clear();
483 self.leaf = None;
484 self.has_conversation = false;
485 let mut labels = HashMap::new();
486 for entry in self.index.entries() {
487 self.leaf = Some(entry.id.clone());
488 self.has_conversation |= is_conversation(entry);
489 if let EntryKind::Label { target_id, label } = &entry.kind {
490 match label.as_deref().filter(|l| !l.is_empty()) {
491 Some(label) => {
492 labels.insert(
493 target_id.clone(),
494 (label.to_owned(), entry.timestamp.clone()),
495 );
496 }
497 None => {
498 labels.remove(target_id);
499 }
500 }
501 }
502 }
503 self.labels = labels;
504 }
505
506 #[must_use]
508 pub const fn is_persisted(&self) -> bool {
509 self.persist
510 }
511
512 #[must_use]
514 pub const fn is_flushed(&self) -> bool {
515 self.flushed
516 }
517
518 #[must_use]
520 pub fn cwd(&self) -> &Path {
521 &self.cwd
522 }
523
524 #[must_use]
526 pub fn session_dir(&self) -> &Path {
527 &self.session_dir
528 }
529
530 #[must_use]
532 pub fn uses_default_session_dir(&self) -> bool {
533 default_sessions_root()
534 .is_some_and(|root| resolve_path(&root) == resolve_path(&self.session_dir))
535 }
536
537 #[must_use]
539 pub fn session_id(&self) -> &str {
540 &self.header.id
541 }
542
543 #[must_use]
545 pub fn session_file(&self) -> Option<&Path> {
546 self.session_file.as_deref()
547 }
548
549 #[must_use]
551 pub const fn header(&self) -> &SessionHeader {
552 &self.header
553 }
554
555 #[must_use]
557 pub fn entry_count(&self) -> usize {
558 self.index.id_count()
559 }
560
561 #[must_use]
563 pub const fn index(&self) -> &EntryIndex {
564 &self.index
565 }
566
567 fn push(
568 &mut self,
569 kind: EntryKind,
570 id: Option<EntryId>,
571 parent: Option<EntryId>,
572 timestamp: Option<IsoTimestamp>,
573 ) -> Result<EntryId, SessionError> {
574 let id = id.unwrap_or_else(|| self.generate_id());
575 let entry = SessionEntry {
576 id: id.clone(),
577 parent_id: parent,
578 timestamp: timestamp.unwrap_or_else(IsoTimestamp::now),
579 kind,
580 };
581 self.has_conversation |= is_conversation(&entry);
582 let line = serde_json::to_string(&entry).map_err(|source| SessionError::Json {
583 path: self.session_file.clone().unwrap_or_default(),
584 line: 0,
585 source,
586 })?;
587 self.index.push(entry);
588 self.leaf = Some(id.clone());
589 self.persist_line(&line)?;
590 Ok(id)
591 }
592
593 fn generate_id(&self) -> EntryId {
594 EntryId::generate(|candidate| self.index.contains_str(candidate))
595 }
596
597 fn persist_line(&mut self, line: &str) -> Result<(), SessionError> {
598 let Some(path) = self.session_file.clone().filter(|_| self.persist) else {
599 return Ok(());
600 };
601 if self.flushed {
602 return self.write_appended(&path, line);
603 }
604 if !self.has_conversation {
605 return Ok(());
606 }
607 self.flush_initial(&path)
608 }
609
610 fn flush_initial(&mut self, path: &Path) -> Result<(), SessionError> {
613 files::write_new(
614 path,
615 &files::serialize_lines(&self.header, self.index.entries())?,
616 )?;
617 self.flushed = true;
618 self.writer = None;
619 self.dir_synced = false;
620 self.sync()
621 }
622
623 fn write_appended(&mut self, path: &Path, line: &str) -> Result<(), SessionError> {
626 self.ensure_writer(path)?;
627 self.with_writer(path, |file| files::write_line(file, path, line))
628 }
629
630 fn ensure_writer(&mut self, path: &Path) -> Result<(), SessionError> {
632 if self
633 .writer
634 .as_ref()
635 .is_none_or(|writer| writer.path != path)
636 {
637 self.writer = Some(SessionWriter {
638 file: files::open_append(path)?,
639 path: path.to_path_buf(),
640 });
641 }
642 Ok(())
643 }
644
645 fn with_writer(
647 &mut self,
648 path: &Path,
649 f: impl FnOnce(&mut std::fs::File) -> Result<(), SessionError>,
650 ) -> Result<(), SessionError> {
651 match self.writer.as_mut().filter(|writer| writer.path == path) {
652 Some(writer) => f(&mut writer.file),
653 None => Err(SessionError::io(
654 path,
655 std::io::Error::other("session append writer handle missing"),
656 )),
657 }
658 }
659
660 pub fn sync(&mut self) -> Result<(), SessionError> {
674 let Some(path) = self.session_file.clone().filter(|_| self.persist) else {
675 return Ok(());
676 };
677 if !self.flushed {
678 if !self.has_conversation {
679 return Ok(());
680 }
681 return self.flush_initial(&path);
682 }
683 self.ensure_writer(&path)?;
684 self.with_writer(&path, |file| {
685 file.sync_all().map_err(|e| SessionError::io(&path, e))
686 })?;
687 if !self.dir_synced {
688 files::sync_dir(&path)?;
689 self.dir_synced = true;
690 }
691 Ok(())
692 }
693
694 pub fn append_entry(&mut self, kind: EntryKind) -> Result<EntryId, SessionError> {
700 let parent = self.leaf.clone();
701 self.push(kind, None, parent, None)
702 }
703
704 pub fn append_message(&mut self, message: AgentMessage) -> Result<EntryId, SessionError> {
710 if matches!(
711 message,
712 AgentMessage::BranchSummary(_) | AgentMessage::CompactionSummary(_)
713 ) {
714 return Err(SessionError::InvalidAppend(
715 "summary messages are stored as branch_summary/compaction entries",
716 ));
717 }
718 self.append_entry(EntryKind::Message { message })
719 }
720
721 pub fn append_thinking_level_change(&mut self, level: &str) -> Result<EntryId, SessionError> {
727 self.append_entry(EntryKind::ThinkingLevelChange {
728 thinking_level: level.to_owned(),
729 })
730 }
731
732 pub fn append_model_change(
738 &mut self,
739 provider: &str,
740 model_id: &str,
741 ) -> Result<EntryId, SessionError> {
742 self.append_entry(EntryKind::ModelChange {
743 provider: provider.to_owned(),
744 model_id: model_id.to_owned(),
745 })
746 }
747
748 pub fn append_usage(
754 &mut self,
755 kind: &str,
756 provider: &str,
757 model: &str,
758 usage: Usage,
759 note: Option<String>,
760 ) -> Result<EntryId, SessionError> {
761 self.append_entry(EntryKind::Usage {
762 kind: kind.to_owned(),
763 provider: provider.to_owned(),
764 model: model.to_owned(),
765 usage,
766 note: note.filter(|n| !n.is_empty()),
767 })
768 }
769
770 pub fn append_compaction(&mut self, draft: CompactionDraft) -> Result<EntryId, SessionError> {
777 let id = self.generate_id();
778 let now = IsoTimestamp::now();
779 let system_message = draft.system_message.or_else(|| {
780 current_system_message(&self.build_session_projection().messages).map(|mut system| {
781 system.timestamp = now.to_ms().unwrap_or(0);
782 system
783 })
784 });
785 let kind = EntryKind::Compaction(CompactionEntry {
786 summary: draft.summary,
787 first_kept_entry_id: draft.first_kept_entry_id.unwrap_or_else(|| id.clone()),
788 tokens_before: draft.tokens_before,
789 details: draft.details,
790 usage: draft.usage,
791 from_hook: draft.from_hook,
792 system_message,
793 });
794 let parent = self.leaf.clone();
795 self.push(kind, Some(id), parent, Some(now))
796 }
797
798 pub fn append_custom_entry(
804 &mut self,
805 custom_type: &str,
806 data: Option<JsonValue>,
807 ) -> Result<EntryId, SessionError> {
808 self.append_entry(EntryKind::Custom {
809 custom_type: custom_type.to_owned(),
810 data,
811 })
812 }
813
814 pub fn append_session_info(&mut self, name: Option<&str>) -> Result<EntryId, SessionError> {
820 self.append_entry(EntryKind::SessionInfo {
821 name: name.map(sanitize_name),
822 })
823 }
824
825 pub fn append_custom_message_entry(
831 &mut self,
832 custom_type: &str,
833 content: UserContentField,
834 display: bool,
835 details: Option<JsonValue>,
836 ) -> Result<EntryId, SessionError> {
837 self.append_entry(EntryKind::CustomMessage {
838 custom_type: custom_type.to_owned(),
839 content,
840 display,
841 details,
842 })
843 }
844
845 pub fn append_context_edit(
852 &mut self,
853 target: &EntryId,
854 replacement: Option<EditableContent>,
855 ) -> Result<EntryId, SessionError> {
856 let entry = self
857 .index
858 .get(target)
859 .ok_or_else(|| SessionError::UnknownEntry(target.clone()))?;
860 if !self.branch(None).iter().any(|e| e.id == *target) {
861 return Err(SessionError::InvalidAppend(
862 "context edit target is not on the active branch",
863 ));
864 }
865 let role = match &entry.kind {
866 EntryKind::CustomMessage { .. } => "custom",
867 EntryKind::Message { message } => message.role(),
868 _ => "",
869 };
870 if !matches!(role, "custom" | "user" | "assistant" | "toolResult") {
871 return Err(SessionError::InvalidAppend(
872 "context edit target does not contribute editable model content",
873 ));
874 }
875 let replacement = replacement.map(|content| ContextReplacement {
876 content: match (role, content) {
877 ("assistant", EditableContent::Text(text)) => {
878 EditableContent::Assistant(vec![oino_ai::AssistantContent::Text(
879 TextContent::new(text),
880 )])
881 }
882 ("toolResult", EditableContent::Text(text)) => {
883 EditableContent::User(vec![UserContent::Text(TextContent::new(text))])
884 }
885 (_, content) => content,
886 },
887 });
888 self.append_entry(EntryKind::ContextEdit {
889 target_id: target.clone(),
890 replacement,
891 })
892 }
893
894 pub fn append_label_change(
900 &mut self,
901 target: &EntryId,
902 label: Option<&str>,
903 ) -> Result<EntryId, SessionError> {
904 if !self.index.contains(target) {
905 return Err(SessionError::UnknownEntry(target.clone()));
906 }
907 let label = label.filter(|l| !l.is_empty()).map(str::to_owned);
908 let id = self.append_entry(EntryKind::Label {
909 target_id: target.clone(),
910 label: label.clone(),
911 })?;
912 match label {
913 Some(label) => {
914 let timestamp = self
915 .index
916 .get(&id)
917 .map(|e| e.timestamp.clone())
918 .unwrap_or_default();
919 self.labels.insert(target.clone(), (label, timestamp));
920 }
921 None => {
922 self.labels.remove(target);
923 }
924 }
925 Ok(id)
926 }
927
928 #[must_use]
930 pub fn session_name(&self) -> Option<String> {
931 self.index
932 .entries()
933 .iter()
934 .rev()
935 .find_map(|entry| match &entry.kind {
936 EntryKind::SessionInfo { name } => Some(
937 name.as_deref()
938 .map(str::trim)
939 .filter(|n| !n.is_empty())
940 .map(str::to_owned),
941 ),
942 _ => None,
943 })?
944 }
945
946 #[must_use]
948 pub const fn leaf_id(&self) -> Option<&EntryId> {
949 self.leaf.as_ref()
950 }
951
952 #[must_use]
954 pub fn leaf_entry(&self) -> Option<&SessionEntry> {
955 self.leaf.as_ref().and_then(|id| self.index.get(id))
956 }
957
958 #[must_use]
960 pub fn entry(&self, id: &EntryId) -> Option<&SessionEntry> {
961 self.index.get(id)
962 }
963
964 #[must_use]
966 pub fn children(&self, parent: Option<&EntryId>) -> Vec<&SessionEntry> {
967 self.index
968 .entries()
969 .iter()
970 .filter(|entry| entry.parent_id.as_ref() == parent)
971 .collect()
972 }
973
974 #[must_use]
976 pub fn label(&self, id: &EntryId) -> Option<&str> {
977 self.labels.get(id).map(|(label, _)| label.as_str())
978 }
979
980 #[must_use]
982 pub fn branch(&self, from: Option<&EntryId>) -> Vec<&SessionEntry> {
983 from.or(self.leaf.as_ref())
984 .and_then(|id| self.index.get(id))
985 .map(|start| walk_up(&self.index, start))
986 .unwrap_or_default()
987 }
988
989 #[must_use]
991 pub fn entries(&self) -> &[SessionEntry] {
992 self.index.entries()
993 }
994
995 #[must_use]
997 pub fn tree(&self) -> Vec<SessionTreeNode> {
998 tree::build(self.index.entries(), &self.labels)
999 }
1000
1001 #[must_use]
1003 pub fn build_context_entries(&self) -> Vec<SessionEntry> {
1004 build_context_entries(&self.index, self.leaf.as_ref())
1005 }
1006
1007 #[must_use]
1009 pub fn build_session_projection(&self) -> SessionProjection {
1010 build_session_projection(&self.index, self.leaf.as_ref())
1011 }
1012
1013 #[must_use]
1015 pub fn build_session_context(&self) -> SessionContext {
1016 build_session_context(&self.index, self.leaf.as_ref())
1017 }
1018
1019 pub fn set_leaf(&mut self, id: &EntryId) -> Result<(), SessionError> {
1025 if !self.index.contains(id) {
1026 return Err(SessionError::UnknownEntry(id.clone()));
1027 }
1028 self.leaf = Some(id.clone());
1029 Ok(())
1030 }
1031
1032 pub fn reset_leaf(&mut self) {
1034 self.leaf = None;
1035 self.writer = None;
1036 }
1037
1038 pub fn branch_with_summary(
1044 &mut self,
1045 from: Option<&EntryId>,
1046 summary: BranchSummaryDraft,
1047 ) -> Result<EntryId, SessionError> {
1048 if let Some(from) = from.filter(|id| !self.index.contains(id)) {
1049 return Err(SessionError::UnknownEntry(from.clone()));
1050 }
1051 self.leaf = from.cloned();
1052 self.append_entry(EntryKind::BranchSummary(BranchSummaryEntry {
1053 from_id: summary.from_id,
1054 summary: summary.summary,
1055 details: summary.details,
1056 usage: summary.usage,
1057 from_hook: summary.from_hook,
1058 }))
1059 }
1060
1061 fn branched(&self, leaf: &EntryId) -> Result<(SessionHeader, Vec<SessionEntry>), SessionError> {
1064 let path = self.branch(Some(leaf));
1065 if path.is_empty() {
1066 return Err(SessionError::UnknownEntry(leaf.clone()));
1067 }
1068 let mut kept: Vec<SessionEntry> = Vec::with_capacity(path.len());
1069 let mut label_replacement: HashMap<EntryId, EntryId> = HashMap::new();
1070 let mut pending_labels: Vec<EntryId> = Vec::new();
1071 let mut parent: Option<EntryId> = None;
1072 for entry in path {
1073 if matches!(entry.kind, EntryKind::Label { .. }) {
1074 pending_labels.push(entry.id.clone());
1075 continue;
1076 }
1077 for label in pending_labels.drain(..) {
1078 label_replacement.insert(label, entry.id.clone());
1079 }
1080 let mut copy = entry.clone();
1081 copy.parent_id.clone_from(&parent);
1082 if let EntryKind::Compaction(data) = &mut copy.kind
1083 && data.first_kept_entry_id != entry.id
1084 && let Some(replacement) = label_replacement.get(&data.first_kept_entry_id)
1085 {
1086 data.first_kept_entry_id = replacement.clone();
1087 }
1088 parent = Some(entry.id.clone());
1089 kept.push(copy);
1090 }
1091 let header = new_header(
1092 &self.cwd,
1093 &CreateOptions {
1094 id: None,
1095 parent_session: self.session_file.clone().filter(|_| self.persist),
1096 },
1097 )?;
1098 let mut labels: Vec<(&EntryId, &(String, IsoTimestamp))> = self
1099 .labels
1100 .iter()
1101 .filter(|(target, _)| kept.iter().any(|e| &e.id == *target))
1102 .collect();
1103 labels.sort_by(|a, b| a.1.1.cmp(&b.1.1).then_with(|| a.0.cmp(b.0)));
1104 let mut taken: std::collections::HashSet<String> =
1105 kept.iter().map(|e| e.id.to_string()).collect();
1106 let mut previous = kept.last().map(|e| e.id.clone());
1107 for (target, (label, timestamp)) in labels {
1108 let id = EntryId::generate(|candidate| taken.contains(candidate));
1109 taken.insert(id.to_string());
1110 kept.push(SessionEntry {
1111 id: id.clone(),
1112 parent_id: previous.replace(id),
1113 timestamp: timestamp.clone(),
1114 kind: EntryKind::Label {
1115 target_id: target.clone(),
1116 label: Some(label.clone()),
1117 },
1118 });
1119 }
1120 Ok((header, kept))
1121 }
1122
1123 pub fn create_branched_session(&self, leaf: &EntryId) -> Result<PathBuf, SessionError> {
1131 if !self.persist {
1132 return Err(SessionError::InvalidAppend(
1133 "in-memory sessions have no file to branch into",
1134 ));
1135 }
1136 let (header, entries) = self.branched(leaf)?;
1137 let path = cwd_session_dir(&self.session_dir, &self.cwd).join(session_file_name(&header));
1138 files::write_new(&path, &files::serialize_lines(&header, &entries)?)?;
1139 Ok(path)
1140 }
1141
1142 pub fn branch_into_new_session(
1150 &mut self,
1151 leaf: &EntryId,
1152 ) -> Result<Option<PathBuf>, SessionError> {
1153 let (header, entries) = self.branched(leaf)?;
1154 self.header = header;
1155 self.load_entries(entries);
1156 self.flushed = false;
1157 self.writer = None;
1158 self.dir_synced = false;
1159 if !self.persist {
1160 self.session_file = None;
1161 return Ok(None);
1162 }
1163 let path =
1164 cwd_session_dir(&self.session_dir, &self.cwd).join(session_file_name(&self.header));
1165 self.session_file = Some(path.clone());
1166 if self.has_conversation {
1167 files::write_atomic(
1168 &path,
1169 &files::serialize_lines(&self.header, self.index.entries())?,
1170 )?;
1171 self.flushed = true;
1172 }
1173 Ok(Some(path))
1174 }
1175}
1176
1177impl ReadonlySession for SessionManager {
1178 fn cwd(&self) -> &Path {
1179 Self::cwd(self)
1180 }
1181 fn session_id(&self) -> &str {
1182 Self::session_id(self)
1183 }
1184 fn session_file(&self) -> Option<&Path> {
1185 Self::session_file(self)
1186 }
1187 fn header(&self) -> &SessionHeader {
1188 Self::header(self)
1189 }
1190 fn session_name(&self) -> Option<String> {
1191 Self::session_name(self)
1192 }
1193 fn leaf_id(&self) -> Option<&EntryId> {
1194 Self::leaf_id(self)
1195 }
1196 fn entry(&self, id: &EntryId) -> Option<&SessionEntry> {
1197 Self::entry(self, id)
1198 }
1199 fn children(&self, parent: Option<&EntryId>) -> Vec<&SessionEntry> {
1200 Self::children(self, parent)
1201 }
1202 fn label(&self, id: &EntryId) -> Option<&str> {
1203 Self::label(self, id)
1204 }
1205 fn branch(&self, from: Option<&EntryId>) -> Vec<&SessionEntry> {
1206 Self::branch(self, from)
1207 }
1208 fn entries(&self) -> &[SessionEntry] {
1209 Self::entries(self)
1210 }
1211 fn tree(&self) -> Vec<SessionTreeNode> {
1212 Self::tree(self)
1213 }
1214 fn build_context_entries(&self) -> Vec<SessionEntry> {
1215 Self::build_context_entries(self)
1216 }
1217 fn build_session_projection(&self) -> SessionProjection {
1218 Self::build_session_projection(self)
1219 }
1220 fn build_session_context(&self) -> SessionContext {
1221 Self::build_session_context(self)
1222 }
1223}