1use crate::core::session::Session;
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use std::io::Write;
25use std::path::{Path, PathBuf};
26
27pub const JOURNAL_SCHEMA_VERSION: u8 = 1;
29
30pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
32
33pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
36
37const META_TAIL_WINDOW: u64 = 64 * 1024;
39
40pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; #[cfg(unix)]
63type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
64
65#[cfg(unix)]
68fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
69 match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
70 Ok(handle) => Ok(Some(handle)),
71 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
72 Err(e) => Err(e),
73 }
74}
75
76#[cfg(unix)]
78fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
79 crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)
80}
81
82#[cfg(unix)]
85fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
86 match handle.open_file(&[name.to_string()]) {
87 Ok(file) => Ok(Some(file)),
88 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
89 Err(e) => Err(e),
90 }
91}
92
93#[cfg(unix)]
95pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
96 let Some(handle) = open_sessions_dir(dir)? else {
97 return Ok(None);
98 };
99 open_artifact(&handle, name)
100}
101
102#[cfg(not(unix))]
105struct SessionsDirHandle {
106 dir: std::path::PathBuf,
107}
108
109#[cfg(not(unix))]
110fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
111 if !dir.exists() {
112 return Ok(None);
113 }
114 Ok(Some(SessionsDirHandle {
115 dir: dir.to_path_buf(),
116 }))
117}
118
119#[cfg(not(unix))]
120fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
121 crate::core::private_fs::ensure_private_dir(dir)?;
122 Ok(SessionsDirHandle {
123 dir: dir.to_path_buf(),
124 })
125}
126
127#[cfg(not(unix))]
128impl SessionsDirHandle {
129 fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
130 crate::core::private_fs::write_atomic_private(&self.dir.join(name), data)
131 .map_err(std::io::Error::other)
132 }
133 fn append_file(&self, name: &str) -> std::io::Result<std::fs::File> {
134 crate::core::private_fs::open_private_append(&self.dir.join(name))
135 .map_err(std::io::Error::other)
136 }
137 fn remove_file(&self, name: &str) -> std::io::Result<()> {
138 std::fs::remove_file(self.dir.join(name))
139 }
140}
141
142#[cfg(not(unix))]
143fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
144 let path = handle.dir.join(name);
145 match std::fs::symlink_metadata(&path) {
146 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
147 Ok(meta) if meta.file_type().is_symlink() => {
148 return Err(std::io::Error::other(format!(
149 "refusing symlinked session artifact {name:?}"
150 )))
151 }
152 _ => {}
153 }
154 match std::fs::File::open(&path) {
155 Ok(f) => Ok(Some(f)),
156 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
157 Err(e) => Err(e),
158 }
159}
160
161#[cfg(not(unix))]
162pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
163 let Some(handle) = open_sessions_dir(dir)? else {
164 return Ok(None);
165 };
166 open_artifact(&handle, name)
167}
168
169fn read_artifact_bytes(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<Vec<u8>>> {
171 use std::io::Read;
172 let Some(file) = open_artifact(handle, name)? else {
173 return Ok(None);
174 };
175 let mut buf = Vec::new();
176 file.take(MAX_PERSISTED_READ_BYTES + 1)
177 .read_to_end(&mut buf)?;
178 if buf.len() as u64 > MAX_PERSISTED_READ_BYTES {
179 return Err(std::io::Error::other(format!(
180 "session artifact {name:?} exceeds the {MAX_PERSISTED_READ_BYTES}-byte read bound"
181 )));
182 }
183 Ok(Some(buf))
184}
185
186#[derive(Debug, Clone)]
188pub struct SessionDirEntry {
189 pub name: String,
190 pub mtime: Option<std::time::SystemTime>,
191 pub byte_len: u64,
194}
195
196pub fn session_dir_entries(dir: &Path) -> std::io::Result<Vec<SessionDirEntry>> {
202 #[cfg(unix)]
203 {
204 let Some(handle) = open_sessions_dir(dir)? else {
205 return Ok(Vec::new());
206 };
207 Ok(handle
208 .entries()?
209 .into_iter()
210 .filter(|e| e.is_file)
211 .map(|e| SessionDirEntry {
212 name: e.name,
213 mtime: e.mtime_unix_ms.map(|ms| {
214 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(ms)
215 }),
216 byte_len: e.byte_len,
217 })
218 .collect())
219 }
220 #[cfg(not(unix))]
221 {
222 if !dir.exists() {
223 return Ok(Vec::new());
224 }
225 let mut out = Vec::new();
226 for entry in std::fs::read_dir(dir)? {
227 let entry = entry?;
228 if !entry.path().is_file() {
229 continue;
230 }
231 let Ok(name) = entry.file_name().into_string() else {
232 continue;
233 };
234 let byte_len = entry
235 .metadata()
236 .map(|metadata| metadata.len())
237 .unwrap_or_default();
238 out.push(SessionDirEntry {
239 name,
240 mtime: entry.metadata().and_then(|m| m.modified()).ok(),
241 byte_len,
242 });
243 }
244 Ok(out)
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
254pub enum SessionPersistence {
255 #[default]
256 Json,
257 Journal,
258}
259
260impl SessionPersistence {
261 pub fn parse(val: &str) -> Option<Self> {
264 match val.trim() {
265 "json" => Some(Self::Json),
266 "journal" => Some(Self::Journal),
267 _ => None,
268 }
269 }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum SaveMode {
275 FullSnapshot,
278 Append { messages: usize },
280}
281
282#[derive(Debug, Clone, Copy)]
284pub struct SaveReceipt {
285 pub mode: SaveMode,
286 pub bytes_written: u64,
287}
288
289#[derive(Debug, Clone)]
292pub struct JournalMetaTail {
293 pub updated_at: DateTime<Utc>,
294 pub session_cost: f64,
295}
296
297pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
300 dir.join(format!("{id}.journal"))
301}
302
303pub fn snapshot_due(journal_bytes: u64, snapshot_bytes: u64) -> bool {
305 journal_bytes >= JOURNAL_SNAPSHOT_MIN_BYTES.max(snapshot_bytes / JOURNAL_SNAPSHOT_RATIO)
306}
307
308#[derive(Serialize, Deserialize)]
311#[serde(tag = "k")]
312enum JournalRecord {
313 #[serde(rename = "open")]
315 Open { v: u8, base: usize },
316 #[serde(rename = "msg")]
318 Msg {
319 v: u8,
320 i: usize,
321 m: serde_json::Value,
322 },
323 #[serde(rename = "meta")]
325 Meta { v: u8, meta: Box<SessionMeta> },
326}
327
328#[derive(Clone, Serialize, Deserialize)]
332#[serde(deny_unknown_fields)]
333struct SessionMeta {
334 id: String,
335 title: String,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 name: Option<String>,
338 model: String,
339 thinking_level: String,
340 system_prompt: Option<String>,
341 created_at: DateTime<Utc>,
342 updated_at: DateTime<Utc>,
343 total_input_tokens: u64,
344 total_output_tokens: u64,
345 session_cost: f64,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 abort_context: Option<String>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
349 parent_session: Option<String>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 compacted_into: Option<String>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 prompt_provenance: Option<crate::prompt::PromptProvenance>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 compaction: Option<crate::core::compaction::CompactionRecord>,
356}
357
358impl SessionMeta {
359 fn of(s: &Session) -> Self {
360 Self {
361 id: s.id.clone(),
362 title: s.title.clone(),
363 name: s.name.clone(),
364 model: s.model.clone(),
365 thinking_level: s.thinking_level.clone(),
366 system_prompt: s.system_prompt.clone(),
367 created_at: s.created_at,
368 updated_at: s.updated_at,
369 total_input_tokens: s.total_input_tokens,
370 total_output_tokens: s.total_output_tokens,
371 session_cost: s.session_cost,
372 abort_context: s.abort_context.clone(),
373 parent_session: s.parent_session.clone(),
374 compacted_into: s.compacted_into.clone(),
375 prompt_provenance: s.prompt_provenance.clone(),
376 compaction: s.compaction.clone(),
377 }
378 }
379
380 fn apply(self, s: &mut Session) {
382 s.title = self.title;
383 s.name = self.name;
384 s.model = self.model;
385 s.thinking_level = self.thinking_level;
386 s.system_prompt = self.system_prompt;
387 s.created_at = self.created_at;
388 s.updated_at = self.updated_at;
389 s.total_input_tokens = self.total_input_tokens;
390 s.total_output_tokens = self.total_output_tokens;
391 s.session_cost = self.session_cost;
392 s.abort_context = self.abort_context;
393 s.parent_session = self.parent_session;
394 s.compacted_into = self.compacted_into;
395 s.prompt_provenance = self.prompt_provenance;
396 s.compaction = self.compaction;
397 }
398}
399
400struct JournalState {
405 durable_len: usize,
406 last_msg: Option<(usize, serde_json::Value)>,
407 bytes: u64,
408 clean: bool,
409}
410
411fn read_journal_state(
412 handle: &SessionsDirHandle,
413 id: &str,
414) -> std::io::Result<Option<JournalState>> {
415 let Some(raw) = read_artifact_bytes(handle, &format!("{id}.journal"))? else {
416 return Ok(None);
417 };
418 let bytes = raw.len() as u64;
419 let text = String::from_utf8_lossy(&raw);
420 let mut lines = text.lines();
421
422 let Some(first) = lines.next() else {
423 return Ok(Some(JournalState {
424 durable_len: 0,
425 last_msg: None,
426 bytes,
427 clean: false,
428 }));
429 };
430 let base = match serde_json::from_str::<JournalRecord>(first) {
431 Ok(JournalRecord::Open { v, base }) if v == JOURNAL_SCHEMA_VERSION => base,
436 _ => {
437 return Ok(Some(JournalState {
440 durable_len: 0,
441 last_msg: None,
442 bytes,
443 clean: false,
444 }));
445 }
446 };
447
448 let mut durable_len = base;
449 let mut last_msg = None;
450 let mut clean = true;
451 for line in lines {
452 match serde_json::from_str::<JournalRecord>(line) {
453 Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
456 if v != JOURNAL_SCHEMA_VERSION =>
457 {
458 clean = false;
459 break;
460 }
461 Ok(JournalRecord::Msg { i, m, .. }) => {
462 if i == durable_len {
463 durable_len += 1;
464 last_msg = Some((i, m));
465 } else if i > durable_len {
466 clean = false; break;
468 }
469 }
471 Ok(JournalRecord::Meta { .. }) => {}
472 Ok(JournalRecord::Open { .. }) | Err(_) => {
473 clean = false; break;
475 }
476 }
477 }
478 Ok(Some(JournalState {
479 durable_len,
480 last_msg,
481 bytes,
482 clean,
483 }))
484}
485
486pub fn save_session_in_dir(
499 dir: &Path,
500 session: &Session,
501 mode: SessionPersistence,
502) -> std::io::Result<SaveReceipt> {
503 let handle = create_sessions_dir(dir)?;
506 match mode {
507 SessionPersistence::Json => {
508 let json = serde_json::to_string(session).map_err(std::io::Error::other)?;
509 handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
510 remove_artifact_if_exists(&handle, &format!("{}.journal", session.id))?;
513 Ok(SaveReceipt {
514 mode: SaveMode::FullSnapshot,
515 bytes_written: json.len() as u64,
516 })
517 }
518 SessionPersistence::Journal => save_journal_mode(&handle, session),
519 }
520}
521
522fn save_journal_mode(
523 handle: &SessionsDirHandle,
524 session: &Session,
525) -> std::io::Result<SaveReceipt> {
526 let journal_name = format!("{}.journal", session.id);
527
528 let snapshot = open_artifact(handle, &format!("{}.json", session.id))?;
529 let state = match read_journal_state(handle, &session.id)? {
530 Some(state) if snapshot.is_some() => state,
531 _ => return full_snapshot_reset(handle, session),
534 };
535
536 let rewrite_needed = !state.clean
539 || session.api_messages.len() < state.durable_len
540 || state.last_msg.as_ref().is_some_and(|(i, m)| {
541 session
542 .api_messages
543 .get(*i)
544 .map_or(true, |live| live.as_ref() != m)
545 });
546 if rewrite_needed {
547 return full_snapshot_reset(handle, session);
548 }
549
550 let mut buf = Vec::new();
552 for (offset, msg) in session.api_messages[state.durable_len..].iter().enumerate() {
553 let record = JournalRecord::Msg {
554 v: JOURNAL_SCHEMA_VERSION,
555 i: state.durable_len + offset,
556 m: msg.as_ref().clone(),
557 };
558 serde_json::to_writer(&mut buf, &record).map_err(std::io::Error::other)?;
559 buf.push(b'\n');
560 }
561 let meta = JournalRecord::Meta {
562 v: JOURNAL_SCHEMA_VERSION,
563 meta: Box::new(SessionMeta::of(session)),
564 };
565 serde_json::to_writer(&mut buf, &meta).map_err(std::io::Error::other)?;
566 buf.push(b'\n');
567
568 let appended = session.api_messages.len() - state.durable_len;
569 let mut file = handle.append_file(&journal_name)?;
570 file.write_all(&buf)?;
571 file.sync_data()?;
572 drop(file);
573
574 let journal_bytes = state.bytes + buf.len() as u64;
578 let snapshot_bytes = snapshot
579 .and_then(|f| f.metadata().ok())
580 .map(|m| m.len())
581 .unwrap_or(0);
582 if snapshot_due(journal_bytes, snapshot_bytes) {
583 let reset = full_snapshot_reset(handle, session)?;
584 return Ok(SaveReceipt {
585 mode: SaveMode::FullSnapshot,
586 bytes_written: buf.len() as u64 + reset.bytes_written,
587 });
588 }
589
590 Ok(SaveReceipt {
591 mode: SaveMode::Append { messages: appended },
592 bytes_written: buf.len() as u64,
593 })
594}
595
596fn full_snapshot_reset(
600 handle: &SessionsDirHandle,
601 session: &Session,
602) -> std::io::Result<SaveReceipt> {
603 let json = serde_json::to_string(session).map_err(std::io::Error::other)?;
604 handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
605
606 let open = JournalRecord::Open {
607 v: JOURNAL_SCHEMA_VERSION,
608 base: session.api_messages.len(),
609 };
610 let mut line = serde_json::to_vec(&open).map_err(std::io::Error::other)?;
611 line.push(b'\n');
612 handle.write_atomic(&format!("{}.journal", session.id), &line)?;
613
614 Ok(SaveReceipt {
615 mode: SaveMode::FullSnapshot,
616 bytes_written: json.len() as u64 + line.len() as u64,
617 })
618}
619
620pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
627 let handle = open_sessions_dir(dir)?.ok_or_else(|| {
630 std::io::Error::new(
631 std::io::ErrorKind::NotFound,
632 format!("no sessions directory for '{id}'"),
633 )
634 })?;
635 let snapshot = read_artifact_bytes(&handle, &format!("{id}.json"))?.ok_or_else(|| {
636 std::io::Error::new(
637 std::io::ErrorKind::NotFound,
638 format!("no session snapshot for '{id}'"),
639 )
640 })?;
641 let mut session: Session = serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
642
643 let Some(raw) = read_artifact_bytes(&handle, &format!("{id}.journal"))? else {
644 return Ok(session);
645 };
646 let text = String::from_utf8_lossy(&raw);
647 let mut lines = text.lines();
648 match lines.next().map(serde_json::from_str::<JournalRecord>) {
652 Some(Ok(JournalRecord::Open { v, .. })) if v == JOURNAL_SCHEMA_VERSION => {}
653 _ => return Ok(session),
654 }
655 for line in lines {
656 match serde_json::from_str::<JournalRecord>(line) {
657 Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
659 if v != JOURNAL_SCHEMA_VERSION =>
660 {
661 break;
662 }
663 Ok(JournalRecord::Msg { i, m, .. }) => {
664 if i == session.api_messages.len() {
665 session.api_messages.push(std::sync::Arc::new(m));
666 } else if i > session.api_messages.len() {
667 break; }
669 }
671 Ok(JournalRecord::Meta { meta, .. }) => {
672 if meta.id == session.id && meta.updated_at >= session.updated_at {
673 meta.apply(&mut session);
674 }
675 }
676 Ok(JournalRecord::Open { .. }) | Err(_) => break, }
678 }
679 Ok(session)
680}
681
682pub fn journal_meta_tail(dir: &Path, id: &str) -> Option<JournalMetaTail> {
687 use std::io::{Read, Seek, SeekFrom};
688 let mut file = confined_open(dir, &format!("{id}.journal")).ok()??;
689 let len = file.metadata().ok()?.len();
690 let start = len.saturating_sub(META_TAIL_WINDOW);
691 file.seek(SeekFrom::Start(start)).ok()?;
692 let mut tail = String::new();
693 file.take(META_TAIL_WINDOW).read_to_string(&mut tail).ok()?;
694
695 let mut freshest = None;
696 for line in tail.lines() {
697 if let Ok(JournalRecord::Meta { v, meta }) = serde_json::from_str::<JournalRecord>(line) {
698 if v == JOURNAL_SCHEMA_VERSION {
699 freshest = Some(JournalMetaTail {
700 updated_at: meta.updated_at,
701 session_cost: meta.session_cost,
702 });
703 }
704 }
705 }
706 freshest
707}
708
709pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
714 let Some(handle) = open_sessions_dir(dir)? else {
715 return Ok(()); };
717 remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
718 remove_artifact_if_exists(&handle, &format!("{id}.journal"))
719}
720
721fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
722 match handle.remove_file(name) {
723 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
724 other => other,
725 }
726}
727
728#[cfg(test)]
732pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
733 let handle = create_sessions_dir(dir)?;
734 handle.write_atomic(&format!("{id}.json"), json)
735}
736
737#[cfg(test)]
740mod tests {
741 use super::*;
742
743 #[test]
748 fn session_meta_stays_in_sync_with_session_schema() {
749 let mut s = Session::new("model-x", "medium", Some("prompt"));
750 s.name = Some("named".into());
751 s.abort_context = Some("ctx".into());
752 s.parent_session = Some("parent".into());
753 s.compacted_into = Some("child".into());
754 let mut value = serde_json::to_value(&s).unwrap();
755 value.as_object_mut().unwrap().remove("api_messages");
756 let meta: SessionMeta = serde_json::from_value(value)
757 .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
758 assert_eq!(meta.id, s.id);
759 assert_eq!(meta.name.as_deref(), Some("named"));
760 }
761
762 #[test]
763 fn meta_roundtrip_applies_every_field() {
764 let mut a = Session::new("model-a", "high", Some("sys"));
765 a.title = "t".into();
766 a.total_input_tokens = 7;
767 a.session_cost = 0.5;
768 a.updated_at = chrono::Utc::now();
769 let mut b = Session::new("model-b", "low", None);
770 b.id = a.id.clone();
771 SessionMeta::of(&a).apply(&mut b);
772 assert_eq!(b.model, "model-a");
773 assert_eq!(b.title, "t");
774 assert_eq!(b.total_input_tokens, 7);
775 assert_eq!(b.session_cost, 0.5);
776 assert_eq!(b.updated_at, a.updated_at);
777 }
778
779 #[cfg(unix)]
782 mod private_modes {
783 use super::*;
784 use crate::core::private_fs::test_support::UmaskGuard;
785 use serial_test::serial;
786 use std::os::unix::fs::PermissionsExt;
787
788 fn mode_of(path: &Path) -> u32 {
789 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
790 }
791
792 #[test]
793 #[serial(umask)]
794 fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
795 let _umask = UmaskGuard::set(0);
796 let tmp = tempfile::TempDir::new().unwrap();
797 let dir = tmp.path().join("sessions");
798 let mut s = Session::new("m", "medium", None);
799 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
800 s.api_messages.push(std::sync::Arc::new(
801 serde_json::json!({"role":"user","content":"x"}),
802 ));
803 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
804
805 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
806 assert_eq!(
807 mode_of(&dir.join(format!("{}.json", s.id))),
808 0o600,
809 "snapshot must be 0600"
810 );
811 assert_eq!(
812 mode_of(&journal_path(&dir, &s.id)),
813 0o600,
814 "journal must be 0600"
815 );
816 }
817
818 #[test]
819 fn journal_append_refuses_symlink_target() {
820 let tmp = tempfile::TempDir::new().unwrap();
821 let dir = tmp.path().join("sessions");
822 std::fs::create_dir_all(&dir).unwrap();
823 let mut s = Session::new("m", "medium", None);
824 let victim = tmp.path().join("victim");
825 std::fs::write(&victim, "original").unwrap();
826 std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
827 s.api_messages.push(std::sync::Arc::new(
829 serde_json::json!({"role":"user","content":"x"}),
830 ));
831 let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
832 assert!(res.is_err(), "journal write onto a symlink must fail");
833 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
834 }
835 }
836}