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 MESSAGE_TURN_KEY: &str = "_synaps_turn";
43
44pub const MESSAGE_AT_KEY: &str = "_synaps_at";
49
50pub fn message_time(message: &serde_json::Value) -> Option<&str> {
52 message.get(MESSAGE_AT_KEY)?.as_str()
53}
54
55fn valid_time(ts: &str) -> bool {
57 DateTime::parse_from_rfc3339(ts).is_ok()
58}
59
60pub const JOURNAL_SCHEMA_VERSION: u8 = 2;
62
63pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
65
66pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
69
70const META_TAIL_WINDOW: u64 = 64 * 1024;
72
73pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; #[cfg(unix)]
96type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
97
98#[cfg(unix)]
101fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
102 match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
103 Ok(handle) => Ok(Some(handle)),
104 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
105 Err(e) => Err(e),
106 }
107}
108
109#[cfg(unix)]
111fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
112 crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)
113}
114
115#[cfg(unix)]
118fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
119 match handle.open_file(&[name.to_string()]) {
120 Ok(file) => Ok(Some(file)),
121 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
122 Err(e) => Err(e),
123 }
124}
125
126#[cfg(unix)]
128pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
129 let Some(handle) = open_sessions_dir(dir)? else {
130 return Ok(None);
131 };
132 open_artifact(&handle, name)
133}
134
135#[cfg(not(unix))]
138struct SessionsDirHandle {
139 dir: std::path::PathBuf,
140}
141
142#[cfg(not(unix))]
143fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
144 if !dir.exists() {
145 return Ok(None);
146 }
147 Ok(Some(SessionsDirHandle {
148 dir: dir.to_path_buf(),
149 }))
150}
151
152#[cfg(not(unix))]
153fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
154 crate::core::private_fs::ensure_private_dir(dir)?;
155 Ok(SessionsDirHandle {
156 dir: dir.to_path_buf(),
157 })
158}
159
160#[cfg(not(unix))]
161impl SessionsDirHandle {
162 fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
163 crate::core::private_fs::write_atomic_private(&self.dir.join(name), data)
164 .map_err(std::io::Error::other)
165 }
166 fn append_file(&self, name: &str) -> std::io::Result<std::fs::File> {
167 crate::core::private_fs::open_private_append(&self.dir.join(name))
168 .map_err(std::io::Error::other)
169 }
170 fn remove_file(&self, name: &str) -> std::io::Result<()> {
171 std::fs::remove_file(self.dir.join(name))
172 }
173}
174
175#[cfg(not(unix))]
176fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
177 let path = handle.dir.join(name);
178 match std::fs::symlink_metadata(&path) {
179 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
180 Ok(meta) if meta.file_type().is_symlink() => {
181 return Err(std::io::Error::other(format!(
182 "refusing symlinked session artifact {name:?}"
183 )))
184 }
185 _ => {}
186 }
187 match std::fs::File::open(&path) {
188 Ok(f) => Ok(Some(f)),
189 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
190 Err(e) => Err(e),
191 }
192}
193
194#[cfg(not(unix))]
195pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
196 let Some(handle) = open_sessions_dir(dir)? else {
197 return Ok(None);
198 };
199 open_artifact(&handle, name)
200}
201
202#[cfg(not(windows))]
205pub(crate) fn confined_open_for_times(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
206 confined_open(dir, name)
207}
208
209#[cfg(windows)]
214pub(crate) fn confined_open_for_times(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
215 use std::os::windows::fs::OpenOptionsExt;
216 if confined_open(dir, name)?.is_none() {
217 return Ok(None);
218 }
219 match std::fs::OpenOptions::new()
220 .access_mode(windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES)
221 .open(dir.join(name))
222 {
223 Ok(f) => Ok(Some(f)),
224 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
225 Err(e) => Err(e),
226 }
227}
228
229fn read_artifact_bytes(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<Vec<u8>>> {
231 use std::io::Read;
232 let Some(file) = open_artifact(handle, name)? else {
233 return Ok(None);
234 };
235 let mut buf = Vec::new();
236 file.take(MAX_PERSISTED_READ_BYTES + 1)
237 .read_to_end(&mut buf)?;
238 if buf.len() as u64 > MAX_PERSISTED_READ_BYTES {
239 return Err(std::io::Error::other(format!(
240 "session artifact {name:?} exceeds the {MAX_PERSISTED_READ_BYTES}-byte read bound"
241 )));
242 }
243 Ok(Some(buf))
244}
245
246#[derive(Debug, Clone)]
248pub struct SessionDirEntry {
249 pub name: String,
250 pub mtime: Option<std::time::SystemTime>,
251 pub byte_len: u64,
254}
255
256pub fn session_dir_entries(dir: &Path) -> std::io::Result<Vec<SessionDirEntry>> {
262 #[cfg(unix)]
263 {
264 let Some(handle) = open_sessions_dir(dir)? else {
265 return Ok(Vec::new());
266 };
267 Ok(handle
268 .entries()?
269 .into_iter()
270 .filter(|e| e.is_file)
271 .map(|e| SessionDirEntry {
272 name: e.name,
273 mtime: e.mtime_unix_ms.map(|ms| {
274 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(ms)
275 }),
276 byte_len: e.byte_len,
277 })
278 .collect())
279 }
280 #[cfg(not(unix))]
281 {
282 if !dir.exists() {
283 return Ok(Vec::new());
284 }
285 let mut out = Vec::new();
286 for entry in std::fs::read_dir(dir)? {
287 let entry = entry?;
288 if !entry.path().is_file() {
289 continue;
290 }
291 let Ok(name) = entry.file_name().into_string() else {
292 continue;
293 };
294 let byte_len = entry
295 .metadata()
296 .map(|metadata| metadata.len())
297 .unwrap_or_default();
298 out.push(SessionDirEntry {
299 name,
300 mtime: entry.metadata().and_then(|m| m.modified()).ok(),
301 byte_len,
302 });
303 }
304 Ok(out)
305 }
306}
307
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
314pub enum SessionPersistence {
315 #[default]
316 Json,
317 Journal,
318}
319
320impl SessionPersistence {
321 pub fn parse(val: &str) -> Option<Self> {
324 match val.trim() {
325 "json" => Some(Self::Json),
326 "journal" => Some(Self::Journal),
327 _ => None,
328 }
329 }
330}
331
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub enum SaveMode {
335 FullSnapshot,
338 Append { messages: usize },
340}
341
342#[derive(Debug, Clone, Copy)]
344pub struct SaveReceipt {
345 pub mode: SaveMode,
346 pub bytes_written: u64,
347}
348
349#[derive(Debug, Clone)]
352pub struct JournalMetaTail {
353 pub updated_at: DateTime<Utc>,
354 pub session_cost: f64,
355 pub message_count: Option<usize>,
357}
358
359pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
362 dir.join(format!("{id}.journal"))
363}
364
365pub fn snapshot_due(journal_bytes: u64, snapshot_bytes: u64) -> bool {
367 journal_bytes >= JOURNAL_SNAPSHOT_MIN_BYTES.max(snapshot_bytes / JOURNAL_SNAPSHOT_RATIO)
368}
369
370#[derive(Serialize, Deserialize)]
373#[serde(tag = "k")]
374enum JournalRecord {
375 #[serde(rename = "open")]
377 Open {
378 v: u8,
379 base: usize,
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 generation: Option<String>,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 history_hash: Option<String>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 turns_hash: Option<String>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
389 times_hash: Option<String>,
390 },
391 #[serde(rename = "msg")]
395 Msg {
396 v: u8,
397 i: usize,
398 m: serde_json::Value,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
400 t: Option<serde_json::Value>,
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 at: Option<serde_json::Value>,
405 },
406 #[serde(rename = "meta")]
408 Meta {
409 v: u8,
410 meta: Box<SessionMeta>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 history_hash: Option<String>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 turns_hash: Option<String>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 times_hash: Option<String>,
417 },
418}
419
420fn message_turn_id(message: &serde_json::Value) -> Option<&str> {
421 message.get(MESSAGE_TURN_KEY)?.get("id")?.as_str()
422}
423
424const SIBLING_KEYS: [&str; 2] = [MESSAGE_TURN_KEY, MESSAGE_AT_KEY];
426
427fn has_sibling_key(object: &serde_json::Map<String, serde_json::Value>) -> bool {
428 SIBLING_KEYS.iter().any(|k| object.contains_key(*k))
429}
430
431struct Stored<'a>(&'a serde_json::Value);
433
434impl Serialize for Stored<'_> {
435 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
436 use serde::ser::SerializeMap;
437 match self.0.as_object() {
438 Some(object) if has_sibling_key(object) => {
439 let kept = object.iter().filter(|(k, _)| !SIBLING_KEYS.contains(&k.as_str()));
440 let mut map = serializer.serialize_map(Some(kept.clone().count()))?;
441 for (k, v) in kept {
442 map.serialize_entry(k, v)?;
443 }
444 map.end()
445 }
446 _ => self.0.serialize(serializer),
447 }
448 }
449}
450
451struct StoredMessages<'a>(&'a [SharedMessage]);
452
453impl Serialize for StoredMessages<'_> {
454 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
455 serializer.collect_seq(self.0.iter().map(|m| Stored(m)))
456 }
457}
458
459fn stored_value(message: &serde_json::Value) -> serde_json::Value {
460 let mut message = message.clone();
461 if let Some(object) = message.as_object_mut() {
462 for key in SIBLING_KEYS {
463 object.remove(key);
464 }
465 }
466 message
467}
468
469fn turn_ids(messages: &[SharedMessage]) -> Vec<(usize, &str)> {
471 messages
472 .iter()
473 .enumerate()
474 .filter_map(|(i, m)| message_turn_id(m).map(|id| (i, id)))
475 .collect()
476}
477
478fn turns_hash(messages: &[SharedMessage]) -> std::io::Result<Option<String>> {
481 use sha2::{Digest, Sha256};
482 let ids = turn_ids(messages);
483 if ids.is_empty() {
484 return Ok(None);
485 }
486 let bytes = serde_json::to_vec(&ids).map_err(std::io::Error::other)?;
487 Ok(Some(format!("{:x}", Sha256::digest(&bytes))))
488}
489
490fn message_times(messages: &[SharedMessage]) -> Vec<(usize, &str)> {
492 messages
493 .iter()
494 .enumerate()
495 .filter_map(|(i, m)| message_time(m).map(|ts| (i, ts)))
496 .collect()
497}
498
499fn times_hash(messages: &[SharedMessage]) -> std::io::Result<Option<String>> {
503 use sha2::{Digest, Sha256};
504 let times = message_times(messages);
505 if times.is_empty() {
506 return Ok(None);
507 }
508 let bytes = serde_json::to_vec(×).map_err(std::io::Error::other)?;
509 Ok(Some(format!("{:x}", Sha256::digest(&bytes))))
510}
511
512fn set_message_time(message: &mut serde_json::Value, ts: String) {
513 if let Some(object) = message.as_object_mut() {
514 object.insert(MESSAGE_AT_KEY.into(), serde_json::Value::String(ts));
515 }
516}
517
518fn set_turn_id(message: &mut serde_json::Value, id: String) {
519 if let Some(object) = message.as_object_mut() {
520 object.insert(MESSAGE_TURN_KEY.into(), serde_json::json!({ "id": id }));
521 }
522}
523
524fn is_zero_cost(v: &f64) -> bool {
525 *v == 0.0
526}
527
528fn is_zero_tokens(v: &u64) -> bool {
529 *v == 0
530}
531
532#[cfg_attr(not(test), allow(dead_code))]
539const SNAPSHOT_ONLY_FIELDS: &[&str] = &[
540 "keepwarm_cost",
541 "keepwarm_tokens",
542 "total_cache_read_tokens",
543 "total_cache_creation_tokens",
544];
545
546#[derive(Clone, Serialize, Deserialize)]
552#[serde(deny_unknown_fields)]
553struct SessionMeta {
554 id: String,
555 title: String,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 name: Option<String>,
558 model: String,
559 thinking_level: String,
560 system_prompt: Option<String>,
561 created_at: DateTime<Utc>,
562 updated_at: DateTime<Utc>,
563 total_input_tokens: u64,
564 total_output_tokens: u64,
565 session_cost: f64,
566 #[serde(default, skip_serializing_if = "Option::is_none")]
567 message_count: Option<usize>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
569 abort_context: Option<String>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
571 parent_session: Option<String>,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
573 compacted_into: Option<String>,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
575 env: Option<Vec<(String, String)>>,
576 #[serde(default, skip_serializing_if = "Vec::is_empty")]
577 env_stripped: Vec<String>,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
579 prompt_provenance: Option<crate::prompt::PromptProvenance>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 compaction: Option<crate::core::compaction::CompactionRecord>,
582}
583
584impl SessionMeta {
585 fn of(s: &Session) -> Self {
586 let env = scrub_secret_env(s.env.as_ref());
590 Self {
591 id: s.id.clone(),
592 title: s.title.clone(),
593 name: s.name.clone(),
594 model: s.model.clone(),
595 thinking_level: s.thinking_level.clone(),
596 system_prompt: s.system_prompt.clone(),
597 created_at: s.created_at,
598 updated_at: s.updated_at,
599 total_input_tokens: s.total_input_tokens,
600 total_output_tokens: s.total_output_tokens,
601 session_cost: s.session_cost,
602 message_count: Some(s.api_messages.len()),
603 abort_context: s.abort_context.clone(),
604 parent_session: s.parent_session.clone(),
605 compacted_into: s.compacted_into.clone(),
606 env,
607 env_stripped: s.env_stripped.clone(),
608 prompt_provenance: s.prompt_provenance.clone(),
609 compaction: s.compaction.clone(),
610 }
611 }
612
613 fn apply(self, s: &mut Session) {
615 s.title = self.title;
616 s.name = self.name;
617 s.model = self.model;
618 s.thinking_level = self.thinking_level;
619 s.system_prompt = self.system_prompt;
620 s.created_at = self.created_at;
621 s.updated_at = self.updated_at;
622 s.total_input_tokens = self.total_input_tokens;
623 s.total_output_tokens = self.total_output_tokens;
624 s.session_cost = self.session_cost;
625 s.abort_context = self.abort_context;
626 s.parent_session = self.parent_session;
627 s.compacted_into = self.compacted_into;
628 s.env = self.env;
629 s.env_stripped = self.env_stripped;
630 s.prompt_provenance = self.prompt_provenance;
631 s.compaction = self.compaction;
632 }
633}
634
635#[derive(Default, Deserialize)]
638struct SnapshotBinding {
639 #[serde(default, rename = "_journal_generation")]
640 generation: Option<String>,
641 #[serde(default, rename = "_turn_ids")]
644 turn_ids: serde_json::Value,
645 #[serde(default, rename = "_message_times")]
647 message_times: serde_json::Value,
648}
649
650fn stored_message_times(value: serde_json::Value) -> Vec<(usize, String)> {
654 let entries = match value {
655 serde_json::Value::Null => return Vec::new(),
656 serde_json::Value::Array(entries) => entries,
657 _ => {
658 tracing::warn!("session snapshot has a malformed _message_times; times dropped");
659 return Vec::new();
660 }
661 };
662 let total = entries.len();
663 let times: Vec<(usize, String)> = entries
664 .into_iter()
665 .filter_map(|entry| serde_json::from_value::<(usize, String)>(entry).ok())
666 .filter(|(_, ts)| valid_time(ts))
667 .collect();
668 if times.len() != total {
669 tracing::warn!(dropped = total - times.len(), "malformed _message_times entries dropped");
670 }
671 times
672}
673
674fn journal_time(at: Option<serde_json::Value>) -> Option<String> {
676 match at? {
677 serde_json::Value::String(ts) if valid_time(&ts) => Some(ts),
678 _ => {
679 tracing::warn!("malformed journal message time dropped");
680 None
681 }
682 }
683}
684
685fn stored_turn_ids(value: serde_json::Value) -> Vec<(usize, String)> {
688 let entries = match value {
689 serde_json::Value::Null => return Vec::new(),
690 serde_json::Value::Array(entries) => entries,
691 _ => {
692 tracing::warn!("session snapshot has a malformed _turn_ids; turn ids dropped");
693 return Vec::new();
694 }
695 };
696 let total = entries.len();
697 let ids: Vec<(usize, String)> = entries
698 .into_iter()
699 .filter_map(|entry| serde_json::from_value(entry).ok())
700 .collect();
701 if ids.len() != total {
702 tracing::warn!(dropped = total - ids.len(), "malformed _turn_ids entries dropped");
703 }
704 ids
705}
706
707fn snapshot_binding(file: std::fs::File) -> std::io::Result<SnapshotBinding> {
708 let header = crate::core::session::read_session_header_from_file(file)
709 .ok_or_else(|| std::io::Error::other("cannot read session snapshot header"))?;
710 serde_json::from_str(&header).map_err(std::io::Error::other)
711}
712
713fn matching_open(record: &JournalRecord, snapshot_generation: Option<&str>) -> Option<u8> {
716 match record {
717 JournalRecord::Open {
718 v: 1,
719 generation: None,
720 ..
721 } if snapshot_generation.is_none() => Some(1),
722 JournalRecord::Open {
723 v,
724 generation: Some(generation),
725 ..
726 } if *v == JOURNAL_SCHEMA_VERSION
727 && !generation.is_empty()
728 && Some(generation.as_str()) == snapshot_generation =>
729 {
730 Some(*v)
731 }
732 _ => None,
733 }
734}
735
736fn history_hash(messages: &[SharedMessage]) -> std::io::Result<String> {
741 use sha2::{Digest, Sha256};
742 struct HashWriter(Sha256);
743 impl Write for HashWriter {
744 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
745 self.0.update(bytes);
746 Ok(bytes.len())
747 }
748 fn flush(&mut self) -> std::io::Result<()> {
749 Ok(())
750 }
751 }
752 let mut writer = HashWriter(Sha256::new());
753 serde_json::to_writer(&mut writer, &StoredMessages(messages)).map_err(std::io::Error::other)?;
754 Ok(format!("{:x}", writer.0.finalize()))
755}
756
757struct JournalState {
760 durable_len: usize,
761 history_hash: String,
762 turns_hash: Option<String>,
763 times_hash: Option<String>,
764 bytes: u64,
765}
766
767fn read_journal_state(
768 handle: &SessionsDirHandle,
769 id: &str,
770 generation: Option<&str>,
771) -> std::io::Result<Option<JournalState>> {
772 let Some(raw) = read_artifact_bytes(handle, &format!("{id}.journal"))? else {
773 return Ok(None);
774 };
775 if !raw.ends_with(b"\n") {
776 return Ok(None); }
778 let Ok(text) = std::str::from_utf8(&raw) else {
779 return Ok(None);
780 };
781 let mut lines = text.lines();
782 let Some(open) = lines
783 .next()
784 .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
785 else {
786 return Ok(None);
787 };
788 if matching_open(&open, generation) != Some(JOURNAL_SCHEMA_VERSION) {
789 return Ok(None);
790 }
791 let JournalRecord::Open {
792 base,
793 history_hash: Some(mut hash),
794 turns_hash: mut turns,
795 times_hash: mut times,
796 ..
797 } = open
798 else {
799 return Ok(None);
800 };
801 let mut durable_len = base;
802 let mut hashed_len = base;
803 for line in lines {
804 match serde_json::from_str::<JournalRecord>(line) {
805 Ok(JournalRecord::Msg { v, i, .. })
806 if v == JOURNAL_SCHEMA_VERSION && i == durable_len =>
807 {
808 durable_len += 1;
809 }
810 Ok(JournalRecord::Meta {
811 v,
812 meta,
813 history_hash: Some(next_hash),
814 turns_hash: next_turns,
815 times_hash: next_times,
816 }) if v == JOURNAL_SCHEMA_VERSION
817 && meta.id == id
818 && meta.message_count == Some(durable_len) =>
819 {
820 hash = next_hash;
821 turns = next_turns;
822 times = next_times;
823 hashed_len = durable_len;
824 }
825 _ => return Ok(None),
826 }
827 }
828 if hashed_len != durable_len {
829 return Ok(None); }
831 Ok(Some(JournalState {
832 durable_len,
833 history_hash: hash,
834 turns_hash: turns,
835 times_hash: times,
836 bytes: raw.len() as u64,
837 }))
838}
839
840pub fn save_session_in_dir(
847 dir: &Path,
848 session: &Session,
849 mode: SessionPersistence,
850) -> std::io::Result<SaveReceipt> {
851 let handle = create_sessions_dir(dir)?;
852 match mode {
853 SessionPersistence::Json => {
854 let journal = open_artifact(&handle, &format!("{}.journal", session.id))?;
857 let bound =
858 open_artifact(&handle, &format!("{}.json", session.id))?.is_some_and(|file| {
859 snapshot_binding(file).map_or(true, |b| b.generation.is_some())
862 });
863 publish_snapshot(&handle, session, mode, false, journal.is_some() || bound)
864 }
865 SessionPersistence::Journal => save_journal_mode(&handle, session),
866 }
867}
868
869pub fn save_session_durable_in_dir(
875 dir: &Path,
876 session: &Session,
877 mode: SessionPersistence,
878) -> std::io::Result<SaveReceipt> {
879 #[cfg(unix)]
880 {
881 let handle = SessionsDirHandle::create_absolute_no_symlinks_durable(dir)?;
882 publish_snapshot(&handle, session, mode, true, true)
883 }
884 #[cfg(not(unix))]
885 {
886 let handle = create_sessions_dir(dir)?;
889 publish_snapshot(&handle, session, mode, true, true)
890 }
891}
892
893fn save_journal_mode(
894 handle: &SessionsDirHandle,
895 session: &Session,
896) -> std::io::Result<SaveReceipt> {
897 let journal_name = format!("{}.journal", session.id);
898
899 let snapshot = open_artifact(handle, &format!("{}.json", session.id))?;
900 let Some(snapshot) = snapshot else {
901 return full_snapshot_reset(handle, session);
902 };
903 let snapshot_bytes = snapshot.metadata()?.len();
904 let Ok(binding) = snapshot_binding(snapshot) else {
905 return full_snapshot_reset(handle, session);
906 };
907 let Some(state) = read_journal_state(handle, &session.id, binding.generation.as_deref())?
908 else {
909 return full_snapshot_reset(handle, session);
910 };
911
912 if session.api_messages.len() < state.durable_len
913 || history_hash(&session.api_messages[..state.durable_len])? != state.history_hash
914 || turns_hash(&session.api_messages[..state.durable_len])? != state.turns_hash
915 || times_hash(&session.api_messages[..state.durable_len])? != state.times_hash
916 {
917 return full_snapshot_reset(handle, session);
918 }
919
920 let mut buf = Vec::new();
922 for (offset, msg) in session.api_messages[state.durable_len..].iter().enumerate() {
923 let record = JournalRecord::Msg {
924 v: JOURNAL_SCHEMA_VERSION,
925 i: state.durable_len + offset,
926 m: stored_value(msg),
927 t: message_turn_id(msg).map(serde_json::Value::from),
928 at: message_time(msg).map(serde_json::Value::from),
929 };
930 serde_json::to_writer(&mut buf, &record).map_err(std::io::Error::other)?;
931 buf.push(b'\n');
932 }
933 let meta = JournalRecord::Meta {
934 v: JOURNAL_SCHEMA_VERSION,
935 meta: Box::new(SessionMeta::of(session)),
936 history_hash: Some(history_hash(&session.api_messages)?),
937 turns_hash: turns_hash(&session.api_messages)?,
938 times_hash: times_hash(&session.api_messages)?,
939 };
940 serde_json::to_writer(&mut buf, &meta).map_err(std::io::Error::other)?;
941 buf.push(b'\n');
942
943 let appended = session.api_messages.len() - state.durable_len;
944 let mut file = handle.append_file(&journal_name)?;
945 file.write_all(&buf)?;
946 file.sync_data()?;
947 drop(file);
948
949 let journal_bytes = state.bytes + buf.len() as u64;
952 if snapshot_due(journal_bytes, snapshot_bytes) {
953 let reset = full_snapshot_reset(handle, session)?;
954 return Ok(SaveReceipt {
955 mode: SaveMode::FullSnapshot,
956 bytes_written: buf.len() as u64 + reset.bytes_written,
957 });
958 }
959
960 Ok(SaveReceipt {
961 mode: SaveMode::Append { messages: appended },
962 bytes_written: buf.len() as u64,
963 })
964}
965
966#[derive(Serialize)]
973struct SessionSnapshotRef<'a> {
974 #[serde(skip_serializing_if = "Option::is_none")]
977 _journal_generation: Option<&'a str>,
978 id: &'a str,
979 title: &'a str,
980 #[serde(skip_serializing_if = "Option::is_none")]
981 name: &'a Option<String>,
982 model: &'a str,
983 thinking_level: &'a str,
984 system_prompt: &'a Option<String>,
985 created_at: &'a DateTime<Utc>,
986 updated_at: &'a DateTime<Utc>,
987 total_input_tokens: u64,
988 total_output_tokens: u64,
989 session_cost: f64,
990 #[serde(skip_serializing_if = "is_zero_cost")]
991 keepwarm_cost: f64,
992 #[serde(skip_serializing_if = "KeepwarmTokens::is_zero")]
993 keepwarm_tokens: KeepwarmTokens,
994 #[serde(skip_serializing_if = "is_zero_tokens")]
995 total_cache_read_tokens: u64,
996 #[serde(skip_serializing_if = "is_zero_tokens")]
997 total_cache_creation_tokens: u64,
998 message_count: usize,
999 #[serde(skip_serializing_if = "Option::is_none")]
1000 parent_session: &'a Option<String>,
1001 #[serde(skip_serializing_if = "Option::is_none")]
1002 compacted_into: &'a Option<String>,
1003 #[serde(skip_serializing_if = "Option::is_none")]
1006 env: Option<Vec<(String, String)>>,
1007 #[serde(skip_serializing_if = "<[String]>::is_empty")]
1008 env_stripped: &'a [String],
1009 api_messages: StoredMessages<'a>,
1010 #[serde(skip_serializing_if = "Option::is_none")]
1011 abort_context: &'a Option<String>,
1012 #[serde(skip_serializing_if = "Option::is_none")]
1013 prompt_provenance: &'a Option<crate::prompt::PromptProvenance>,
1014 #[serde(skip_serializing_if = "Option::is_none")]
1015 compaction: &'a Option<crate::core::compaction::CompactionRecord>,
1016 #[serde(rename = "_turn_ids", skip_serializing_if = "Vec::is_empty")]
1018 turn_ids: Vec<(usize, &'a str)>,
1019 #[serde(rename = "_message_times", skip_serializing_if = "Vec::is_empty")]
1021 message_times: Vec<(usize, &'a str)>,
1022}
1023
1024impl<'a> SessionSnapshotRef<'a> {
1025 fn of(s: &'a Session, generation: Option<&'a str>) -> Self {
1026 Self {
1027 _journal_generation: generation,
1028 id: &s.id,
1029 title: &s.title,
1030 name: &s.name,
1031 model: &s.model,
1032 thinking_level: &s.thinking_level,
1033 system_prompt: &s.system_prompt,
1034 created_at: &s.created_at,
1035 updated_at: &s.updated_at,
1036 total_input_tokens: s.total_input_tokens,
1037 total_output_tokens: s.total_output_tokens,
1038 session_cost: s.session_cost,
1039 keepwarm_cost: s.keepwarm_cost,
1040 keepwarm_tokens: s.keepwarm_tokens,
1041 total_cache_read_tokens: s.total_cache_read_tokens,
1042 total_cache_creation_tokens: s.total_cache_creation_tokens,
1043 message_count: s.api_messages.len(),
1044 parent_session: &s.parent_session,
1045 compacted_into: &s.compacted_into,
1046 env: scrub_secret_env(s.env.as_ref()),
1047 env_stripped: &s.env_stripped,
1048 api_messages: StoredMessages(&s.api_messages),
1049 abort_context: &s.abort_context,
1050 prompt_provenance: &s.prompt_provenance,
1051 compaction: &s.compaction,
1052 turn_ids: turn_ids(&s.api_messages),
1053 message_times: message_times(&s.api_messages),
1054 }
1055 }
1056}
1057
1058fn scrub_secret_env(env: Option<&Vec<(String, String)>>) -> Option<Vec<(String, String)>> {
1064 use crate::core::config::is_secret_env;
1065 env.map(|pairs| {
1066 pairs
1067 .iter()
1068 .filter(|(k, v)| !is_secret_env(k, v))
1069 .cloned()
1070 .collect()
1071 })
1072}
1073
1074fn snapshot_json(session: &Session, generation: Option<&str>) -> std::io::Result<String> {
1076 serde_json::to_string(&SessionSnapshotRef::of(session, generation))
1077 .map_err(std::io::Error::other)
1078}
1079
1080fn full_snapshot_reset(
1081 handle: &SessionsDirHandle,
1082 session: &Session,
1083) -> std::io::Result<SaveReceipt> {
1084 publish_snapshot(handle, session, SessionPersistence::Journal, false, true)
1085}
1086
1087fn publish_snapshot(
1088 handle: &SessionsDirHandle,
1089 session: &Session,
1090 mode: SessionPersistence,
1091 durable: bool,
1092 bind: bool,
1093) -> std::io::Result<SaveReceipt> {
1094 let generation = bind.then(|| uuid::Uuid::new_v4().to_string());
1095 let json = snapshot_json(session, generation.as_deref())?;
1096 let mut journal = Vec::new();
1098 if mode == SessionPersistence::Journal {
1099 let open = JournalRecord::Open {
1100 v: JOURNAL_SCHEMA_VERSION,
1101 base: session.api_messages.len(),
1102 generation,
1103 history_hash: Some(history_hash(&session.api_messages)?),
1104 turns_hash: turns_hash(&session.api_messages)?,
1105 times_hash: times_hash(&session.api_messages)?,
1106 };
1107 serde_json::to_writer(&mut journal, &open).map_err(std::io::Error::other)?;
1108 journal.push(b'\n');
1109 }
1110 open_artifact(handle, &format!("{}.journal", session.id))?;
1113 handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
1114 #[cfg(unix)]
1118 if durable || bind {
1119 sync_sessions_dir(handle)?;
1120 }
1121 if mode == SessionPersistence::Journal {
1122 handle.write_atomic(&format!("{}.journal", session.id), &journal)?;
1123 } else {
1124 remove_artifact_if_exists(handle, &format!("{}.journal", session.id))?;
1125 }
1126 if durable {
1127 sync_sessions_dir(handle)?;
1128 }
1129 Ok(SaveReceipt {
1130 mode: SaveMode::FullSnapshot,
1131 bytes_written: json.len() as u64 + journal.len() as u64,
1132 })
1133}
1134
1135fn sync_sessions_dir(handle: &SessionsDirHandle) -> std::io::Result<()> {
1136 #[cfg(unix)]
1137 {
1138 handle.sync_all()
1139 }
1140 #[cfg(not(unix))]
1147 {
1148 let _ = handle;
1149 Ok(())
1150 }
1151}
1152
1153pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
1160 let handle = open_sessions_dir(dir)?.ok_or_else(|| {
1163 std::io::Error::new(
1164 std::io::ErrorKind::NotFound,
1165 format!("no sessions directory for '{id}'"),
1166 )
1167 })?;
1168 let snapshot = read_artifact_bytes(&handle, &format!("{id}.json"))?.ok_or_else(|| {
1169 std::io::Error::new(
1170 std::io::ErrorKind::NotFound,
1171 format!("no session snapshot for '{id}'"),
1172 )
1173 })?;
1174 let binding: SnapshotBinding =
1175 serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
1176 let mut session: Session = serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
1177 session.clamp_keepwarm_to_totals();
1178 for (i, id) in stored_turn_ids(binding.turn_ids) {
1179 if let Some(message) = session.api_messages.get_mut(i) {
1180 set_turn_id(std::sync::Arc::make_mut(message), id);
1181 }
1182 }
1183 for (i, ts) in stored_message_times(binding.message_times) {
1184 if let Some(message) = session.api_messages.get_mut(i) {
1185 set_message_time(std::sync::Arc::make_mut(message), ts);
1186 }
1187 }
1188
1189 let Some(raw) = read_artifact_bytes(&handle, &format!("{id}.journal"))? else {
1190 return Ok(session);
1191 };
1192 let text = String::from_utf8_lossy(&raw);
1193 let mut lines = text.lines();
1194 let version = match lines
1195 .next()
1196 .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
1197 .and_then(|open| matching_open(&open, binding.generation.as_deref()))
1198 {
1199 Some(version) => version,
1200 None => return Ok(session),
1201 };
1202 for line in lines {
1203 match serde_json::from_str::<JournalRecord>(line) {
1204 Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
1206 if v != version =>
1207 {
1208 break;
1209 }
1210 Ok(JournalRecord::Msg { i, mut m, t, at, .. }) => {
1211 if i == session.api_messages.len() {
1212 if let Some(serde_json::Value::String(id)) = t {
1213 set_turn_id(&mut m, id);
1214 }
1215 if let Some(ts) = journal_time(at) {
1216 set_message_time(&mut m, ts);
1217 }
1218 session.api_messages.push(std::sync::Arc::new(m));
1219 } else if i > session.api_messages.len() {
1220 break; }
1222 }
1224 Ok(JournalRecord::Meta { meta, .. }) => {
1225 if meta.id == session.id && meta.updated_at >= session.updated_at {
1226 meta.apply(&mut session);
1227 }
1228 }
1229 Ok(JournalRecord::Open { .. }) | Err(_) => break, }
1231 }
1232 session.clamp_keepwarm_to_totals();
1235 Ok(session)
1236}
1237
1238pub fn journal_meta_tail(dir: &Path, id: &str) -> Option<JournalMetaTail> {
1243 let handle = open_sessions_dir(dir).ok()??;
1244 let binding = snapshot_binding(open_artifact(&handle, &format!("{id}.json")).ok()??).ok()?;
1245 journal_meta_tail_from_handle(&handle, id, binding.generation.as_deref())
1246}
1247
1248pub(crate) fn journal_meta_tail_for_generation(
1251 dir: &Path,
1252 id: &str,
1253 generation: Option<&str>,
1254) -> Option<JournalMetaTail> {
1255 let handle = open_sessions_dir(dir).ok()??;
1256 journal_meta_tail_from_handle(&handle, id, generation)
1257}
1258
1259fn journal_meta_tail_from_handle(
1260 handle: &SessionsDirHandle,
1261 id: &str,
1262 generation: Option<&str>,
1263) -> Option<JournalMetaTail> {
1264 use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
1265 let mut file = open_artifact(handle, &format!("{id}.journal")).ok()??;
1266 let mut first = String::new();
1267 BufReader::new((&mut file).take(4096))
1268 .read_line(&mut first)
1269 .ok()?;
1270 let open: JournalRecord = serde_json::from_str(&first).ok()?;
1271 let version = matching_open(&open, generation)?;
1272 let len = file.metadata().ok()?.len();
1273 let start = len.saturating_sub(META_TAIL_WINDOW);
1274 file.seek(SeekFrom::Start(start)).ok()?;
1275 let mut tail = String::new();
1276 file.take(META_TAIL_WINDOW).read_to_string(&mut tail).ok()?;
1277
1278 let mut freshest = None;
1279 for line in tail.lines() {
1280 if let Ok(JournalRecord::Meta { v, meta, .. }) = serde_json::from_str::<JournalRecord>(line)
1281 {
1282 if v == version && meta.id == id {
1283 freshest = Some(JournalMetaTail {
1284 updated_at: meta.updated_at,
1285 session_cost: meta.session_cost,
1286 message_count: meta.message_count,
1287 });
1288 }
1289 }
1290 }
1291 freshest
1292}
1293
1294pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
1300 let Some(handle) = open_sessions_dir(dir)? else {
1301 return Ok(()); };
1303 remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
1304 remove_artifact_if_exists(&handle, &format!("{id}.journal"))?;
1305 remove_artifact_if_exists(&handle, &format!("{id}.turn"))
1306}
1307
1308fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
1309 match handle.remove_file(name) {
1310 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1311 other => other,
1312 }
1313}
1314
1315#[cfg(test)]
1319pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
1320 let handle = create_sessions_dir(dir)?;
1321 handle.write_atomic(&format!("{id}.json"), json)
1322}
1323
1324#[cfg(test)]
1327mod tests {
1328 use super::*;
1329
1330 #[test]
1335 fn snapshot_json_matches_session_schema() {
1336 let mut s = Session::new("model-x", "medium", Some("prompt"));
1337 s.name = Some("named".into());
1338 s.abort_context = Some("ctx".into());
1339 s.parent_session = Some("parent".into());
1340 s.compacted_into = Some("child".into());
1341 s.api_messages.push(std::sync::Arc::new(
1342 serde_json::json!({"role": "user", "content": "hi"}),
1343 ));
1344 s.message_count = 0; let mut expected = s.clone();
1347 expected.message_count = expected.api_messages.len();
1348 let expected = serde_json::to_string(&expected).unwrap();
1349 assert_eq!(snapshot_json(&s, None).unwrap(), expected);
1350 let back: Session = serde_json::from_str(&snapshot_json(&s, None).unwrap()).unwrap();
1351 assert_eq!(back.message_count, 1);
1352 let s = Session::new("model-x", "medium", None);
1354 let mut expected = s.clone();
1355 expected.message_count = 0;
1356 assert_eq!(
1357 snapshot_json(&s, None).unwrap(),
1358 serde_json::to_string(&expected).unwrap()
1359 );
1360 }
1361
1362 #[test]
1363 fn session_meta_stays_in_sync_with_session_schema() {
1364 let mut s = Session::new("model-x", "medium", Some("prompt"));
1365 s.name = Some("named".into());
1366 s.abort_context = Some("ctx".into());
1367 s.parent_session = Some("parent".into());
1368 s.compacted_into = Some("child".into());
1369 s.keepwarm_cost = 0.25;
1370 s.keepwarm_tokens.cache_read = 9;
1371 let mut value = serde_json::to_value(&s).unwrap();
1372 value.as_object_mut().unwrap().remove("api_messages");
1373 for f in SNAPSHOT_ONLY_FIELDS {
1374 value.as_object_mut().unwrap().remove(*f);
1375 }
1376 let meta: SessionMeta = serde_json::from_value(value)
1377 .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
1378 assert_eq!(meta.id, s.id);
1379 assert_eq!(meta.name.as_deref(), Some("named"));
1380 }
1381
1382 #[test]
1383 fn meta_roundtrip_applies_every_field() {
1384 let mut a = Session::new("model-a", "high", Some("sys"));
1385 a.title = "t".into();
1386 a.total_input_tokens = 7;
1387 a.session_cost = 0.5;
1388 a.updated_at = chrono::Utc::now();
1389 let mut b = Session::new("model-b", "low", None);
1390 b.id = a.id.clone();
1391 SessionMeta::of(&a).apply(&mut b);
1392 assert_eq!(b.model, "model-a");
1393 assert_eq!(b.title, "t");
1394 assert_eq!(b.total_input_tokens, 7);
1395 assert_eq!(b.session_cost, 0.5);
1396 assert_eq!(b.updated_at, a.updated_at);
1397 }
1398
1399 #[test]
1400 fn env_roundtrip_and_secret_scrub() {
1401 let tmp = tempfile::TempDir::new().unwrap();
1402 let dir = tmp.path().join("sessions");
1403 let mut s = Session::new("m", "medium", None);
1404 s.env = Some(vec![
1406 ("PATH".into(), "/usr/bin".into()),
1407 ("GH_TOKEN".into(), "ghp_supersecret42".into()),
1408 ]);
1409 s.env_stripped = vec!["GH_TOKEN".into()];
1410 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1411 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1413 let env = loaded.env.as_ref().expect("env must round-trip");
1415 assert!(
1416 env.iter().any(|(k, v)| k == "PATH" && v == "/usr/bin"),
1417 "non-secret key must survive"
1418 );
1419 let snap_bytes =
1421 std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
1422 assert!(
1423 !snap_bytes.contains("ghp_supersecret42"),
1424 "secret VALUE must never appear on disk"
1425 );
1426 assert!(
1429 snap_bytes.contains("\"env\"") && snap_bytes.contains("/usr/bin"),
1430 "snapshot must carry the scrubbed env: {snap_bytes}"
1431 );
1432 assert_eq!(loaded.env_stripped, vec!["GH_TOKEN".to_string()]);
1434 assert!(
1436 !env.iter().any(|(k, _)| k == "GH_TOKEN"),
1437 "secret key must be scrubbed from env pairs"
1438 );
1439 }
1440
1441 #[test]
1442 fn credential_url_values_never_reach_disk_whatever_the_key() {
1443 let tmp = tempfile::TempDir::new().unwrap();
1444 let dir = tmp.path().join("sessions");
1445 let mut s = Session::new("m", "medium", None);
1446 s.env = Some(vec![
1447 ("PATH".into(), "/usr/bin".into()),
1448 (
1449 "DATABASE_URL".into(),
1450 "postgres://app:hunter2@db/prod".into(),
1451 ),
1452 (
1453 "SENTRY_DSN".into(),
1454 "https://abc123dsnkey@o1.ingest.sentry.io/42".into(),
1455 ),
1456 ]);
1457 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1458 for entry in std::fs::read_dir(&dir).unwrap() {
1460 let path = entry.unwrap().path();
1461 let bytes = std::fs::read(&path).unwrap();
1462 let text = String::from_utf8_lossy(&bytes);
1463 assert!(
1464 !text.contains("hunter2"),
1465 "credential URL leaked to {}",
1466 path.display()
1467 );
1468 assert!(
1469 !text.contains("abc123dsnkey"),
1470 "DSN key leaked to {}",
1471 path.display()
1472 );
1473 }
1474 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1475 let env = loaded.env.as_ref().expect("env must round-trip");
1476 assert!(
1477 env.iter().any(|(k, _)| k == "PATH"),
1478 "non-secret key must survive"
1479 );
1480 assert!(!env
1481 .iter()
1482 .any(|(k, _)| k == "DATABASE_URL" || k == "SENTRY_DSN"));
1483 }
1484
1485 pub(super) fn with_messages(n: usize) -> Session {
1486 let mut s = Session::new("checkpoint-test", "medium", None);
1487 for i in 0..n {
1488 push_message(&mut s, &format!("old {i}"));
1489 }
1490 s
1491 }
1492
1493 pub(super) fn push_message(s: &mut Session, text: &str) {
1494 s.api_messages.push(std::sync::Arc::new(
1495 serde_json::json!({"role": "user", "content": text}),
1496 ));
1497 }
1498
1499 fn assert_saved(dir: &Path, expected: &Session) {
1500 let loaded = load_session_in_dir(dir, &expected.id).unwrap();
1501 let mut expected = expected.clone();
1502 expected.message_count = expected.api_messages.len();
1503 let mut loaded = loaded;
1505 loaded.message_count = loaded.api_messages.len();
1506 assert_eq!(
1507 serde_json::to_value(loaded).unwrap(),
1508 serde_json::to_value(expected).unwrap()
1509 );
1510 }
1511
1512 fn disk_generation(dir: &Path, id: &str) -> String {
1513 let snapshot: serde_json::Value =
1514 serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
1515 .unwrap();
1516 snapshot["_journal_generation"]
1517 .as_str()
1518 .unwrap()
1519 .to_string()
1520 }
1521
1522 #[test]
1523 fn bound_snapshot_is_session_shaped_and_storage_metadata_is_not_mirrored() {
1524 let s = with_messages(2);
1525 let json = snapshot_json(&s, Some("test-generation")).unwrap();
1526 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1527 assert_eq!(
1528 value
1529 .as_object_mut()
1530 .unwrap()
1531 .remove("_journal_generation")
1532 .unwrap(),
1533 "test-generation"
1534 );
1535 let mut expected = s.clone();
1536 expected.message_count = 2;
1537 assert_eq!(value, serde_json::to_value(&expected).unwrap());
1538 let back: Session = serde_json::from_str(&json).unwrap();
1539 assert_eq!(serde_json::to_value(back).unwrap(), value);
1540 value.as_object_mut().unwrap().remove("api_messages");
1541 serde_json::from_value::<SessionMeta>(value).unwrap();
1542 }
1543
1544 fn replacement_crash_case(mode: SessionPersistence, durable: bool, shorten: bool) {
1548 let tmp = tempfile::TempDir::new().unwrap();
1549 let dir = tmp.path().canonicalize().unwrap();
1550 let mut s = with_messages(2);
1551 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1552 for _ in 0..4 {
1553 push_message(&mut s, "obsolete journal message");
1554 }
1555 s.title = "obsolete metadata".into();
1556 s.updated_at += chrono::Duration::hours(1);
1557 s.session_cost = 99.0;
1558 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1559 let old = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1560 let old_generation = disk_generation(&dir, &s.id);
1561
1562 s.api_messages[0] =
1564 std::sync::Arc::new(serde_json::json!({"role":"user","content":"new head"}));
1565 if shorten {
1566 s.api_messages.truncate(3);
1567 }
1568 s.title = "candidate".into();
1569 s.updated_at -= chrono::Duration::hours(2); s.session_cost = 1.0;
1571 let receipt = if durable {
1572 save_session_durable_in_dir(&dir, &s, mode).unwrap()
1573 } else {
1574 save_session_in_dir(&dir, &s, mode).unwrap()
1575 };
1576 assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1577 assert_ne!(disk_generation(&dir, &s.id), old_generation);
1578 std::fs::write(journal_path(&dir, &s.id), old).unwrap();
1579 assert_saved(&dir, &s);
1580 assert!(journal_meta_tail(&dir, &s.id).is_none());
1581
1582 push_message(&mut s, "post-checkpoint");
1584 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1585 assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1586 assert_saved(&dir, &s);
1587 push_message(&mut s, "new generation delta");
1588 assert_eq!(
1589 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1590 .unwrap()
1591 .mode,
1592 SaveMode::Append { messages: 1 }
1593 );
1594 assert_saved(&dir, &s);
1595 }
1596
1597 #[test]
1598 fn normal_replacements_ignore_stale_journal_after_snapshot_publication() {
1599 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1600 for shorten in [false, true] {
1601 replacement_crash_case(mode, false, shorten);
1602 }
1603 }
1604 }
1605
1606 #[cfg(unix)]
1607 #[test]
1608 fn durable_replacements_ignore_stale_journal_after_snapshot_publication() {
1609 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1610 for shorten in [false, true] {
1611 replacement_crash_case(mode, true, shorten);
1612 }
1613 }
1614 }
1615
1616 #[test]
1617 fn legacy_v1_reads_then_migrates_and_cannot_replay_over_replacement() {
1618 let tmp = tempfile::TempDir::new().unwrap();
1619 let dir = tmp.path().canonicalize().unwrap();
1620 let mut s = with_messages(1);
1621 std::fs::write(
1622 dir.join(format!("{}.json", s.id)),
1623 snapshot_json(&s, None).unwrap(),
1624 )
1625 .unwrap();
1626 let mut legacy = vec![JournalRecord::Open {
1627 v: 1,
1628 base: 1,
1629 generation: None,
1630 history_hash: None,
1631 turns_hash: None,
1632 times_hash: None,
1633 }];
1634 for i in 1..5 {
1635 push_message(&mut s, "legacy delta");
1636 legacy.push(JournalRecord::Msg {
1637 v: 1,
1638 i,
1639 m: s.api_messages[i].as_ref().clone(),
1640 t: None,
1641 at: None,
1642 });
1643 }
1644 s.title = "legacy title".into();
1645 s.updated_at += chrono::Duration::hours(1);
1646 legacy.push(JournalRecord::Meta {
1647 v: 1,
1648 meta: Box::new(SessionMeta::of(&s)),
1649 history_hash: None,
1650 turns_hash: None,
1651 times_hash: None,
1652 });
1653 let mut bytes = Vec::new();
1654 for record in legacy {
1655 serde_json::to_writer(&mut bytes, &record).unwrap();
1656 bytes.push(b'\n');
1657 }
1658 std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1659 assert_saved(&dir, &s);
1660 assert_eq!(
1661 journal_meta_tail(&dir, &s.id).unwrap().message_count,
1662 Some(5)
1663 );
1664 let migrated = load_session_in_dir(&dir, &s.id).unwrap();
1665 assert_eq!(
1666 save_session_in_dir(&dir, &migrated, SessionPersistence::Journal)
1667 .unwrap()
1668 .mode,
1669 SaveMode::FullSnapshot
1670 );
1671 assert_saved(&dir, &s);
1672 assert!(!disk_generation(&dir, &s.id).is_empty());
1673
1674 s.api_messages.truncate(2);
1675 s.title = "new window".into();
1676 s.updated_at -= chrono::Duration::hours(2);
1677 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1678 std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1679 assert_saved(&dir, &s);
1680 assert!(journal_meta_tail(&dir, &s.id).is_none());
1681 }
1682
1683 #[test]
1684 fn same_length_edits_in_snapshot_or_earlier_journal_message_resnapshot() {
1685 for appended in [false, true] {
1686 let tmp = tempfile::TempDir::new().unwrap();
1687 let dir = tmp.path().canonicalize().unwrap();
1688 let mut s = with_messages(3);
1689 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1690 let edited = if appended {
1691 push_message(&mut s, "editable");
1692 push_message(&mut s, "unchanged last message");
1693 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1694 3
1695 } else {
1696 0
1697 };
1698 let old_generation = disk_generation(&dir, &s.id);
1699 std::sync::Arc::make_mut(&mut s.api_messages[edited])["content"] = "replaced".into();
1700 assert_eq!(
1701 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1702 .unwrap()
1703 .mode,
1704 SaveMode::FullSnapshot
1705 );
1706 assert_ne!(disk_generation(&dir, &s.id), old_generation);
1707 assert_saved(&dir, &s);
1708 }
1709 }
1710
1711 #[test]
1712 fn v2_never_replays_without_binding_or_across_record_versions() {
1713 let tmp = tempfile::TempDir::new().unwrap();
1714 let dir = tmp.path().canonicalize().unwrap();
1715 let mut s = with_messages(1);
1716 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1717 let first = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1718 push_message(&mut s, "v2 delta");
1719 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1720 let snapshot: Session =
1721 serde_json::from_slice(&std::fs::read(dir.join(format!("{}.json", s.id))).unwrap())
1722 .unwrap();
1723 std::fs::write(
1725 dir.join(format!("{}.json", s.id)),
1726 serde_json::to_vec(&snapshot).unwrap(),
1727 )
1728 .unwrap();
1729 assert_saved(&dir, &snapshot); save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1731 let bound_snapshot = s.clone();
1732 let mut journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1733 journal.extend_from_slice(
1734 b"{\"v\":1,\"k\":\"msg\",\"i\":2,\"m\":{\"content\":\"wrong version\"}}\n",
1735 );
1736 std::fs::write(journal_path(&dir, &s.id), &journal).unwrap();
1737 assert_saved(&dir, &bound_snapshot);
1738 assert_eq!(
1739 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1740 .unwrap()
1741 .mode,
1742 SaveMode::FullSnapshot
1743 );
1744 std::fs::write(journal_path(&dir, &s.id), first).unwrap();
1746 assert_saved(&dir, &s);
1747 }
1748
1749 #[test]
1750 fn json_saves_keep_binding_even_after_journal_deletion() {
1751 let tmp = tempfile::TempDir::new().unwrap();
1752 let dir = tmp.path().canonicalize().unwrap();
1753 let mut s = with_messages(2);
1754 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1755 push_message(&mut s, "old delta");
1756 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1757 let journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1758 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1759 let generation = disk_generation(&dir, &s.id);
1760 s.api_messages.truncate(2);
1761 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1762 assert_ne!(generation, disk_generation(&dir, &s.id));
1763 std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
1764 assert_saved(&dir, &s);
1765 }
1766
1767 #[cfg(unix)]
1768 #[test]
1769 fn durable_error_after_rename_keeps_candidate_and_retry_is_safe() {
1770 let tmp = tempfile::TempDir::new().unwrap();
1771 let dir = tmp.path().canonicalize().unwrap();
1772 let mut s = with_messages(2);
1773 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1774 push_message(&mut s, "old delta");
1775 push_message(&mut s, "more old history");
1776 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1777 let old_journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1778 let old_generation = disk_generation(&dir, &s.id);
1779 s.api_messages.truncate(2);
1780 s.title = "durable candidate".into();
1781 let blocker = dir.join(format!("{}.journal.tmp", s.id));
1783 std::fs::create_dir(&blocker).unwrap();
1784 let error = save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap_err();
1785 assert_ne!(error.kind(), std::io::ErrorKind::NotFound);
1786 assert_ne!(old_generation, disk_generation(&dir, &s.id));
1787 assert_eq!(
1788 std::fs::read(journal_path(&dir, &s.id)).unwrap(),
1789 old_journal
1790 );
1791 assert_saved(&dir, &s); std::fs::remove_dir(blocker).unwrap();
1793 save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1794 assert_saved(&dir, &s);
1795 }
1796
1797 #[cfg(unix)]
1800 mod private_modes {
1801 use super::*;
1802 use crate::core::private_fs::test_support::UmaskGuard;
1803 use serial_test::serial;
1804 use std::os::unix::fs::PermissionsExt;
1805
1806 fn mode_of(path: &Path) -> u32 {
1807 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1808 }
1809
1810 #[test]
1811 #[serial(umask)]
1812 fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
1813 let _umask = UmaskGuard::set(0);
1814 let tmp = tempfile::TempDir::new().unwrap();
1815 let dir = tmp.path().join("sessions");
1816 let mut s = Session::new("m", "medium", None);
1817 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1818 s.api_messages.push(std::sync::Arc::new(
1819 serde_json::json!({"role":"user","content":"x"}),
1820 ));
1821 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1822
1823 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1824 assert_eq!(
1825 mode_of(&dir.join(format!("{}.json", s.id))),
1826 0o600,
1827 "snapshot must be 0600"
1828 );
1829 assert_eq!(
1830 mode_of(&journal_path(&dir, &s.id)),
1831 0o600,
1832 "journal must be 0600"
1833 );
1834 }
1835
1836 #[test]
1837 #[serial(umask)]
1838 fn durable_files_are_private_in_both_modes_and_repair_leaf_modes() {
1839 let _umask = UmaskGuard::set(0);
1840 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1841 let tmp = tempfile::TempDir::new().unwrap();
1842 let dir = tmp
1843 .path()
1844 .canonicalize()
1845 .unwrap()
1846 .join("new/profile/sessions");
1847 let s = with_messages(2);
1848 save_session_durable_in_dir(&dir, &s, mode).unwrap();
1849 assert_eq!(mode_of(dir.parent().unwrap()), 0o700);
1850 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
1851 let snapshot = dir.join(format!("{}.json", s.id));
1852 std::fs::set_permissions(&snapshot, std::fs::Permissions::from_mode(0o666))
1853 .unwrap();
1854 save_session_durable_in_dir(&dir, &s, mode).unwrap();
1855 assert_eq!(mode_of(&dir), 0o700);
1856 assert_eq!(mode_of(&snapshot), 0o600);
1857 if mode == SessionPersistence::Journal {
1858 assert_eq!(mode_of(&journal_path(&dir, &s.id)), 0o600);
1859 }
1860 assert_saved(&dir, &s);
1861 }
1862 }
1863
1864 #[test]
1865 fn durable_saves_refuse_artifact_and_ancestor_symlinks_in_both_modes() {
1866 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1867 for extension in ["json", "journal"] {
1868 let tmp = tempfile::TempDir::new().unwrap();
1869 let root = tmp.path().canonicalize().unwrap();
1870 let dir = root.join("sessions");
1871 std::fs::create_dir(&dir).unwrap();
1872 let victim = root.join("victim");
1873 std::fs::write(&victim, "original").unwrap();
1874 let s = with_messages(1);
1875 std::os::unix::fs::symlink(
1876 &victim,
1877 dir.join(format!("{}.{}", s.id, extension)),
1878 )
1879 .unwrap();
1880 assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1881 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1882 }
1883 let tmp = tempfile::TempDir::new().unwrap();
1884 let root = tmp.path().canonicalize().unwrap();
1885 let victim_dir = root.join("victim-dir");
1886 std::fs::create_dir(&victim_dir).unwrap();
1887 let link = root.join("link");
1888 std::os::unix::fs::symlink(&victim_dir, &link).unwrap();
1889 let s = with_messages(1);
1890 for dir in [link.clone(), link.join("profile/sessions")] {
1891 assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1892 }
1893 assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
1894 }
1895 }
1896
1897 #[test]
1898 fn journal_append_refuses_symlink_target() {
1899 let tmp = tempfile::TempDir::new().unwrap();
1900 let dir = tmp.path().join("sessions");
1901 std::fs::create_dir_all(&dir).unwrap();
1902 let mut s = Session::new("m", "medium", None);
1903 let victim = tmp.path().join("victim");
1904 std::fs::write(&victim, "original").unwrap();
1905 std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
1906 s.api_messages.push(std::sync::Arc::new(
1908 serde_json::json!({"role":"user","content":"x"}),
1909 ));
1910 let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
1911 assert!(res.is_err(), "journal write onto a symlink must fail");
1912 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1913 }
1914 }
1915}
1916
1917#[cfg(test)]
1923mod save_cost_bench {
1924 use super::*;
1925 use std::sync::Arc;
1926 use std::time::{Duration, Instant};
1927
1928 fn session(bytes: usize) -> Session {
1929 let mut s = Session::new("claude-sonnet-4-5", "low", None);
1930 let chunk = "x".repeat(4000);
1933 let n = bytes / 4096;
1934 s.api_messages = (0..n)
1935 .map(|i| {
1936 let role = if i % 2 == 0 { "user" } else { "assistant" };
1937 Arc::new(serde_json::json!({"role": role, "content": format!("{i} {chunk}")}))
1938 })
1939 .collect();
1940 s
1941 }
1942
1943 fn pct(sorted: &[Duration], p: f64) -> Duration {
1944 sorted[((sorted.len() - 1) as f64 * p).round() as usize]
1945 }
1946
1947 #[test]
1948 #[ignore = "benchmark: needs SYNAPS_SAVE_BENCH_DIR on a real disk"]
1949 fn save_cost_bench() {
1950 let Ok(root) = std::env::var("SYNAPS_SAVE_BENCH_DIR") else {
1951 eprintln!("SYNAPS_SAVE_BENCH_DIR not set; skipping");
1952 return;
1953 };
1954 let dir = tempfile::tempdir_in(root).unwrap();
1955 for (label, bytes) in [("200 KB", 200_000), ("2.3 MB", 2_300_000), ("10 MB", 10_000_000)] {
1956 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1957 let mut s = session(bytes);
1958 save_session_in_dir(dir.path(), &s, mode).unwrap(); let mut samples = Vec::new();
1960 for round in 0..40 {
1961 for role in ["assistant", "user"] {
1963 s.api_messages.push(Arc::new(
1964 serde_json::json!({"role": role, "content": format!("round {round}")}),
1965 ));
1966 }
1967 let t = Instant::now();
1968 save_session_in_dir(dir.path(), &s, mode).unwrap();
1969 samples.push(t.elapsed());
1970 }
1971 samples.sort();
1972 eprintln!(
1973 "{label:>7} {mode:?}: p50 {:?} p95 {:?} max {:?}",
1974 pct(&samples, 0.5),
1975 pct(&samples, 0.95),
1976 samples[samples.len() - 1]
1977 );
1978 let _ = delete_session_files_in_dir(dir.path(), &s.id);
1979 }
1980 }
1981 }
1982}
1983
1984#[cfg(test)]
1987mod snapshot_only_fields {
1988 use super::tests::{push_message, with_messages};
1989 use super::*;
1990
1991 #[test]
1994 fn keepwarm_cost_survives_save_and_load() {
1995 let tmp = tempfile::TempDir::new().unwrap();
1996 let dir = tmp.path().join("sessions");
1997 for persistence in [SessionPersistence::Json, SessionPersistence::Journal] {
1998 let mut s = Session::new("m", "medium", None);
1999 s.api_messages.push(std::sync::Arc::new(
2000 serde_json::json!({"role": "user", "content": "q"}),
2001 ));
2002 s.session_cost = 1.5;
2003 s.total_input_tokens = 100;
2004 s.total_output_tokens = 10;
2005 s.keepwarm_cost = 0.25;
2006 s.keepwarm_tokens = KeepwarmTokens {
2007 input: 4,
2008 output: 2,
2009 cache_read: 900,
2010 cache_creation: 0,
2011 };
2012 save_session_in_dir(&dir, &s, persistence).unwrap();
2013 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2014 assert_eq!(loaded.keepwarm_cost, 0.25, "{persistence:?}");
2015 assert_eq!(loaded.keepwarm_tokens, s.keepwarm_tokens, "{persistence:?}");
2016 assert_eq!(loaded.session_cost, 1.5, "{persistence:?}");
2017 }
2018 }
2019
2020 #[derive(Deserialize)]
2025 #[serde(deny_unknown_fields)]
2026 #[allow(dead_code)]
2027 struct SessionMetaV0_10_1 {
2028 id: String,
2029 title: String,
2030 #[serde(default)]
2031 name: Option<String>,
2032 model: String,
2033 thinking_level: String,
2034 system_prompt: Option<String>,
2035 created_at: DateTime<Utc>,
2036 updated_at: DateTime<Utc>,
2037 total_input_tokens: u64,
2038 total_output_tokens: u64,
2039 session_cost: f64,
2040 #[serde(default)]
2041 message_count: Option<usize>,
2042 #[serde(default)]
2043 abort_context: Option<String>,
2044 #[serde(default)]
2045 parent_session: Option<String>,
2046 #[serde(default)]
2047 compacted_into: Option<String>,
2048 #[serde(default)]
2049 env: Option<Vec<(String, String)>>,
2050 #[serde(default)]
2051 env_stripped: Vec<String>,
2052 #[serde(default)]
2053 prompt_provenance: Option<serde_json::Value>,
2054 #[serde(default)]
2055 compaction: Option<serde_json::Value>,
2056 }
2057
2058 #[derive(Deserialize)]
2062 #[serde(tag = "k")]
2063 #[allow(dead_code)]
2064 enum JournalRecordV0_10_1 {
2065 #[serde(rename = "open")]
2066 Open {
2067 v: u8,
2068 base: usize,
2069 #[serde(default)]
2070 generation: Option<String>,
2071 #[serde(default)]
2072 history_hash: Option<String>,
2073 },
2074 #[serde(rename = "msg")]
2075 Msg {
2076 v: u8,
2077 i: usize,
2078 m: serde_json::Value,
2079 },
2080 #[serde(rename = "meta")]
2081 Meta {
2082 v: u8,
2083 meta: Box<SessionMetaV0_10_1>,
2084 #[serde(default)]
2085 history_hash: Option<String>,
2086 },
2087 }
2088
2089 #[test]
2090 fn journal_written_by_this_build_parses_with_the_0_10_1_reader() {
2091 let tmp = tempfile::TempDir::new().unwrap();
2092 let dir = tmp.path().join("sessions");
2093 let mut s = Session::new("m", "medium", Some("sys"));
2094 s.name = Some("n".into());
2095 s.parent_session = Some("p".into());
2096 s.env = Some(vec![("PATH".into(), "/bin".into())]);
2097 s.api_messages.push(std::sync::Arc::new(
2098 serde_json::json!({"role": "user", "content": "q"}),
2099 ));
2100 s.session_cost = 1.0;
2101 s.total_input_tokens = 50;
2102 s.keepwarm_cost = 0.5;
2103 s.keepwarm_tokens.input = 5;
2104 s.keepwarm_tokens.cache_read = 1_000;
2105 s.total_cache_read_tokens = 2_000;
2106 s.total_cache_creation_tokens = 300;
2107 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2110 for turn in 0..2 {
2111 s.api_messages.push(std::sync::Arc::new(
2112 serde_json::json!({"role": "assistant", "content": format!("a{turn}")}),
2113 ));
2114 s.session_cost += 0.25;
2115 s.keepwarm_cost += 0.125;
2116 s.total_cache_read_tokens += 1_000;
2117 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2118 assert!(matches!(receipt.mode, SaveMode::Append { .. }));
2119 }
2120 let raw = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
2121 let mut metas = 0;
2122 for line in raw.lines() {
2123 let record: JournalRecordV0_10_1 = serde_json::from_str(line)
2124 .unwrap_or_else(|e| panic!("0.10.1 cannot parse {line}: {e}"));
2125 if matches!(record, JournalRecordV0_10_1::Meta { .. }) {
2126 metas += 1;
2127 }
2128 assert!(
2129 !line.contains("keepwarm"),
2130 "journaled a snapshot-only field: {line}"
2131 );
2132 assert!(
2133 !line.contains("total_cache_"),
2134 "journaled a snapshot-only field: {line}"
2135 );
2136 }
2137 assert_eq!(metas, 2);
2138 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2141 assert_eq!(loaded.api_messages.len(), 3);
2142 assert_eq!(loaded.session_cost, 1.5);
2143 assert_eq!(loaded.keepwarm_cost, 0.5);
2144 assert_eq!(loaded.total_cache_read_tokens, 2_000);
2145 assert_eq!(loaded.total_cache_creation_tokens, 300);
2146 }
2147
2148 #[test]
2153 fn cache_totals_survive_save_and_load() {
2154 let tmp = tempfile::TempDir::new().unwrap();
2155 let dir = tmp.path().join("sessions");
2156 for persistence in [SessionPersistence::Json, SessionPersistence::Journal] {
2157 let mut s = Session::new("m", "medium", None);
2158 s.api_messages.push(std::sync::Arc::new(
2159 serde_json::json!({"role": "user", "content": "q"}),
2160 ));
2161 s.total_input_tokens = 100;
2162 s.total_output_tokens = 10;
2163 s.total_cache_read_tokens = 85_679;
2164 s.total_cache_creation_tokens = 6_250;
2165 save_session_in_dir(&dir, &s, persistence).unwrap();
2166 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2167 assert_eq!(loaded.total_cache_read_tokens, 85_679, "{persistence:?}");
2168 assert_eq!(loaded.total_cache_creation_tokens, 6_250, "{persistence:?}");
2169 assert_eq!(loaded.total_input_tokens, 100, "{persistence:?}");
2170 }
2171 }
2172
2173 #[test]
2176 fn cache_totals_are_optional_in_the_snapshot() {
2177 let s = Session::new("m", "medium", None);
2178 let value = serde_json::to_value(&s).unwrap();
2179 assert!(value.get("total_cache_read_tokens").is_none());
2180 assert!(value.get("total_cache_creation_tokens").is_none());
2181 let legacy: Session = serde_json::from_value(value).unwrap();
2182 assert_eq!(legacy.total_cache_read_tokens, 0);
2183 assert_eq!(legacy.total_cache_creation_tokens, 0);
2184 }
2185
2186 #[test]
2187 fn keepwarm_labels_clamp_under_the_totals_on_load() {
2188 let tmp = tempfile::TempDir::new().unwrap();
2189 let dir = tmp.path().join("sessions");
2190 let mut s = Session::new("m", "medium", None);
2191 s.api_messages.push(std::sync::Arc::new(
2192 serde_json::json!({"role": "user", "content": "q"}),
2193 ));
2194 s.session_cost = 0.25;
2195 s.total_input_tokens = 3;
2196 s.total_output_tokens = 1;
2197 s.keepwarm_cost = 0.75; s.keepwarm_tokens.input = 10;
2199 s.keepwarm_tokens.output = 10;
2200 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
2201 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2202 assert_eq!(loaded.keepwarm_cost, 0.25);
2203 assert_eq!(loaded.keepwarm_tokens.input, 3);
2204 assert_eq!(loaded.keepwarm_tokens.output, 1);
2205 }
2206
2207 fn tagged(text: &str, id: &str) -> SharedMessage {
2210 std::sync::Arc::new(serde_json::json!({
2211 "role": "user", "content": text, MESSAGE_TURN_KEY: {"id": id}
2212 }))
2213 }
2214
2215 fn assert_no_host_keys(m: &serde_json::Value, ctx: &str) {
2216 let keys: Vec<_> = m
2217 .as_object()
2218 .map(|o| o.keys().filter(|k| k.starts_with("_synaps_")).cloned().collect())
2219 .unwrap_or_default();
2220 assert!(keys.is_empty(), "{ctx}: persisted message carries {keys:?}: {m}");
2221 }
2222
2223 fn old_reader_view(dir: &Path, id: &str) -> (Vec<serde_json::Value>, Option<String>) {
2228 let snap: Session =
2229 serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
2230 .unwrap();
2231 let mut messages: Vec<_> = snap.api_messages.iter().map(|m| (**m).clone()).collect();
2232 let mut hash = None;
2233 if let Ok(raw) = std::fs::read_to_string(journal_path(dir, id)) {
2234 for line in raw.lines() {
2235 match serde_json::from_str::<JournalRecordV0_10_1>(line)
2236 .unwrap_or_else(|e| panic!("0.10.2 cannot parse {line}: {e}"))
2237 {
2238 JournalRecordV0_10_1::Msg { i, m, .. } if i == messages.len() => {
2239 messages.push(m)
2240 }
2241 JournalRecordV0_10_1::Open { history_hash, .. }
2242 | JournalRecordV0_10_1::Meta { history_hash, .. } => hash = history_hash,
2243 _ => {}
2244 }
2245 }
2246 }
2247 (messages, hash)
2248 }
2249
2250 #[test]
2251 fn turn_ids_are_persisted_beside_messages_not_inside_them() {
2252 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
2253 let tmp = tempfile::TempDir::new().unwrap();
2254 let dir = tmp.path().join("sessions");
2255 let mut s = Session::new("m", "medium", None);
2256 s.api_messages.push(tagged("first", "t-1"));
2257 push_message(&mut s, "reply");
2258 save_session_in_dir(&dir, &s, mode).unwrap();
2259 s.api_messages.push(tagged("second", "t-2"));
2260 let receipt = save_session_in_dir(&dir, &s, mode).unwrap();
2261 if mode == SessionPersistence::Journal {
2262 assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }));
2263 }
2264
2265 let (old, hash) = old_reader_view(&dir, &s.id);
2266 assert_eq!(old.len(), 3, "{mode:?}");
2267 for m in &old {
2268 assert_no_host_keys(m, &format!("{mode:?}"));
2269 }
2270 if mode == SessionPersistence::Journal {
2271 let old: Vec<SharedMessage> =
2274 old.into_iter().map(std::sync::Arc::new).collect();
2275 assert_eq!(hash, Some(history_hash(&old).unwrap()), "{mode:?}");
2276 }
2277
2278 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2280 assert_eq!(
2281 serde_json::to_value(&loaded.api_messages).unwrap(),
2282 serde_json::to_value(&s.api_messages).unwrap(),
2283 "{mode:?}"
2284 );
2285 }
2286 }
2287
2288 fn dev_build_hash(messages: &[SharedMessage]) -> String {
2290 use sha2::{Digest, Sha256};
2291 format!("{:x}", Sha256::digest(serde_json::to_vec(messages).unwrap()))
2292 }
2293
2294 #[test]
2295 fn inline_turn_ids_from_earlier_dev_builds_still_load_and_migrate() {
2296 let tmp = tempfile::TempDir::new().unwrap();
2297 let dir = tmp.path().join("sessions");
2298 let mut s = Session::new("m", "medium", None);
2299 s.api_messages.push(tagged("first", "t-1"));
2300 let id = s.id.clone();
2302 let generation = "g-1";
2303 let mut snap = serde_json::to_value(&s).unwrap();
2304 snap["_journal_generation"] = generation.into();
2305 snap["message_count"] = 1.into();
2306 write_json_snapshot(&dir, &id, snap.to_string().as_bytes()).unwrap();
2307 let inline_hash = dev_build_hash(&s.api_messages);
2308 s.api_messages.push(tagged("second", "t-2"));
2309 let mut journal = String::new();
2310 for record in [
2311 serde_json::json!({"k":"open","v":2,"base":1,"generation":generation,"history_hash":inline_hash}),
2312 serde_json::json!({"k":"msg","v":2,"i":1,"m":&*s.api_messages[1]}),
2313 serde_json::json!({"k":"meta","v":2,"meta":SessionMeta::of(&s),
2314 "history_hash":dev_build_hash(&s.api_messages)}),
2315 ] {
2316 journal.push_str(&record.to_string());
2317 journal.push('\n');
2318 }
2319 std::fs::write(journal_path(&dir, &id), journal).unwrap();
2320
2321 let loaded = load_session_in_dir(&dir, &id).unwrap();
2322 assert_eq!(
2323 serde_json::to_value(&loaded.api_messages).unwrap(),
2324 serde_json::to_value(&s.api_messages).unwrap()
2325 );
2326 let receipt = save_session_in_dir(&dir, &loaded, SessionPersistence::Journal).unwrap();
2328 assert!(matches!(receipt.mode, SaveMode::FullSnapshot));
2329 for m in old_reader_view(&dir, &id).0 {
2330 assert_no_host_keys(&m, "migrated");
2331 }
2332 let again = load_session_in_dir(&dir, &id).unwrap();
2333 assert_eq!(
2334 serde_json::to_value(&again.api_messages).unwrap(),
2335 serde_json::to_value(&s.api_messages).unwrap()
2336 );
2337 }
2338
2339 #[test]
2340 fn tagging_an_already_saved_message_forces_a_snapshot() {
2341 let tmp = tempfile::TempDir::new().unwrap();
2342 let dir = tmp.path().join("sessions");
2343 let mut s = with_messages(2);
2344 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2345 s.api_messages[1] = tagged("old 1", "late");
2346 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2347 assert!(matches!(receipt.mode, SaveMode::FullSnapshot));
2348 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2349 assert_eq!(loaded.api_messages[1][MESSAGE_TURN_KEY]["id"], "late");
2350 }
2351
2352 #[test]
2355 fn rollback_round_trip_through_a_0_10_2_append() {
2356 let tmp = tempfile::TempDir::new().unwrap();
2357 let dir = tmp.path().join("sessions");
2358 let mut s = Session::new("m", "medium", None);
2359 s.api_messages.push(tagged("first", "t-1"));
2360 push_message(&mut s, "reply");
2361 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2362 s.api_messages.push(tagged("second", "t-2"));
2363 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2364
2365 let (old, hash) = old_reader_view(&dir, &s.id);
2369 let mut old: Vec<SharedMessage> = old.into_iter().map(std::sync::Arc::new).collect();
2370 assert_eq!(hash, Some(history_hash(&old).unwrap()));
2371 old.push(std::sync::Arc::new(
2372 serde_json::json!({"role": "user", "content": "asked on 0.10.2"}),
2373 ));
2374 let mut old_session = s.clone();
2375 old_session.api_messages = old.clone();
2376 old_session.updated_at += chrono::Duration::minutes(1);
2377 let mut journal = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
2378 for record in [
2379 serde_json::json!({"k":"msg","v":2,"i":3,"m":&*old[3]}),
2380 serde_json::json!({"k":"meta","v":2,"meta":SessionMeta::of(&old_session),
2381 "history_hash":history_hash(&old).unwrap()}),
2382 ] {
2383 journal.push_str(&record.to_string());
2384 journal.push('\n');
2385 }
2386 std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
2387
2388 let mut loaded = load_session_in_dir(&dir, &s.id).unwrap();
2390 assert_eq!(loaded.api_messages.len(), 4);
2391 let ids: Vec<_> = loaded.api_messages.iter().map(|m| message_turn_id(m)).collect();
2392 assert_eq!(ids, vec![Some("t-1"), None, Some("t-2"), None]);
2393 assert_eq!(loaded.api_messages[3]["content"], "asked on 0.10.2");
2394 let receipt = save_session_in_dir(&dir, &loaded, SessionPersistence::Journal).unwrap();
2396 assert!(matches!(receipt.mode, SaveMode::FullSnapshot), "{:?}", receipt.mode);
2397 loaded.api_messages.push(tagged("third", "t-3"));
2398 let receipt = save_session_in_dir(&dir, &loaded, SessionPersistence::Journal).unwrap();
2399 assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }), "{:?}", receipt.mode);
2400 let again = load_session_in_dir(&dir, &s.id).unwrap();
2401 assert_eq!(
2402 serde_json::to_value(&again.api_messages).unwrap(),
2403 serde_json::to_value(&loaded.api_messages).unwrap()
2404 );
2405 }
2406
2407 #[test]
2408 fn a_malformed_journal_turn_id_does_not_end_replay() {
2409 let tmp = tempfile::TempDir::new().unwrap();
2410 let dir = tmp.path().join("sessions");
2411 let mut s = with_messages(1);
2412 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2413 s.api_messages.push(tagged("second", "t-2"));
2414 push_message(&mut s, "third");
2415 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2416 let path = journal_path(&dir, &s.id);
2417 let raw = std::fs::read_to_string(&path).unwrap();
2418 assert!(raw.contains(r#""t":"t-2""#));
2419 std::fs::write(&path, raw.replace(r#""t":"t-2""#, r#""t":{"bad":1}"#)).unwrap();
2420 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2421 assert_eq!(loaded.api_messages.len(), 3, "replay continued past the bad id");
2422 assert_eq!(message_turn_id(&loaded.api_messages[1]), None);
2423 }
2424
2425 #[test]
2428 fn malformed_turn_ids_are_dropped_not_fatal() {
2429 for bad in [
2430 serde_json::json!("garbage"),
2431 serde_json::json!([[0, "t-1"], ["x", 1], [99, "past-the-end"], 7]),
2432 ] {
2433 let tmp = tempfile::TempDir::new().unwrap();
2434 let dir = tmp.path().join("sessions");
2435 let mut s = Session::new("m", "medium", None);
2436 s.api_messages.push(tagged("first", "t-1"));
2437 push_message(&mut s, "reply");
2438 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
2439 let path = dir.join(format!("{}.json", s.id));
2440 let mut snap: serde_json::Value =
2441 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
2442 snap["_turn_ids"] = bad.clone();
2443 std::fs::write(&path, snap.to_string()).unwrap();
2444 let loaded = load_session_in_dir(&dir, &s.id)
2445 .unwrap_or_else(|e| panic!("{bad}: session not loadable: {e}"));
2446 assert_eq!(loaded.api_messages.len(), 2, "{bad}");
2447 let well_formed = bad.as_array().is_some();
2448 assert_eq!(
2449 message_turn_id(&loaded.api_messages[0]),
2450 well_formed.then_some("t-1"),
2451 "{bad}: valid entries survive"
2452 );
2453 }
2454 }
2455
2456 fn timed(text: &str, ts: &str) -> SharedMessage {
2459 std::sync::Arc::new(serde_json::json!({
2460 "role": "user", "content": text, MESSAGE_AT_KEY: ts
2461 }))
2462 }
2463
2464 const T1: &str = "2026-10-25T05:47:00Z";
2465 const T2: &str = "2026-10-25T05:48:30Z";
2466
2467 #[test]
2468 fn message_times_round_trip_beside_messages_not_inside_them() {
2469 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
2470 let tmp = tempfile::TempDir::new().unwrap();
2471 let dir = tmp.path().join("sessions");
2472 let mut s = Session::new("m", "medium", None);
2473 let mut first = (*timed("first", T1)).clone();
2474 first[MESSAGE_TURN_KEY] = serde_json::json!({"id": "t-1"});
2475 s.api_messages.push(std::sync::Arc::new(first));
2476 push_message(&mut s, "legacy reply, no time");
2477 save_session_in_dir(&dir, &s, mode).unwrap();
2478 s.api_messages.push(timed("second", T2));
2479 let receipt = save_session_in_dir(&dir, &s, mode).unwrap();
2480 if mode == SessionPersistence::Journal {
2481 assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }));
2482 let raw = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
2483 assert!(raw.contains(&format!(r#""at":"{T2}""#)), "{raw}");
2484 } else {
2485 let raw = std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
2486 assert!(raw.contains(&format!(r#""_message_times":[[0,"{T1}"],[2,"{T2}"]]"#)), "{raw}");
2487 }
2488 let (old, hash) = old_reader_view(&dir, &s.id);
2489 assert_eq!(old.len(), 3, "{mode:?}");
2490 for m in &old {
2491 assert_no_host_keys(m, &format!("{mode:?}"));
2492 }
2493 if mode == SessionPersistence::Journal {
2494 let old: Vec<SharedMessage> = old.into_iter().map(std::sync::Arc::new).collect();
2495 assert_eq!(hash, Some(history_hash(&old).unwrap()), "{mode:?}");
2496 }
2497 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2498 assert_eq!(
2499 serde_json::to_value(&loaded.api_messages).unwrap(),
2500 serde_json::to_value(&s.api_messages).unwrap(),
2501 "{mode:?}"
2502 );
2503 let times: Vec<_> = loaded.api_messages.iter().map(|m| message_time(m)).collect();
2504 assert_eq!(times, vec![Some(T1), None, Some(T2)], "{mode:?}");
2505 }
2506 }
2507
2508 #[test]
2509 fn stamping_an_already_saved_message_forces_a_snapshot() {
2510 let tmp = tempfile::TempDir::new().unwrap();
2511 let dir = tmp.path().join("sessions");
2512 let mut s = with_messages(2);
2513 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2514 let mut late = (*s.api_messages[1]).clone();
2515 late[MESSAGE_AT_KEY] = T1.into();
2516 s.api_messages[1] = std::sync::Arc::new(late);
2517 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2518 assert!(matches!(receipt.mode, SaveMode::FullSnapshot), "{:?}", receipt.mode);
2519 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2520 assert_eq!(message_time(&loaded.api_messages[1]), Some(T1));
2521 let mut s = loaded;
2523 s.api_messages.push(timed("next", T2));
2524 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2525 assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }), "{:?}", receipt.mode);
2526 }
2527
2528 #[test]
2529 fn a_session_saved_without_times_loads_unchanged() {
2530 let tmp = tempfile::TempDir::new().unwrap();
2531 let dir = tmp.path().join("sessions");
2532 let mut s = with_messages(2);
2533 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2534 push_message(&mut s, "delta");
2535 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2536 let snap = std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
2537 let journal = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
2538 assert!(!snap.contains("_message_times") && !journal.contains("times_hash"));
2539 assert!(!journal.contains(r#""at":"#));
2540 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2541 assert_eq!(
2542 serde_json::to_value(&loaded.api_messages).unwrap(),
2543 serde_json::to_value(&s.api_messages).unwrap()
2544 );
2545 assert!(loaded.api_messages.iter().all(|m| message_time(m).is_none()));
2546 }
2547
2548 #[test]
2549 fn malformed_message_times_are_dropped_not_fatal() {
2550 for bad in [
2551 serde_json::json!("garbage"),
2552 serde_json::json!([[0, T1], [1, "not a time"], ["x", T2], [99, T2], 7]),
2553 ] {
2554 let tmp = tempfile::TempDir::new().unwrap();
2555 let dir = tmp.path().join("sessions");
2556 let mut s = Session::new("m", "medium", None);
2557 s.api_messages.push(timed("first", T1));
2558 push_message(&mut s, "reply");
2559 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
2560 let path = dir.join(format!("{}.json", s.id));
2561 let mut snap: serde_json::Value =
2562 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
2563 snap["_message_times"] = bad.clone();
2564 std::fs::write(&path, snap.to_string()).unwrap();
2565 let loaded = load_session_in_dir(&dir, &s.id)
2566 .unwrap_or_else(|e| panic!("{bad}: session not loadable: {e}"));
2567 assert_eq!(loaded.api_messages.len(), 2, "{bad}");
2568 let well_formed = bad.as_array().is_some();
2569 assert_eq!(message_time(&loaded.api_messages[0]), well_formed.then_some(T1), "{bad}");
2570 assert_eq!(message_time(&loaded.api_messages[1]), None, "{bad}");
2571 }
2572 let tmp = tempfile::TempDir::new().unwrap();
2574 let dir = tmp.path().join("sessions");
2575 let mut s = with_messages(1);
2576 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2577 s.api_messages.push(timed("second", T2));
2578 push_message(&mut s, "third");
2579 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2580 let path = journal_path(&dir, &s.id);
2581 let raw = std::fs::read_to_string(&path).unwrap();
2582 std::fs::write(&path, raw.replace(&format!(r#""at":"{T2}""#), r#""at":{"bad":1}"#)).unwrap();
2583 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2584 assert_eq!(loaded.api_messages.len(), 3, "replay continued past the bad time");
2585 assert_eq!(message_time(&loaded.api_messages[1]), None);
2586 }
2587}