1use crate::core::session::{KeepwarmTokens, Session};
31use crate::core::stream_types::SharedMessage;
32use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34use std::io::Write;
35use std::path::{Path, PathBuf};
36
37pub const JOURNAL_SCHEMA_VERSION: u8 = 2;
39
40pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
42
43pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
46
47const META_TAIL_WINDOW: u64 = 64 * 1024;
49
50pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; #[cfg(unix)]
73type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
74
75#[cfg(unix)]
78fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
79 match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
80 Ok(handle) => Ok(Some(handle)),
81 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
82 Err(e) => Err(e),
83 }
84}
85
86#[cfg(unix)]
88fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
89 crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)
90}
91
92#[cfg(unix)]
95fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
96 match handle.open_file(&[name.to_string()]) {
97 Ok(file) => Ok(Some(file)),
98 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
99 Err(e) => Err(e),
100 }
101}
102
103#[cfg(unix)]
105pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
106 let Some(handle) = open_sessions_dir(dir)? else {
107 return Ok(None);
108 };
109 open_artifact(&handle, name)
110}
111
112#[cfg(not(unix))]
115struct SessionsDirHandle {
116 dir: std::path::PathBuf,
117}
118
119#[cfg(not(unix))]
120fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
121 if !dir.exists() {
122 return Ok(None);
123 }
124 Ok(Some(SessionsDirHandle {
125 dir: dir.to_path_buf(),
126 }))
127}
128
129#[cfg(not(unix))]
130fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
131 crate::core::private_fs::ensure_private_dir(dir)?;
132 Ok(SessionsDirHandle {
133 dir: dir.to_path_buf(),
134 })
135}
136
137#[cfg(not(unix))]
138impl SessionsDirHandle {
139 fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
140 crate::core::private_fs::write_atomic_private(&self.dir.join(name), data)
141 .map_err(std::io::Error::other)
142 }
143 fn append_file(&self, name: &str) -> std::io::Result<std::fs::File> {
144 crate::core::private_fs::open_private_append(&self.dir.join(name))
145 .map_err(std::io::Error::other)
146 }
147 fn remove_file(&self, name: &str) -> std::io::Result<()> {
148 std::fs::remove_file(self.dir.join(name))
149 }
150}
151
152#[cfg(not(unix))]
153fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
154 let path = handle.dir.join(name);
155 match std::fs::symlink_metadata(&path) {
156 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
157 Ok(meta) if meta.file_type().is_symlink() => {
158 return Err(std::io::Error::other(format!(
159 "refusing symlinked session artifact {name:?}"
160 )))
161 }
162 _ => {}
163 }
164 match std::fs::File::open(&path) {
165 Ok(f) => Ok(Some(f)),
166 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
167 Err(e) => Err(e),
168 }
169}
170
171#[cfg(not(unix))]
172pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
173 let Some(handle) = open_sessions_dir(dir)? else {
174 return Ok(None);
175 };
176 open_artifact(&handle, name)
177}
178
179fn read_artifact_bytes(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<Vec<u8>>> {
181 use std::io::Read;
182 let Some(file) = open_artifact(handle, name)? else {
183 return Ok(None);
184 };
185 let mut buf = Vec::new();
186 file.take(MAX_PERSISTED_READ_BYTES + 1)
187 .read_to_end(&mut buf)?;
188 if buf.len() as u64 > MAX_PERSISTED_READ_BYTES {
189 return Err(std::io::Error::other(format!(
190 "session artifact {name:?} exceeds the {MAX_PERSISTED_READ_BYTES}-byte read bound"
191 )));
192 }
193 Ok(Some(buf))
194}
195
196#[derive(Debug, Clone)]
198pub struct SessionDirEntry {
199 pub name: String,
200 pub mtime: Option<std::time::SystemTime>,
201 pub byte_len: u64,
204}
205
206pub fn session_dir_entries(dir: &Path) -> std::io::Result<Vec<SessionDirEntry>> {
212 #[cfg(unix)]
213 {
214 let Some(handle) = open_sessions_dir(dir)? else {
215 return Ok(Vec::new());
216 };
217 Ok(handle
218 .entries()?
219 .into_iter()
220 .filter(|e| e.is_file)
221 .map(|e| SessionDirEntry {
222 name: e.name,
223 mtime: e.mtime_unix_ms.map(|ms| {
224 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(ms)
225 }),
226 byte_len: e.byte_len,
227 })
228 .collect())
229 }
230 #[cfg(not(unix))]
231 {
232 if !dir.exists() {
233 return Ok(Vec::new());
234 }
235 let mut out = Vec::new();
236 for entry in std::fs::read_dir(dir)? {
237 let entry = entry?;
238 if !entry.path().is_file() {
239 continue;
240 }
241 let Ok(name) = entry.file_name().into_string() else {
242 continue;
243 };
244 let byte_len = entry
245 .metadata()
246 .map(|metadata| metadata.len())
247 .unwrap_or_default();
248 out.push(SessionDirEntry {
249 name,
250 mtime: entry.metadata().and_then(|m| m.modified()).ok(),
251 byte_len,
252 });
253 }
254 Ok(out)
255 }
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
264pub enum SessionPersistence {
265 #[default]
266 Json,
267 Journal,
268}
269
270impl SessionPersistence {
271 pub fn parse(val: &str) -> Option<Self> {
274 match val.trim() {
275 "json" => Some(Self::Json),
276 "journal" => Some(Self::Journal),
277 _ => None,
278 }
279 }
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum SaveMode {
285 FullSnapshot,
288 Append { messages: usize },
290}
291
292#[derive(Debug, Clone, Copy)]
294pub struct SaveReceipt {
295 pub mode: SaveMode,
296 pub bytes_written: u64,
297}
298
299#[derive(Debug, Clone)]
302pub struct JournalMetaTail {
303 pub updated_at: DateTime<Utc>,
304 pub session_cost: f64,
305 pub message_count: Option<usize>,
307}
308
309pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
312 dir.join(format!("{id}.journal"))
313}
314
315pub fn snapshot_due(journal_bytes: u64, snapshot_bytes: u64) -> bool {
317 journal_bytes >= JOURNAL_SNAPSHOT_MIN_BYTES.max(snapshot_bytes / JOURNAL_SNAPSHOT_RATIO)
318}
319
320#[derive(Serialize, Deserialize)]
323#[serde(tag = "k")]
324enum JournalRecord {
325 #[serde(rename = "open")]
327 Open {
328 v: u8,
329 base: usize,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 generation: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 history_hash: Option<String>,
334 },
335 #[serde(rename = "msg")]
337 Msg {
338 v: u8,
339 i: usize,
340 m: serde_json::Value,
341 },
342 #[serde(rename = "meta")]
344 Meta {
345 v: u8,
346 meta: Box<SessionMeta>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
348 history_hash: Option<String>,
349 },
350}
351
352fn is_zero_cost(v: &f64) -> bool {
353 *v == 0.0
354}
355
356#[cfg_attr(not(test), allow(dead_code))]
363const SNAPSHOT_ONLY_FIELDS: &[&str] = &["keepwarm_cost", "keepwarm_tokens"];
364
365#[derive(Clone, Serialize, Deserialize)]
371#[serde(deny_unknown_fields)]
372struct SessionMeta {
373 id: String,
374 title: String,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 name: Option<String>,
377 model: String,
378 thinking_level: String,
379 system_prompt: Option<String>,
380 created_at: DateTime<Utc>,
381 updated_at: DateTime<Utc>,
382 total_input_tokens: u64,
383 total_output_tokens: u64,
384 session_cost: f64,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 message_count: Option<usize>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 abort_context: Option<String>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 parent_session: Option<String>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 compacted_into: Option<String>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 env: Option<Vec<(String, String)>>,
395 #[serde(default, skip_serializing_if = "Vec::is_empty")]
396 env_stripped: Vec<String>,
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 prompt_provenance: Option<crate::prompt::PromptProvenance>,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
400 compaction: Option<crate::core::compaction::CompactionRecord>,
401}
402
403impl SessionMeta {
404 fn of(s: &Session) -> Self {
405 let env = scrub_secret_env(s.env.as_ref());
409 Self {
410 id: s.id.clone(),
411 title: s.title.clone(),
412 name: s.name.clone(),
413 model: s.model.clone(),
414 thinking_level: s.thinking_level.clone(),
415 system_prompt: s.system_prompt.clone(),
416 created_at: s.created_at,
417 updated_at: s.updated_at,
418 total_input_tokens: s.total_input_tokens,
419 total_output_tokens: s.total_output_tokens,
420 session_cost: s.session_cost,
421 message_count: Some(s.api_messages.len()),
422 abort_context: s.abort_context.clone(),
423 parent_session: s.parent_session.clone(),
424 compacted_into: s.compacted_into.clone(),
425 env,
426 env_stripped: s.env_stripped.clone(),
427 prompt_provenance: s.prompt_provenance.clone(),
428 compaction: s.compaction.clone(),
429 }
430 }
431
432 fn apply(self, s: &mut Session) {
434 s.title = self.title;
435 s.name = self.name;
436 s.model = self.model;
437 s.thinking_level = self.thinking_level;
438 s.system_prompt = self.system_prompt;
439 s.created_at = self.created_at;
440 s.updated_at = self.updated_at;
441 s.total_input_tokens = self.total_input_tokens;
442 s.total_output_tokens = self.total_output_tokens;
443 s.session_cost = self.session_cost;
444 s.abort_context = self.abort_context;
445 s.parent_session = self.parent_session;
446 s.compacted_into = self.compacted_into;
447 s.env = self.env;
448 s.env_stripped = self.env_stripped;
449 s.prompt_provenance = self.prompt_provenance;
450 s.compaction = self.compaction;
451 }
452}
453
454#[derive(Default, Deserialize)]
457struct SnapshotBinding {
458 #[serde(default, rename = "_journal_generation")]
459 generation: Option<String>,
460}
461
462fn snapshot_binding(file: std::fs::File) -> std::io::Result<SnapshotBinding> {
463 let header = crate::core::session::read_session_header_from_file(file)
464 .ok_or_else(|| std::io::Error::other("cannot read session snapshot header"))?;
465 serde_json::from_str(&header).map_err(std::io::Error::other)
466}
467
468fn matching_open(record: &JournalRecord, snapshot_generation: Option<&str>) -> Option<u8> {
471 match record {
472 JournalRecord::Open {
473 v: 1,
474 generation: None,
475 ..
476 } if snapshot_generation.is_none() => Some(1),
477 JournalRecord::Open {
478 v,
479 generation: Some(generation),
480 ..
481 } if *v == JOURNAL_SCHEMA_VERSION
482 && !generation.is_empty()
483 && Some(generation.as_str()) == snapshot_generation =>
484 {
485 Some(*v)
486 }
487 _ => None,
488 }
489}
490
491fn history_hash(messages: &[SharedMessage]) -> std::io::Result<String> {
495 use sha2::{Digest, Sha256};
496 struct HashWriter(Sha256);
497 impl Write for HashWriter {
498 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
499 self.0.update(bytes);
500 Ok(bytes.len())
501 }
502 fn flush(&mut self) -> std::io::Result<()> {
503 Ok(())
504 }
505 }
506 let mut writer = HashWriter(Sha256::new());
507 serde_json::to_writer(&mut writer, messages).map_err(std::io::Error::other)?;
508 Ok(format!("{:x}", writer.0.finalize()))
509}
510
511struct JournalState {
514 durable_len: usize,
515 history_hash: String,
516 bytes: u64,
517}
518
519fn read_journal_state(
520 handle: &SessionsDirHandle,
521 id: &str,
522 generation: Option<&str>,
523) -> std::io::Result<Option<JournalState>> {
524 let Some(raw) = read_artifact_bytes(handle, &format!("{id}.journal"))? else {
525 return Ok(None);
526 };
527 if !raw.ends_with(b"\n") {
528 return Ok(None); }
530 let Ok(text) = std::str::from_utf8(&raw) else {
531 return Ok(None);
532 };
533 let mut lines = text.lines();
534 let Some(open) = lines
535 .next()
536 .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
537 else {
538 return Ok(None);
539 };
540 if matching_open(&open, generation) != Some(JOURNAL_SCHEMA_VERSION) {
541 return Ok(None);
542 }
543 let JournalRecord::Open {
544 base,
545 history_hash: Some(mut hash),
546 ..
547 } = open
548 else {
549 return Ok(None);
550 };
551 let mut durable_len = base;
552 let mut hashed_len = base;
553 for line in lines {
554 match serde_json::from_str::<JournalRecord>(line) {
555 Ok(JournalRecord::Msg { v, i, .. })
556 if v == JOURNAL_SCHEMA_VERSION && i == durable_len =>
557 {
558 durable_len += 1;
559 }
560 Ok(JournalRecord::Meta {
561 v,
562 meta,
563 history_hash: Some(next_hash),
564 }) if v == JOURNAL_SCHEMA_VERSION
565 && meta.id == id
566 && meta.message_count == Some(durable_len) =>
567 {
568 hash = next_hash;
569 hashed_len = durable_len;
570 }
571 _ => return Ok(None),
572 }
573 }
574 if hashed_len != durable_len {
575 return Ok(None); }
577 Ok(Some(JournalState {
578 durable_len,
579 history_hash: hash,
580 bytes: raw.len() as u64,
581 }))
582}
583
584pub fn save_session_in_dir(
591 dir: &Path,
592 session: &Session,
593 mode: SessionPersistence,
594) -> std::io::Result<SaveReceipt> {
595 let handle = create_sessions_dir(dir)?;
596 match mode {
597 SessionPersistence::Json => {
598 let journal = open_artifact(&handle, &format!("{}.journal", session.id))?;
601 let bound =
602 open_artifact(&handle, &format!("{}.json", session.id))?.is_some_and(|file| {
603 snapshot_binding(file).map_or(true, |b| b.generation.is_some())
606 });
607 publish_snapshot(&handle, session, mode, false, journal.is_some() || bound)
608 }
609 SessionPersistence::Journal => save_journal_mode(&handle, session),
610 }
611}
612
613pub fn save_session_durable_in_dir(
619 dir: &Path,
620 session: &Session,
621 mode: SessionPersistence,
622) -> std::io::Result<SaveReceipt> {
623 #[cfg(unix)]
624 {
625 let handle = SessionsDirHandle::create_absolute_no_symlinks_durable(dir)?;
626 publish_snapshot(&handle, session, mode, true, true)
627 }
628 #[cfg(not(unix))]
629 {
630 let _ = (dir, session, mode);
631 Err(std::io::Error::new(
632 std::io::ErrorKind::Unsupported,
633 "durable session checkpoints require Unix directory fsync",
634 ))
635 }
636}
637
638fn save_journal_mode(
639 handle: &SessionsDirHandle,
640 session: &Session,
641) -> std::io::Result<SaveReceipt> {
642 let journal_name = format!("{}.journal", session.id);
643
644 let snapshot = open_artifact(handle, &format!("{}.json", session.id))?;
645 let Some(snapshot) = snapshot else {
646 return full_snapshot_reset(handle, session);
647 };
648 let snapshot_bytes = snapshot.metadata()?.len();
649 let Ok(binding) = snapshot_binding(snapshot) else {
650 return full_snapshot_reset(handle, session);
651 };
652 let Some(state) = read_journal_state(handle, &session.id, binding.generation.as_deref())?
653 else {
654 return full_snapshot_reset(handle, session);
655 };
656
657 if session.api_messages.len() < state.durable_len
658 || history_hash(&session.api_messages[..state.durable_len])? != state.history_hash
659 {
660 return full_snapshot_reset(handle, session);
661 }
662
663 let mut buf = Vec::new();
665 for (offset, msg) in session.api_messages[state.durable_len..].iter().enumerate() {
666 let record = JournalRecord::Msg {
667 v: JOURNAL_SCHEMA_VERSION,
668 i: state.durable_len + offset,
669 m: msg.as_ref().clone(),
670 };
671 serde_json::to_writer(&mut buf, &record).map_err(std::io::Error::other)?;
672 buf.push(b'\n');
673 }
674 let meta = JournalRecord::Meta {
675 v: JOURNAL_SCHEMA_VERSION,
676 meta: Box::new(SessionMeta::of(session)),
677 history_hash: Some(history_hash(&session.api_messages)?),
678 };
679 serde_json::to_writer(&mut buf, &meta).map_err(std::io::Error::other)?;
680 buf.push(b'\n');
681
682 let appended = session.api_messages.len() - state.durable_len;
683 let mut file = handle.append_file(&journal_name)?;
684 file.write_all(&buf)?;
685 file.sync_data()?;
686 drop(file);
687
688 let journal_bytes = state.bytes + buf.len() as u64;
691 if snapshot_due(journal_bytes, snapshot_bytes) {
692 let reset = full_snapshot_reset(handle, session)?;
693 return Ok(SaveReceipt {
694 mode: SaveMode::FullSnapshot,
695 bytes_written: buf.len() as u64 + reset.bytes_written,
696 });
697 }
698
699 Ok(SaveReceipt {
700 mode: SaveMode::Append { messages: appended },
701 bytes_written: buf.len() as u64,
702 })
703}
704
705#[derive(Serialize)]
712struct SessionSnapshotRef<'a> {
713 #[serde(skip_serializing_if = "Option::is_none")]
716 _journal_generation: Option<&'a str>,
717 id: &'a str,
718 title: &'a str,
719 #[serde(skip_serializing_if = "Option::is_none")]
720 name: &'a Option<String>,
721 model: &'a str,
722 thinking_level: &'a str,
723 system_prompt: &'a Option<String>,
724 created_at: &'a DateTime<Utc>,
725 updated_at: &'a DateTime<Utc>,
726 total_input_tokens: u64,
727 total_output_tokens: u64,
728 session_cost: f64,
729 #[serde(skip_serializing_if = "is_zero_cost")]
730 keepwarm_cost: f64,
731 #[serde(skip_serializing_if = "KeepwarmTokens::is_zero")]
732 keepwarm_tokens: KeepwarmTokens,
733 message_count: usize,
734 #[serde(skip_serializing_if = "Option::is_none")]
735 parent_session: &'a Option<String>,
736 #[serde(skip_serializing_if = "Option::is_none")]
737 compacted_into: &'a Option<String>,
738 #[serde(skip_serializing_if = "Option::is_none")]
741 env: Option<Vec<(String, String)>>,
742 #[serde(skip_serializing_if = "<[String]>::is_empty")]
743 env_stripped: &'a [String],
744 api_messages: &'a [SharedMessage],
745 #[serde(skip_serializing_if = "Option::is_none")]
746 abort_context: &'a Option<String>,
747 #[serde(skip_serializing_if = "Option::is_none")]
748 prompt_provenance: &'a Option<crate::prompt::PromptProvenance>,
749 #[serde(skip_serializing_if = "Option::is_none")]
750 compaction: &'a Option<crate::core::compaction::CompactionRecord>,
751}
752
753impl<'a> SessionSnapshotRef<'a> {
754 fn of(s: &'a Session, generation: Option<&'a str>) -> Self {
755 Self {
756 _journal_generation: generation,
757 id: &s.id,
758 title: &s.title,
759 name: &s.name,
760 model: &s.model,
761 thinking_level: &s.thinking_level,
762 system_prompt: &s.system_prompt,
763 created_at: &s.created_at,
764 updated_at: &s.updated_at,
765 total_input_tokens: s.total_input_tokens,
766 total_output_tokens: s.total_output_tokens,
767 session_cost: s.session_cost,
768 keepwarm_cost: s.keepwarm_cost,
769 keepwarm_tokens: s.keepwarm_tokens,
770 message_count: s.api_messages.len(),
771 parent_session: &s.parent_session,
772 compacted_into: &s.compacted_into,
773 env: scrub_secret_env(s.env.as_ref()),
774 env_stripped: &s.env_stripped,
775 api_messages: &s.api_messages,
776 abort_context: &s.abort_context,
777 prompt_provenance: &s.prompt_provenance,
778 compaction: &s.compaction,
779 }
780 }
781}
782
783fn scrub_secret_env(env: Option<&Vec<(String, String)>>) -> Option<Vec<(String, String)>> {
789 use crate::core::config::is_secret_env;
790 env.map(|pairs| {
791 pairs
792 .iter()
793 .filter(|(k, v)| !is_secret_env(k, v))
794 .cloned()
795 .collect()
796 })
797}
798
799fn snapshot_json(session: &Session, generation: Option<&str>) -> std::io::Result<String> {
801 serde_json::to_string(&SessionSnapshotRef::of(session, generation))
802 .map_err(std::io::Error::other)
803}
804
805fn full_snapshot_reset(
806 handle: &SessionsDirHandle,
807 session: &Session,
808) -> std::io::Result<SaveReceipt> {
809 publish_snapshot(handle, session, SessionPersistence::Journal, false, true)
810}
811
812fn publish_snapshot(
813 handle: &SessionsDirHandle,
814 session: &Session,
815 mode: SessionPersistence,
816 durable: bool,
817 bind: bool,
818) -> std::io::Result<SaveReceipt> {
819 let generation = bind.then(|| uuid::Uuid::new_v4().to_string());
820 let json = snapshot_json(session, generation.as_deref())?;
821 let mut journal = Vec::new();
823 if mode == SessionPersistence::Journal {
824 let open = JournalRecord::Open {
825 v: JOURNAL_SCHEMA_VERSION,
826 base: session.api_messages.len(),
827 generation,
828 history_hash: Some(history_hash(&session.api_messages)?),
829 };
830 serde_json::to_writer(&mut journal, &open).map_err(std::io::Error::other)?;
831 journal.push(b'\n');
832 }
833 open_artifact(handle, &format!("{}.journal", session.id))?;
836 handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
837 #[cfg(unix)]
841 if durable || bind {
842 sync_sessions_dir(handle)?;
843 }
844 if mode == SessionPersistence::Journal {
845 handle.write_atomic(&format!("{}.journal", session.id), &journal)?;
846 } else {
847 remove_artifact_if_exists(handle, &format!("{}.journal", session.id))?;
848 }
849 if durable {
850 sync_sessions_dir(handle)?;
851 }
852 Ok(SaveReceipt {
853 mode: SaveMode::FullSnapshot,
854 bytes_written: json.len() as u64 + journal.len() as u64,
855 })
856}
857
858fn sync_sessions_dir(handle: &SessionsDirHandle) -> std::io::Result<()> {
859 #[cfg(unix)]
860 {
861 handle.sync_all()
862 }
863 #[cfg(not(unix))]
864 {
865 let _ = handle;
866 Err(std::io::Error::new(
867 std::io::ErrorKind::Unsupported,
868 "durable session checkpoints require Unix directory fsync",
869 ))
870 }
871}
872
873pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
880 let handle = open_sessions_dir(dir)?.ok_or_else(|| {
883 std::io::Error::new(
884 std::io::ErrorKind::NotFound,
885 format!("no sessions directory for '{id}'"),
886 )
887 })?;
888 let snapshot = read_artifact_bytes(&handle, &format!("{id}.json"))?.ok_or_else(|| {
889 std::io::Error::new(
890 std::io::ErrorKind::NotFound,
891 format!("no session snapshot for '{id}'"),
892 )
893 })?;
894 let binding: SnapshotBinding =
895 serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
896 let mut session: Session = serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
897 session.clamp_keepwarm_to_totals();
898
899 let Some(raw) = read_artifact_bytes(&handle, &format!("{id}.journal"))? else {
900 return Ok(session);
901 };
902 let text = String::from_utf8_lossy(&raw);
903 let mut lines = text.lines();
904 let version = match lines
905 .next()
906 .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
907 .and_then(|open| matching_open(&open, binding.generation.as_deref()))
908 {
909 Some(version) => version,
910 None => return Ok(session),
911 };
912 for line in lines {
913 match serde_json::from_str::<JournalRecord>(line) {
914 Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
916 if v != version =>
917 {
918 break;
919 }
920 Ok(JournalRecord::Msg { i, m, .. }) => {
921 if i == session.api_messages.len() {
922 session.api_messages.push(std::sync::Arc::new(m));
923 } else if i > session.api_messages.len() {
924 break; }
926 }
928 Ok(JournalRecord::Meta { meta, .. }) => {
929 if meta.id == session.id && meta.updated_at >= session.updated_at {
930 meta.apply(&mut session);
931 }
932 }
933 Ok(JournalRecord::Open { .. }) | Err(_) => break, }
935 }
936 session.clamp_keepwarm_to_totals();
939 Ok(session)
940}
941
942pub fn journal_meta_tail(dir: &Path, id: &str) -> Option<JournalMetaTail> {
947 let handle = open_sessions_dir(dir).ok()??;
948 let binding = snapshot_binding(open_artifact(&handle, &format!("{id}.json")).ok()??).ok()?;
949 journal_meta_tail_from_handle(&handle, id, binding.generation.as_deref())
950}
951
952pub(crate) fn journal_meta_tail_for_generation(
955 dir: &Path,
956 id: &str,
957 generation: Option<&str>,
958) -> Option<JournalMetaTail> {
959 let handle = open_sessions_dir(dir).ok()??;
960 journal_meta_tail_from_handle(&handle, id, generation)
961}
962
963fn journal_meta_tail_from_handle(
964 handle: &SessionsDirHandle,
965 id: &str,
966 generation: Option<&str>,
967) -> Option<JournalMetaTail> {
968 use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
969 let mut file = open_artifact(handle, &format!("{id}.journal")).ok()??;
970 let mut first = String::new();
971 BufReader::new((&mut file).take(4096))
972 .read_line(&mut first)
973 .ok()?;
974 let open: JournalRecord = serde_json::from_str(&first).ok()?;
975 let version = matching_open(&open, generation)?;
976 let len = file.metadata().ok()?.len();
977 let start = len.saturating_sub(META_TAIL_WINDOW);
978 file.seek(SeekFrom::Start(start)).ok()?;
979 let mut tail = String::new();
980 file.take(META_TAIL_WINDOW).read_to_string(&mut tail).ok()?;
981
982 let mut freshest = None;
983 for line in tail.lines() {
984 if let Ok(JournalRecord::Meta { v, meta, .. }) = serde_json::from_str::<JournalRecord>(line)
985 {
986 if v == version && meta.id == id {
987 freshest = Some(JournalMetaTail {
988 updated_at: meta.updated_at,
989 session_cost: meta.session_cost,
990 message_count: meta.message_count,
991 });
992 }
993 }
994 }
995 freshest
996}
997
998pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
1004 let Some(handle) = open_sessions_dir(dir)? else {
1005 return Ok(()); };
1007 remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
1008 remove_artifact_if_exists(&handle, &format!("{id}.journal"))?;
1009 remove_artifact_if_exists(&handle, &format!("{id}.turn"))
1010}
1011
1012fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
1013 match handle.remove_file(name) {
1014 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1015 other => other,
1016 }
1017}
1018
1019#[cfg(test)]
1023pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
1024 let handle = create_sessions_dir(dir)?;
1025 handle.write_atomic(&format!("{id}.json"), json)
1026}
1027
1028#[cfg(test)]
1031mod tests {
1032 use super::*;
1033
1034 #[test]
1039 fn snapshot_json_matches_session_schema() {
1040 let mut s = Session::new("model-x", "medium", Some("prompt"));
1041 s.name = Some("named".into());
1042 s.abort_context = Some("ctx".into());
1043 s.parent_session = Some("parent".into());
1044 s.compacted_into = Some("child".into());
1045 s.api_messages.push(std::sync::Arc::new(
1046 serde_json::json!({"role": "user", "content": "hi"}),
1047 ));
1048 s.message_count = 0; let mut expected = s.clone();
1051 expected.message_count = expected.api_messages.len();
1052 let expected = serde_json::to_string(&expected).unwrap();
1053 assert_eq!(snapshot_json(&s, None).unwrap(), expected);
1054 let back: Session = serde_json::from_str(&snapshot_json(&s, None).unwrap()).unwrap();
1055 assert_eq!(back.message_count, 1);
1056 let s = Session::new("model-x", "medium", None);
1058 let mut expected = s.clone();
1059 expected.message_count = 0;
1060 assert_eq!(
1061 snapshot_json(&s, None).unwrap(),
1062 serde_json::to_string(&expected).unwrap()
1063 );
1064 }
1065
1066 #[test]
1067 fn session_meta_stays_in_sync_with_session_schema() {
1068 let mut s = Session::new("model-x", "medium", Some("prompt"));
1069 s.name = Some("named".into());
1070 s.abort_context = Some("ctx".into());
1071 s.parent_session = Some("parent".into());
1072 s.compacted_into = Some("child".into());
1073 s.keepwarm_cost = 0.25;
1074 s.keepwarm_tokens.cache_read = 9;
1075 let mut value = serde_json::to_value(&s).unwrap();
1076 value.as_object_mut().unwrap().remove("api_messages");
1077 for f in SNAPSHOT_ONLY_FIELDS {
1078 value.as_object_mut().unwrap().remove(*f);
1079 }
1080 let meta: SessionMeta = serde_json::from_value(value)
1081 .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
1082 assert_eq!(meta.id, s.id);
1083 assert_eq!(meta.name.as_deref(), Some("named"));
1084 }
1085
1086 #[test]
1087 fn meta_roundtrip_applies_every_field() {
1088 let mut a = Session::new("model-a", "high", Some("sys"));
1089 a.title = "t".into();
1090 a.total_input_tokens = 7;
1091 a.session_cost = 0.5;
1092 a.updated_at = chrono::Utc::now();
1093 let mut b = Session::new("model-b", "low", None);
1094 b.id = a.id.clone();
1095 SessionMeta::of(&a).apply(&mut b);
1096 assert_eq!(b.model, "model-a");
1097 assert_eq!(b.title, "t");
1098 assert_eq!(b.total_input_tokens, 7);
1099 assert_eq!(b.session_cost, 0.5);
1100 assert_eq!(b.updated_at, a.updated_at);
1101 }
1102
1103 #[test]
1104 fn env_roundtrip_and_secret_scrub() {
1105 let tmp = tempfile::TempDir::new().unwrap();
1106 let dir = tmp.path().join("sessions");
1107 let mut s = Session::new("m", "medium", None);
1108 s.env = Some(vec![
1110 ("PATH".into(), "/usr/bin".into()),
1111 ("GH_TOKEN".into(), "ghp_supersecret42".into()),
1112 ]);
1113 s.env_stripped = vec!["GH_TOKEN".into()];
1114 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1115 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1117 let env = loaded.env.as_ref().expect("env must round-trip");
1119 assert!(
1120 env.iter().any(|(k, v)| k == "PATH" && v == "/usr/bin"),
1121 "non-secret key must survive"
1122 );
1123 let snap_bytes =
1125 std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
1126 assert!(
1127 !snap_bytes.contains("ghp_supersecret42"),
1128 "secret VALUE must never appear on disk"
1129 );
1130 assert!(
1133 snap_bytes.contains("\"env\"") && snap_bytes.contains("/usr/bin"),
1134 "snapshot must carry the scrubbed env: {snap_bytes}"
1135 );
1136 assert_eq!(loaded.env_stripped, vec!["GH_TOKEN".to_string()]);
1138 assert!(
1140 !env.iter().any(|(k, _)| k == "GH_TOKEN"),
1141 "secret key must be scrubbed from env pairs"
1142 );
1143 }
1144
1145 #[test]
1146 fn credential_url_values_never_reach_disk_whatever_the_key() {
1147 let tmp = tempfile::TempDir::new().unwrap();
1148 let dir = tmp.path().join("sessions");
1149 let mut s = Session::new("m", "medium", None);
1150 s.env = Some(vec![
1151 ("PATH".into(), "/usr/bin".into()),
1152 (
1153 "DATABASE_URL".into(),
1154 "postgres://app:hunter2@db/prod".into(),
1155 ),
1156 (
1157 "SENTRY_DSN".into(),
1158 "https://abc123dsnkey@o1.ingest.sentry.io/42".into(),
1159 ),
1160 ]);
1161 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1162 for entry in std::fs::read_dir(&dir).unwrap() {
1164 let path = entry.unwrap().path();
1165 let bytes = std::fs::read(&path).unwrap();
1166 let text = String::from_utf8_lossy(&bytes);
1167 assert!(
1168 !text.contains("hunter2"),
1169 "credential URL leaked to {}",
1170 path.display()
1171 );
1172 assert!(
1173 !text.contains("abc123dsnkey"),
1174 "DSN key leaked to {}",
1175 path.display()
1176 );
1177 }
1178 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1179 let env = loaded.env.as_ref().expect("env must round-trip");
1180 assert!(
1181 env.iter().any(|(k, _)| k == "PATH"),
1182 "non-secret key must survive"
1183 );
1184 assert!(!env
1185 .iter()
1186 .any(|(k, _)| k == "DATABASE_URL" || k == "SENTRY_DSN"));
1187 }
1188
1189 fn with_messages(n: usize) -> Session {
1190 let mut s = Session::new("checkpoint-test", "medium", None);
1191 for i in 0..n {
1192 push_message(&mut s, &format!("old {i}"));
1193 }
1194 s
1195 }
1196
1197 fn push_message(s: &mut Session, text: &str) {
1198 s.api_messages.push(std::sync::Arc::new(
1199 serde_json::json!({"role": "user", "content": text}),
1200 ));
1201 }
1202
1203 fn assert_saved(dir: &Path, expected: &Session) {
1204 let loaded = load_session_in_dir(dir, &expected.id).unwrap();
1205 let mut expected = expected.clone();
1206 expected.message_count = expected.api_messages.len();
1207 let mut loaded = loaded;
1209 loaded.message_count = loaded.api_messages.len();
1210 assert_eq!(
1211 serde_json::to_value(loaded).unwrap(),
1212 serde_json::to_value(expected).unwrap()
1213 );
1214 }
1215
1216 fn disk_generation(dir: &Path, id: &str) -> String {
1217 let snapshot: serde_json::Value =
1218 serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
1219 .unwrap();
1220 snapshot["_journal_generation"]
1221 .as_str()
1222 .unwrap()
1223 .to_string()
1224 }
1225
1226 #[test]
1227 fn bound_snapshot_is_session_shaped_and_storage_metadata_is_not_mirrored() {
1228 let s = with_messages(2);
1229 let json = snapshot_json(&s, Some("test-generation")).unwrap();
1230 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1231 assert_eq!(
1232 value
1233 .as_object_mut()
1234 .unwrap()
1235 .remove("_journal_generation")
1236 .unwrap(),
1237 "test-generation"
1238 );
1239 let mut expected = s.clone();
1240 expected.message_count = 2;
1241 assert_eq!(value, serde_json::to_value(&expected).unwrap());
1242 let back: Session = serde_json::from_str(&json).unwrap();
1243 assert_eq!(serde_json::to_value(back).unwrap(), value);
1244 value.as_object_mut().unwrap().remove("api_messages");
1245 serde_json::from_value::<SessionMeta>(value).unwrap();
1246 }
1247
1248 fn replacement_crash_case(mode: SessionPersistence, durable: bool, shorten: bool) {
1252 let tmp = tempfile::TempDir::new().unwrap();
1253 let dir = tmp.path().canonicalize().unwrap();
1254 let mut s = with_messages(2);
1255 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1256 for _ in 0..4 {
1257 push_message(&mut s, "obsolete journal message");
1258 }
1259 s.title = "obsolete metadata".into();
1260 s.updated_at += chrono::Duration::hours(1);
1261 s.session_cost = 99.0;
1262 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1263 let old = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1264 let old_generation = disk_generation(&dir, &s.id);
1265
1266 s.api_messages[0] =
1268 std::sync::Arc::new(serde_json::json!({"role":"user","content":"new head"}));
1269 if shorten {
1270 s.api_messages.truncate(3);
1271 }
1272 s.title = "candidate".into();
1273 s.updated_at -= chrono::Duration::hours(2); s.session_cost = 1.0;
1275 let receipt = if durable {
1276 save_session_durable_in_dir(&dir, &s, mode).unwrap()
1277 } else {
1278 save_session_in_dir(&dir, &s, mode).unwrap()
1279 };
1280 assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1281 assert_ne!(disk_generation(&dir, &s.id), old_generation);
1282 std::fs::write(journal_path(&dir, &s.id), old).unwrap();
1283 assert_saved(&dir, &s);
1284 assert!(journal_meta_tail(&dir, &s.id).is_none());
1285
1286 push_message(&mut s, "post-checkpoint");
1288 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1289 assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1290 assert_saved(&dir, &s);
1291 push_message(&mut s, "new generation delta");
1292 assert_eq!(
1293 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1294 .unwrap()
1295 .mode,
1296 SaveMode::Append { messages: 1 }
1297 );
1298 assert_saved(&dir, &s);
1299 }
1300
1301 #[test]
1302 fn normal_replacements_ignore_stale_journal_after_snapshot_publication() {
1303 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1304 for shorten in [false, true] {
1305 replacement_crash_case(mode, false, shorten);
1306 }
1307 }
1308 }
1309
1310 #[cfg(unix)]
1311 #[test]
1312 fn durable_replacements_ignore_stale_journal_after_snapshot_publication() {
1313 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1314 for shorten in [false, true] {
1315 replacement_crash_case(mode, true, shorten);
1316 }
1317 }
1318 }
1319
1320 #[test]
1321 fn legacy_v1_reads_then_migrates_and_cannot_replay_over_replacement() {
1322 let tmp = tempfile::TempDir::new().unwrap();
1323 let dir = tmp.path().canonicalize().unwrap();
1324 let mut s = with_messages(1);
1325 std::fs::write(
1326 dir.join(format!("{}.json", s.id)),
1327 snapshot_json(&s, None).unwrap(),
1328 )
1329 .unwrap();
1330 let mut legacy = vec![JournalRecord::Open {
1331 v: 1,
1332 base: 1,
1333 generation: None,
1334 history_hash: None,
1335 }];
1336 for i in 1..5 {
1337 push_message(&mut s, "legacy delta");
1338 legacy.push(JournalRecord::Msg {
1339 v: 1,
1340 i,
1341 m: s.api_messages[i].as_ref().clone(),
1342 });
1343 }
1344 s.title = "legacy title".into();
1345 s.updated_at += chrono::Duration::hours(1);
1346 legacy.push(JournalRecord::Meta {
1347 v: 1,
1348 meta: Box::new(SessionMeta::of(&s)),
1349 history_hash: None,
1350 });
1351 let mut bytes = Vec::new();
1352 for record in legacy {
1353 serde_json::to_writer(&mut bytes, &record).unwrap();
1354 bytes.push(b'\n');
1355 }
1356 std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1357 assert_saved(&dir, &s);
1358 assert_eq!(
1359 journal_meta_tail(&dir, &s.id).unwrap().message_count,
1360 Some(5)
1361 );
1362 let migrated = load_session_in_dir(&dir, &s.id).unwrap();
1363 assert_eq!(
1364 save_session_in_dir(&dir, &migrated, SessionPersistence::Journal)
1365 .unwrap()
1366 .mode,
1367 SaveMode::FullSnapshot
1368 );
1369 assert_saved(&dir, &s);
1370 assert!(!disk_generation(&dir, &s.id).is_empty());
1371
1372 s.api_messages.truncate(2);
1373 s.title = "new window".into();
1374 s.updated_at -= chrono::Duration::hours(2);
1375 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1376 std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1377 assert_saved(&dir, &s);
1378 assert!(journal_meta_tail(&dir, &s.id).is_none());
1379 }
1380
1381 #[test]
1382 fn same_length_edits_in_snapshot_or_earlier_journal_message_resnapshot() {
1383 for appended in [false, true] {
1384 let tmp = tempfile::TempDir::new().unwrap();
1385 let dir = tmp.path().canonicalize().unwrap();
1386 let mut s = with_messages(3);
1387 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1388 let edited = if appended {
1389 push_message(&mut s, "editable");
1390 push_message(&mut s, "unchanged last message");
1391 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1392 3
1393 } else {
1394 0
1395 };
1396 let old_generation = disk_generation(&dir, &s.id);
1397 std::sync::Arc::make_mut(&mut s.api_messages[edited])["content"] = "replaced".into();
1398 assert_eq!(
1399 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1400 .unwrap()
1401 .mode,
1402 SaveMode::FullSnapshot
1403 );
1404 assert_ne!(disk_generation(&dir, &s.id), old_generation);
1405 assert_saved(&dir, &s);
1406 }
1407 }
1408
1409 #[test]
1410 fn v2_never_replays_without_binding_or_across_record_versions() {
1411 let tmp = tempfile::TempDir::new().unwrap();
1412 let dir = tmp.path().canonicalize().unwrap();
1413 let mut s = with_messages(1);
1414 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1415 let first = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1416 push_message(&mut s, "v2 delta");
1417 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1418 let snapshot: Session =
1419 serde_json::from_slice(&std::fs::read(dir.join(format!("{}.json", s.id))).unwrap())
1420 .unwrap();
1421 std::fs::write(
1423 dir.join(format!("{}.json", s.id)),
1424 serde_json::to_vec(&snapshot).unwrap(),
1425 )
1426 .unwrap();
1427 assert_saved(&dir, &snapshot); save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1429 let bound_snapshot = s.clone();
1430 let mut journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1431 journal.extend_from_slice(
1432 b"{\"v\":1,\"k\":\"msg\",\"i\":2,\"m\":{\"content\":\"wrong version\"}}\n",
1433 );
1434 std::fs::write(journal_path(&dir, &s.id), &journal).unwrap();
1435 assert_saved(&dir, &bound_snapshot);
1436 assert_eq!(
1437 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1438 .unwrap()
1439 .mode,
1440 SaveMode::FullSnapshot
1441 );
1442 std::fs::write(journal_path(&dir, &s.id), first).unwrap();
1444 assert_saved(&dir, &s);
1445 }
1446
1447 #[test]
1448 fn json_saves_keep_binding_even_after_journal_deletion() {
1449 let tmp = tempfile::TempDir::new().unwrap();
1450 let dir = tmp.path().canonicalize().unwrap();
1451 let mut s = with_messages(2);
1452 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1453 push_message(&mut s, "old delta");
1454 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1455 let journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1456 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1457 let generation = disk_generation(&dir, &s.id);
1458 s.api_messages.truncate(2);
1459 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1460 assert_ne!(generation, disk_generation(&dir, &s.id));
1461 std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
1462 assert_saved(&dir, &s);
1463 }
1464
1465 #[cfg(unix)]
1466 #[test]
1467 fn durable_error_after_rename_keeps_candidate_and_retry_is_safe() {
1468 let tmp = tempfile::TempDir::new().unwrap();
1469 let dir = tmp.path().canonicalize().unwrap();
1470 let mut s = with_messages(2);
1471 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1472 push_message(&mut s, "old delta");
1473 push_message(&mut s, "more old history");
1474 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1475 let old_journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1476 let old_generation = disk_generation(&dir, &s.id);
1477 s.api_messages.truncate(2);
1478 s.title = "durable candidate".into();
1479 let blocker = dir.join(format!("{}.journal.tmp", s.id));
1481 std::fs::create_dir(&blocker).unwrap();
1482 let error = save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap_err();
1483 assert_ne!(error.kind(), std::io::ErrorKind::NotFound);
1484 assert_ne!(old_generation, disk_generation(&dir, &s.id));
1485 assert_eq!(
1486 std::fs::read(journal_path(&dir, &s.id)).unwrap(),
1487 old_journal
1488 );
1489 assert_saved(&dir, &s); std::fs::remove_dir(blocker).unwrap();
1491 save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1492 assert_saved(&dir, &s);
1493 }
1494
1495 #[cfg(unix)]
1498 mod private_modes {
1499 use super::*;
1500 use crate::core::private_fs::test_support::UmaskGuard;
1501 use serial_test::serial;
1502 use std::os::unix::fs::PermissionsExt;
1503
1504 fn mode_of(path: &Path) -> u32 {
1505 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1506 }
1507
1508 #[test]
1509 #[serial(umask)]
1510 fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
1511 let _umask = UmaskGuard::set(0);
1512 let tmp = tempfile::TempDir::new().unwrap();
1513 let dir = tmp.path().join("sessions");
1514 let mut s = Session::new("m", "medium", None);
1515 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1516 s.api_messages.push(std::sync::Arc::new(
1517 serde_json::json!({"role":"user","content":"x"}),
1518 ));
1519 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1520
1521 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1522 assert_eq!(
1523 mode_of(&dir.join(format!("{}.json", s.id))),
1524 0o600,
1525 "snapshot must be 0600"
1526 );
1527 assert_eq!(
1528 mode_of(&journal_path(&dir, &s.id)),
1529 0o600,
1530 "journal must be 0600"
1531 );
1532 }
1533
1534 #[test]
1535 #[serial(umask)]
1536 fn durable_files_are_private_in_both_modes_and_repair_leaf_modes() {
1537 let _umask = UmaskGuard::set(0);
1538 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1539 let tmp = tempfile::TempDir::new().unwrap();
1540 let dir = tmp
1541 .path()
1542 .canonicalize()
1543 .unwrap()
1544 .join("new/profile/sessions");
1545 let s = with_messages(2);
1546 save_session_durable_in_dir(&dir, &s, mode).unwrap();
1547 assert_eq!(mode_of(dir.parent().unwrap()), 0o700);
1548 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
1549 let snapshot = dir.join(format!("{}.json", s.id));
1550 std::fs::set_permissions(&snapshot, std::fs::Permissions::from_mode(0o666))
1551 .unwrap();
1552 save_session_durable_in_dir(&dir, &s, mode).unwrap();
1553 assert_eq!(mode_of(&dir), 0o700);
1554 assert_eq!(mode_of(&snapshot), 0o600);
1555 if mode == SessionPersistence::Journal {
1556 assert_eq!(mode_of(&journal_path(&dir, &s.id)), 0o600);
1557 }
1558 assert_saved(&dir, &s);
1559 }
1560 }
1561
1562 #[test]
1563 fn durable_saves_refuse_artifact_and_ancestor_symlinks_in_both_modes() {
1564 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1565 for extension in ["json", "journal"] {
1566 let tmp = tempfile::TempDir::new().unwrap();
1567 let root = tmp.path().canonicalize().unwrap();
1568 let dir = root.join("sessions");
1569 std::fs::create_dir(&dir).unwrap();
1570 let victim = root.join("victim");
1571 std::fs::write(&victim, "original").unwrap();
1572 let s = with_messages(1);
1573 std::os::unix::fs::symlink(
1574 &victim,
1575 dir.join(format!("{}.{}", s.id, extension)),
1576 )
1577 .unwrap();
1578 assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1579 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1580 }
1581 let tmp = tempfile::TempDir::new().unwrap();
1582 let root = tmp.path().canonicalize().unwrap();
1583 let victim_dir = root.join("victim-dir");
1584 std::fs::create_dir(&victim_dir).unwrap();
1585 let link = root.join("link");
1586 std::os::unix::fs::symlink(&victim_dir, &link).unwrap();
1587 let s = with_messages(1);
1588 for dir in [link.clone(), link.join("profile/sessions")] {
1589 assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1590 }
1591 assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
1592 }
1593 }
1594
1595 #[test]
1596 fn journal_append_refuses_symlink_target() {
1597 let tmp = tempfile::TempDir::new().unwrap();
1598 let dir = tmp.path().join("sessions");
1599 std::fs::create_dir_all(&dir).unwrap();
1600 let mut s = Session::new("m", "medium", None);
1601 let victim = tmp.path().join("victim");
1602 std::fs::write(&victim, "original").unwrap();
1603 std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
1604 s.api_messages.push(std::sync::Arc::new(
1606 serde_json::json!({"role":"user","content":"x"}),
1607 ));
1608 let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
1609 assert!(res.is_err(), "journal write onto a symlink must fail");
1610 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1611 }
1612 }
1613}
1614
1615#[cfg(test)]
1621mod save_cost_bench {
1622 use super::*;
1623 use std::sync::Arc;
1624 use std::time::{Duration, Instant};
1625
1626 fn session(bytes: usize) -> Session {
1627 let mut s = Session::new("claude-sonnet-4-5", "low", None);
1628 let chunk = "x".repeat(4000);
1631 let n = bytes / 4096;
1632 s.api_messages = (0..n)
1633 .map(|i| {
1634 let role = if i % 2 == 0 { "user" } else { "assistant" };
1635 Arc::new(serde_json::json!({"role": role, "content": format!("{i} {chunk}")}))
1636 })
1637 .collect();
1638 s
1639 }
1640
1641 fn pct(sorted: &[Duration], p: f64) -> Duration {
1642 sorted[((sorted.len() - 1) as f64 * p).round() as usize]
1643 }
1644
1645 #[test]
1646 #[ignore = "benchmark: needs SYNAPS_SAVE_BENCH_DIR on a real disk"]
1647 fn save_cost_bench() {
1648 let Ok(root) = std::env::var("SYNAPS_SAVE_BENCH_DIR") else {
1649 eprintln!("SYNAPS_SAVE_BENCH_DIR not set; skipping");
1650 return;
1651 };
1652 let dir = tempfile::tempdir_in(root).unwrap();
1653 for (label, bytes) in [("200 KB", 200_000), ("2.3 MB", 2_300_000), ("10 MB", 10_000_000)] {
1654 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1655 let mut s = session(bytes);
1656 save_session_in_dir(dir.path(), &s, mode).unwrap(); let mut samples = Vec::new();
1658 for round in 0..40 {
1659 for role in ["assistant", "user"] {
1661 s.api_messages.push(Arc::new(
1662 serde_json::json!({"role": role, "content": format!("round {round}")}),
1663 ));
1664 }
1665 let t = Instant::now();
1666 save_session_in_dir(dir.path(), &s, mode).unwrap();
1667 samples.push(t.elapsed());
1668 }
1669 samples.sort();
1670 eprintln!(
1671 "{label:>7} {mode:?}: p50 {:?} p95 {:?} max {:?}",
1672 pct(&samples, 0.5),
1673 pct(&samples, 0.95),
1674 samples[samples.len() - 1]
1675 );
1676 let _ = delete_session_files_in_dir(dir.path(), &s.id);
1677 }
1678 }
1679 }
1680}
1681
1682#[cfg(test)]
1685mod snapshot_only_fields {
1686 use super::*;
1687
1688 #[test]
1691 fn keepwarm_cost_survives_save_and_load() {
1692 let tmp = tempfile::TempDir::new().unwrap();
1693 let dir = tmp.path().join("sessions");
1694 for persistence in [SessionPersistence::Json, SessionPersistence::Journal] {
1695 let mut s = Session::new("m", "medium", None);
1696 s.api_messages.push(std::sync::Arc::new(
1697 serde_json::json!({"role": "user", "content": "q"}),
1698 ));
1699 s.session_cost = 1.5;
1700 s.total_input_tokens = 100;
1701 s.total_output_tokens = 10;
1702 s.keepwarm_cost = 0.25;
1703 s.keepwarm_tokens = KeepwarmTokens {
1704 input: 4,
1705 output: 2,
1706 cache_read: 900,
1707 cache_creation: 0,
1708 };
1709 save_session_in_dir(&dir, &s, persistence).unwrap();
1710 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1711 assert_eq!(loaded.keepwarm_cost, 0.25, "{persistence:?}");
1712 assert_eq!(loaded.keepwarm_tokens, s.keepwarm_tokens, "{persistence:?}");
1713 assert_eq!(loaded.session_cost, 1.5, "{persistence:?}");
1714 }
1715 }
1716
1717 #[derive(Deserialize)]
1722 #[serde(deny_unknown_fields)]
1723 #[allow(dead_code)]
1724 struct SessionMetaV0_10_1 {
1725 id: String,
1726 title: String,
1727 #[serde(default)]
1728 name: Option<String>,
1729 model: String,
1730 thinking_level: String,
1731 system_prompt: Option<String>,
1732 created_at: DateTime<Utc>,
1733 updated_at: DateTime<Utc>,
1734 total_input_tokens: u64,
1735 total_output_tokens: u64,
1736 session_cost: f64,
1737 #[serde(default)]
1738 message_count: Option<usize>,
1739 #[serde(default)]
1740 abort_context: Option<String>,
1741 #[serde(default)]
1742 parent_session: Option<String>,
1743 #[serde(default)]
1744 compacted_into: Option<String>,
1745 #[serde(default)]
1746 env: Option<Vec<(String, String)>>,
1747 #[serde(default)]
1748 env_stripped: Vec<String>,
1749 #[serde(default)]
1750 prompt_provenance: Option<serde_json::Value>,
1751 #[serde(default)]
1752 compaction: Option<serde_json::Value>,
1753 }
1754
1755 #[derive(Deserialize)]
1759 #[serde(tag = "k")]
1760 #[allow(dead_code)]
1761 enum JournalRecordV0_10_1 {
1762 #[serde(rename = "open")]
1763 Open {
1764 v: u8,
1765 base: usize,
1766 #[serde(default)]
1767 generation: Option<String>,
1768 #[serde(default)]
1769 history_hash: Option<String>,
1770 },
1771 #[serde(rename = "msg")]
1772 Msg {
1773 v: u8,
1774 i: usize,
1775 m: serde_json::Value,
1776 },
1777 #[serde(rename = "meta")]
1778 Meta {
1779 v: u8,
1780 meta: Box<SessionMetaV0_10_1>,
1781 #[serde(default)]
1782 history_hash: Option<String>,
1783 },
1784 }
1785
1786 #[test]
1787 fn journal_written_by_this_build_parses_with_the_0_10_1_reader() {
1788 let tmp = tempfile::TempDir::new().unwrap();
1789 let dir = tmp.path().join("sessions");
1790 let mut s = Session::new("m", "medium", Some("sys"));
1791 s.name = Some("n".into());
1792 s.parent_session = Some("p".into());
1793 s.env = Some(vec![("PATH".into(), "/bin".into())]);
1794 s.api_messages.push(std::sync::Arc::new(
1795 serde_json::json!({"role": "user", "content": "q"}),
1796 ));
1797 s.session_cost = 1.0;
1798 s.total_input_tokens = 50;
1799 s.keepwarm_cost = 0.5;
1800 s.keepwarm_tokens.input = 5;
1801 s.keepwarm_tokens.cache_read = 1_000;
1802 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1805 for turn in 0..2 {
1806 s.api_messages.push(std::sync::Arc::new(
1807 serde_json::json!({"role": "assistant", "content": format!("a{turn}")}),
1808 ));
1809 s.session_cost += 0.25;
1810 s.keepwarm_cost += 0.125;
1811 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1812 assert!(matches!(receipt.mode, SaveMode::Append { .. }));
1813 }
1814 let raw = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
1815 let mut metas = 0;
1816 for line in raw.lines() {
1817 let record: JournalRecordV0_10_1 = serde_json::from_str(line)
1818 .unwrap_or_else(|e| panic!("0.10.1 cannot parse {line}: {e}"));
1819 if matches!(record, JournalRecordV0_10_1::Meta { .. }) {
1820 metas += 1;
1821 }
1822 assert!(
1823 !line.contains("keepwarm"),
1824 "journaled a snapshot-only field: {line}"
1825 );
1826 }
1827 assert_eq!(metas, 2);
1828 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1831 assert_eq!(loaded.api_messages.len(), 3);
1832 assert_eq!(loaded.session_cost, 1.5);
1833 assert_eq!(loaded.keepwarm_cost, 0.5);
1834 }
1835
1836 #[test]
1837 fn keepwarm_labels_clamp_under_the_totals_on_load() {
1838 let tmp = tempfile::TempDir::new().unwrap();
1839 let dir = tmp.path().join("sessions");
1840 let mut s = Session::new("m", "medium", None);
1841 s.api_messages.push(std::sync::Arc::new(
1842 serde_json::json!({"role": "user", "content": "q"}),
1843 ));
1844 s.session_cost = 0.25;
1845 s.total_input_tokens = 3;
1846 s.total_output_tokens = 1;
1847 s.keepwarm_cost = 0.75; s.keepwarm_tokens.input = 10;
1849 s.keepwarm_tokens.output = 10;
1850 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1851 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1852 assert_eq!(loaded.keepwarm_cost, 0.25);
1853 assert_eq!(loaded.keepwarm_tokens.input, 3);
1854 assert_eq!(loaded.keepwarm_tokens.output, 1);
1855 }
1856}