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 JOURNAL_SCHEMA_VERSION: u8 = 2;
46
47pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
49
50pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
53
54const META_TAIL_WINDOW: u64 = 64 * 1024;
56
57pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; #[cfg(unix)]
80type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
81
82#[cfg(unix)]
85fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
86 match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
87 Ok(handle) => Ok(Some(handle)),
88 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
89 Err(e) => Err(e),
90 }
91}
92
93#[cfg(unix)]
95fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
96 crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)
97}
98
99#[cfg(unix)]
102fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
103 match handle.open_file(&[name.to_string()]) {
104 Ok(file) => Ok(Some(file)),
105 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
106 Err(e) => Err(e),
107 }
108}
109
110#[cfg(unix)]
112pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
113 let Some(handle) = open_sessions_dir(dir)? else {
114 return Ok(None);
115 };
116 open_artifact(&handle, name)
117}
118
119#[cfg(not(unix))]
122struct SessionsDirHandle {
123 dir: std::path::PathBuf,
124}
125
126#[cfg(not(unix))]
127fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
128 if !dir.exists() {
129 return Ok(None);
130 }
131 Ok(Some(SessionsDirHandle {
132 dir: dir.to_path_buf(),
133 }))
134}
135
136#[cfg(not(unix))]
137fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
138 crate::core::private_fs::ensure_private_dir(dir)?;
139 Ok(SessionsDirHandle {
140 dir: dir.to_path_buf(),
141 })
142}
143
144#[cfg(not(unix))]
145impl SessionsDirHandle {
146 fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
147 crate::core::private_fs::write_atomic_private(&self.dir.join(name), data)
148 .map_err(std::io::Error::other)
149 }
150 fn append_file(&self, name: &str) -> std::io::Result<std::fs::File> {
151 crate::core::private_fs::open_private_append(&self.dir.join(name))
152 .map_err(std::io::Error::other)
153 }
154 fn remove_file(&self, name: &str) -> std::io::Result<()> {
155 std::fs::remove_file(self.dir.join(name))
156 }
157}
158
159#[cfg(not(unix))]
160fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
161 let path = handle.dir.join(name);
162 match std::fs::symlink_metadata(&path) {
163 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
164 Ok(meta) if meta.file_type().is_symlink() => {
165 return Err(std::io::Error::other(format!(
166 "refusing symlinked session artifact {name:?}"
167 )))
168 }
169 _ => {}
170 }
171 match std::fs::File::open(&path) {
172 Ok(f) => Ok(Some(f)),
173 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
174 Err(e) => Err(e),
175 }
176}
177
178#[cfg(not(unix))]
179pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
180 let Some(handle) = open_sessions_dir(dir)? else {
181 return Ok(None);
182 };
183 open_artifact(&handle, name)
184}
185
186fn read_artifact_bytes(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<Vec<u8>>> {
188 use std::io::Read;
189 let Some(file) = open_artifact(handle, name)? else {
190 return Ok(None);
191 };
192 let mut buf = Vec::new();
193 file.take(MAX_PERSISTED_READ_BYTES + 1)
194 .read_to_end(&mut buf)?;
195 if buf.len() as u64 > MAX_PERSISTED_READ_BYTES {
196 return Err(std::io::Error::other(format!(
197 "session artifact {name:?} exceeds the {MAX_PERSISTED_READ_BYTES}-byte read bound"
198 )));
199 }
200 Ok(Some(buf))
201}
202
203#[derive(Debug, Clone)]
205pub struct SessionDirEntry {
206 pub name: String,
207 pub mtime: Option<std::time::SystemTime>,
208 pub byte_len: u64,
211}
212
213pub fn session_dir_entries(dir: &Path) -> std::io::Result<Vec<SessionDirEntry>> {
219 #[cfg(unix)]
220 {
221 let Some(handle) = open_sessions_dir(dir)? else {
222 return Ok(Vec::new());
223 };
224 Ok(handle
225 .entries()?
226 .into_iter()
227 .filter(|e| e.is_file)
228 .map(|e| SessionDirEntry {
229 name: e.name,
230 mtime: e.mtime_unix_ms.map(|ms| {
231 std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(ms)
232 }),
233 byte_len: e.byte_len,
234 })
235 .collect())
236 }
237 #[cfg(not(unix))]
238 {
239 if !dir.exists() {
240 return Ok(Vec::new());
241 }
242 let mut out = Vec::new();
243 for entry in std::fs::read_dir(dir)? {
244 let entry = entry?;
245 if !entry.path().is_file() {
246 continue;
247 }
248 let Ok(name) = entry.file_name().into_string() else {
249 continue;
250 };
251 let byte_len = entry
252 .metadata()
253 .map(|metadata| metadata.len())
254 .unwrap_or_default();
255 out.push(SessionDirEntry {
256 name,
257 mtime: entry.metadata().and_then(|m| m.modified()).ok(),
258 byte_len,
259 });
260 }
261 Ok(out)
262 }
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
271pub enum SessionPersistence {
272 #[default]
273 Json,
274 Journal,
275}
276
277impl SessionPersistence {
278 pub fn parse(val: &str) -> Option<Self> {
281 match val.trim() {
282 "json" => Some(Self::Json),
283 "journal" => Some(Self::Journal),
284 _ => None,
285 }
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum SaveMode {
292 FullSnapshot,
295 Append { messages: usize },
297}
298
299#[derive(Debug, Clone, Copy)]
301pub struct SaveReceipt {
302 pub mode: SaveMode,
303 pub bytes_written: u64,
304}
305
306#[derive(Debug, Clone)]
309pub struct JournalMetaTail {
310 pub updated_at: DateTime<Utc>,
311 pub session_cost: f64,
312 pub message_count: Option<usize>,
314}
315
316pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
319 dir.join(format!("{id}.journal"))
320}
321
322pub fn snapshot_due(journal_bytes: u64, snapshot_bytes: u64) -> bool {
324 journal_bytes >= JOURNAL_SNAPSHOT_MIN_BYTES.max(snapshot_bytes / JOURNAL_SNAPSHOT_RATIO)
325}
326
327#[derive(Serialize, Deserialize)]
330#[serde(tag = "k")]
331enum JournalRecord {
332 #[serde(rename = "open")]
334 Open {
335 v: u8,
336 base: usize,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 generation: Option<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
340 history_hash: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 turns_hash: Option<String>,
344 },
345 #[serde(rename = "msg")]
348 Msg {
349 v: u8,
350 i: usize,
351 m: serde_json::Value,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 t: Option<serde_json::Value>,
355 },
356 #[serde(rename = "meta")]
358 Meta {
359 v: u8,
360 meta: Box<SessionMeta>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 history_hash: Option<String>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 turns_hash: Option<String>,
365 },
366}
367
368fn message_turn_id(message: &serde_json::Value) -> Option<&str> {
369 message.get(MESSAGE_TURN_KEY)?.get("id")?.as_str()
370}
371
372struct Stored<'a>(&'a serde_json::Value);
374
375impl Serialize for Stored<'_> {
376 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
377 match self.0.as_object() {
378 Some(object) if object.contains_key(MESSAGE_TURN_KEY) => {
379 let mut object = object.clone();
380 object.remove(MESSAGE_TURN_KEY);
381 object.serialize(serializer)
382 }
383 _ => self.0.serialize(serializer),
384 }
385 }
386}
387
388struct StoredMessages<'a>(&'a [SharedMessage]);
389
390impl Serialize for StoredMessages<'_> {
391 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
392 serializer.collect_seq(self.0.iter().map(|m| Stored(m)))
393 }
394}
395
396fn stored_value(message: &serde_json::Value) -> serde_json::Value {
397 let mut message = message.clone();
398 if let Some(object) = message.as_object_mut() {
399 object.remove(MESSAGE_TURN_KEY);
400 }
401 message
402}
403
404fn turn_ids(messages: &[SharedMessage]) -> Vec<(usize, &str)> {
406 messages
407 .iter()
408 .enumerate()
409 .filter_map(|(i, m)| message_turn_id(m).map(|id| (i, id)))
410 .collect()
411}
412
413fn turns_hash(messages: &[SharedMessage]) -> std::io::Result<Option<String>> {
416 use sha2::{Digest, Sha256};
417 let ids = turn_ids(messages);
418 if ids.is_empty() {
419 return Ok(None);
420 }
421 let bytes = serde_json::to_vec(&ids).map_err(std::io::Error::other)?;
422 Ok(Some(format!("{:x}", Sha256::digest(&bytes))))
423}
424
425fn set_turn_id(message: &mut serde_json::Value, id: String) {
426 if let Some(object) = message.as_object_mut() {
427 object.insert(MESSAGE_TURN_KEY.into(), serde_json::json!({ "id": id }));
428 }
429}
430
431fn is_zero_cost(v: &f64) -> bool {
432 *v == 0.0
433}
434
435fn is_zero_tokens(v: &u64) -> bool {
436 *v == 0
437}
438
439#[cfg_attr(not(test), allow(dead_code))]
446const SNAPSHOT_ONLY_FIELDS: &[&str] = &[
447 "keepwarm_cost",
448 "keepwarm_tokens",
449 "total_cache_read_tokens",
450 "total_cache_creation_tokens",
451];
452
453#[derive(Clone, Serialize, Deserialize)]
459#[serde(deny_unknown_fields)]
460struct SessionMeta {
461 id: String,
462 title: String,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 name: Option<String>,
465 model: String,
466 thinking_level: String,
467 system_prompt: Option<String>,
468 created_at: DateTime<Utc>,
469 updated_at: DateTime<Utc>,
470 total_input_tokens: u64,
471 total_output_tokens: u64,
472 session_cost: f64,
473 #[serde(default, skip_serializing_if = "Option::is_none")]
474 message_count: Option<usize>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
476 abort_context: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 parent_session: Option<String>,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
480 compacted_into: Option<String>,
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 env: Option<Vec<(String, String)>>,
483 #[serde(default, skip_serializing_if = "Vec::is_empty")]
484 env_stripped: Vec<String>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
486 prompt_provenance: Option<crate::prompt::PromptProvenance>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
488 compaction: Option<crate::core::compaction::CompactionRecord>,
489}
490
491impl SessionMeta {
492 fn of(s: &Session) -> Self {
493 let env = scrub_secret_env(s.env.as_ref());
497 Self {
498 id: s.id.clone(),
499 title: s.title.clone(),
500 name: s.name.clone(),
501 model: s.model.clone(),
502 thinking_level: s.thinking_level.clone(),
503 system_prompt: s.system_prompt.clone(),
504 created_at: s.created_at,
505 updated_at: s.updated_at,
506 total_input_tokens: s.total_input_tokens,
507 total_output_tokens: s.total_output_tokens,
508 session_cost: s.session_cost,
509 message_count: Some(s.api_messages.len()),
510 abort_context: s.abort_context.clone(),
511 parent_session: s.parent_session.clone(),
512 compacted_into: s.compacted_into.clone(),
513 env,
514 env_stripped: s.env_stripped.clone(),
515 prompt_provenance: s.prompt_provenance.clone(),
516 compaction: s.compaction.clone(),
517 }
518 }
519
520 fn apply(self, s: &mut Session) {
522 s.title = self.title;
523 s.name = self.name;
524 s.model = self.model;
525 s.thinking_level = self.thinking_level;
526 s.system_prompt = self.system_prompt;
527 s.created_at = self.created_at;
528 s.updated_at = self.updated_at;
529 s.total_input_tokens = self.total_input_tokens;
530 s.total_output_tokens = self.total_output_tokens;
531 s.session_cost = self.session_cost;
532 s.abort_context = self.abort_context;
533 s.parent_session = self.parent_session;
534 s.compacted_into = self.compacted_into;
535 s.env = self.env;
536 s.env_stripped = self.env_stripped;
537 s.prompt_provenance = self.prompt_provenance;
538 s.compaction = self.compaction;
539 }
540}
541
542#[derive(Default, Deserialize)]
545struct SnapshotBinding {
546 #[serde(default, rename = "_journal_generation")]
547 generation: Option<String>,
548 #[serde(default, rename = "_turn_ids")]
551 turn_ids: serde_json::Value,
552}
553
554fn stored_turn_ids(value: serde_json::Value) -> Vec<(usize, String)> {
557 let entries = match value {
558 serde_json::Value::Null => return Vec::new(),
559 serde_json::Value::Array(entries) => entries,
560 _ => {
561 tracing::warn!("session snapshot has a malformed _turn_ids; turn ids dropped");
562 return Vec::new();
563 }
564 };
565 let total = entries.len();
566 let ids: Vec<(usize, String)> = entries
567 .into_iter()
568 .filter_map(|entry| serde_json::from_value(entry).ok())
569 .collect();
570 if ids.len() != total {
571 tracing::warn!(dropped = total - ids.len(), "malformed _turn_ids entries dropped");
572 }
573 ids
574}
575
576fn snapshot_binding(file: std::fs::File) -> std::io::Result<SnapshotBinding> {
577 let header = crate::core::session::read_session_header_from_file(file)
578 .ok_or_else(|| std::io::Error::other("cannot read session snapshot header"))?;
579 serde_json::from_str(&header).map_err(std::io::Error::other)
580}
581
582fn matching_open(record: &JournalRecord, snapshot_generation: Option<&str>) -> Option<u8> {
585 match record {
586 JournalRecord::Open {
587 v: 1,
588 generation: None,
589 ..
590 } if snapshot_generation.is_none() => Some(1),
591 JournalRecord::Open {
592 v,
593 generation: Some(generation),
594 ..
595 } if *v == JOURNAL_SCHEMA_VERSION
596 && !generation.is_empty()
597 && Some(generation.as_str()) == snapshot_generation =>
598 {
599 Some(*v)
600 }
601 _ => None,
602 }
603}
604
605fn history_hash(messages: &[SharedMessage]) -> std::io::Result<String> {
610 use sha2::{Digest, Sha256};
611 struct HashWriter(Sha256);
612 impl Write for HashWriter {
613 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
614 self.0.update(bytes);
615 Ok(bytes.len())
616 }
617 fn flush(&mut self) -> std::io::Result<()> {
618 Ok(())
619 }
620 }
621 let mut writer = HashWriter(Sha256::new());
622 serde_json::to_writer(&mut writer, &StoredMessages(messages)).map_err(std::io::Error::other)?;
623 Ok(format!("{:x}", writer.0.finalize()))
624}
625
626struct JournalState {
629 durable_len: usize,
630 history_hash: String,
631 turns_hash: Option<String>,
632 bytes: u64,
633}
634
635fn read_journal_state(
636 handle: &SessionsDirHandle,
637 id: &str,
638 generation: Option<&str>,
639) -> std::io::Result<Option<JournalState>> {
640 let Some(raw) = read_artifact_bytes(handle, &format!("{id}.journal"))? else {
641 return Ok(None);
642 };
643 if !raw.ends_with(b"\n") {
644 return Ok(None); }
646 let Ok(text) = std::str::from_utf8(&raw) else {
647 return Ok(None);
648 };
649 let mut lines = text.lines();
650 let Some(open) = lines
651 .next()
652 .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
653 else {
654 return Ok(None);
655 };
656 if matching_open(&open, generation) != Some(JOURNAL_SCHEMA_VERSION) {
657 return Ok(None);
658 }
659 let JournalRecord::Open {
660 base,
661 history_hash: Some(mut hash),
662 turns_hash: mut turns,
663 ..
664 } = open
665 else {
666 return Ok(None);
667 };
668 let mut durable_len = base;
669 let mut hashed_len = base;
670 for line in lines {
671 match serde_json::from_str::<JournalRecord>(line) {
672 Ok(JournalRecord::Msg { v, i, .. })
673 if v == JOURNAL_SCHEMA_VERSION && i == durable_len =>
674 {
675 durable_len += 1;
676 }
677 Ok(JournalRecord::Meta {
678 v,
679 meta,
680 history_hash: Some(next_hash),
681 turns_hash: next_turns,
682 }) if v == JOURNAL_SCHEMA_VERSION
683 && meta.id == id
684 && meta.message_count == Some(durable_len) =>
685 {
686 hash = next_hash;
687 turns = next_turns;
688 hashed_len = durable_len;
689 }
690 _ => return Ok(None),
691 }
692 }
693 if hashed_len != durable_len {
694 return Ok(None); }
696 Ok(Some(JournalState {
697 durable_len,
698 history_hash: hash,
699 turns_hash: turns,
700 bytes: raw.len() as u64,
701 }))
702}
703
704pub fn save_session_in_dir(
711 dir: &Path,
712 session: &Session,
713 mode: SessionPersistence,
714) -> std::io::Result<SaveReceipt> {
715 let handle = create_sessions_dir(dir)?;
716 match mode {
717 SessionPersistence::Json => {
718 let journal = open_artifact(&handle, &format!("{}.journal", session.id))?;
721 let bound =
722 open_artifact(&handle, &format!("{}.json", session.id))?.is_some_and(|file| {
723 snapshot_binding(file).map_or(true, |b| b.generation.is_some())
726 });
727 publish_snapshot(&handle, session, mode, false, journal.is_some() || bound)
728 }
729 SessionPersistence::Journal => save_journal_mode(&handle, session),
730 }
731}
732
733pub fn save_session_durable_in_dir(
739 dir: &Path,
740 session: &Session,
741 mode: SessionPersistence,
742) -> std::io::Result<SaveReceipt> {
743 #[cfg(unix)]
744 {
745 let handle = SessionsDirHandle::create_absolute_no_symlinks_durable(dir)?;
746 publish_snapshot(&handle, session, mode, true, true)
747 }
748 #[cfg(not(unix))]
749 {
750 let _ = (dir, session, mode);
751 Err(std::io::Error::new(
752 std::io::ErrorKind::Unsupported,
753 "durable session checkpoints require Unix directory fsync",
754 ))
755 }
756}
757
758fn save_journal_mode(
759 handle: &SessionsDirHandle,
760 session: &Session,
761) -> std::io::Result<SaveReceipt> {
762 let journal_name = format!("{}.journal", session.id);
763
764 let snapshot = open_artifact(handle, &format!("{}.json", session.id))?;
765 let Some(snapshot) = snapshot else {
766 return full_snapshot_reset(handle, session);
767 };
768 let snapshot_bytes = snapshot.metadata()?.len();
769 let Ok(binding) = snapshot_binding(snapshot) else {
770 return full_snapshot_reset(handle, session);
771 };
772 let Some(state) = read_journal_state(handle, &session.id, binding.generation.as_deref())?
773 else {
774 return full_snapshot_reset(handle, session);
775 };
776
777 if session.api_messages.len() < state.durable_len
778 || history_hash(&session.api_messages[..state.durable_len])? != state.history_hash
779 || turns_hash(&session.api_messages[..state.durable_len])? != state.turns_hash
780 {
781 return full_snapshot_reset(handle, session);
782 }
783
784 let mut buf = Vec::new();
786 for (offset, msg) in session.api_messages[state.durable_len..].iter().enumerate() {
787 let record = JournalRecord::Msg {
788 v: JOURNAL_SCHEMA_VERSION,
789 i: state.durable_len + offset,
790 m: stored_value(msg),
791 t: message_turn_id(msg).map(serde_json::Value::from),
792 };
793 serde_json::to_writer(&mut buf, &record).map_err(std::io::Error::other)?;
794 buf.push(b'\n');
795 }
796 let meta = JournalRecord::Meta {
797 v: JOURNAL_SCHEMA_VERSION,
798 meta: Box::new(SessionMeta::of(session)),
799 history_hash: Some(history_hash(&session.api_messages)?),
800 turns_hash: turns_hash(&session.api_messages)?,
801 };
802 serde_json::to_writer(&mut buf, &meta).map_err(std::io::Error::other)?;
803 buf.push(b'\n');
804
805 let appended = session.api_messages.len() - state.durable_len;
806 let mut file = handle.append_file(&journal_name)?;
807 file.write_all(&buf)?;
808 file.sync_data()?;
809 drop(file);
810
811 let journal_bytes = state.bytes + buf.len() as u64;
814 if snapshot_due(journal_bytes, snapshot_bytes) {
815 let reset = full_snapshot_reset(handle, session)?;
816 return Ok(SaveReceipt {
817 mode: SaveMode::FullSnapshot,
818 bytes_written: buf.len() as u64 + reset.bytes_written,
819 });
820 }
821
822 Ok(SaveReceipt {
823 mode: SaveMode::Append { messages: appended },
824 bytes_written: buf.len() as u64,
825 })
826}
827
828#[derive(Serialize)]
835struct SessionSnapshotRef<'a> {
836 #[serde(skip_serializing_if = "Option::is_none")]
839 _journal_generation: Option<&'a str>,
840 id: &'a str,
841 title: &'a str,
842 #[serde(skip_serializing_if = "Option::is_none")]
843 name: &'a Option<String>,
844 model: &'a str,
845 thinking_level: &'a str,
846 system_prompt: &'a Option<String>,
847 created_at: &'a DateTime<Utc>,
848 updated_at: &'a DateTime<Utc>,
849 total_input_tokens: u64,
850 total_output_tokens: u64,
851 session_cost: f64,
852 #[serde(skip_serializing_if = "is_zero_cost")]
853 keepwarm_cost: f64,
854 #[serde(skip_serializing_if = "KeepwarmTokens::is_zero")]
855 keepwarm_tokens: KeepwarmTokens,
856 #[serde(skip_serializing_if = "is_zero_tokens")]
857 total_cache_read_tokens: u64,
858 #[serde(skip_serializing_if = "is_zero_tokens")]
859 total_cache_creation_tokens: u64,
860 message_count: usize,
861 #[serde(skip_serializing_if = "Option::is_none")]
862 parent_session: &'a Option<String>,
863 #[serde(skip_serializing_if = "Option::is_none")]
864 compacted_into: &'a Option<String>,
865 #[serde(skip_serializing_if = "Option::is_none")]
868 env: Option<Vec<(String, String)>>,
869 #[serde(skip_serializing_if = "<[String]>::is_empty")]
870 env_stripped: &'a [String],
871 api_messages: StoredMessages<'a>,
872 #[serde(skip_serializing_if = "Option::is_none")]
873 abort_context: &'a Option<String>,
874 #[serde(skip_serializing_if = "Option::is_none")]
875 prompt_provenance: &'a Option<crate::prompt::PromptProvenance>,
876 #[serde(skip_serializing_if = "Option::is_none")]
877 compaction: &'a Option<crate::core::compaction::CompactionRecord>,
878 #[serde(rename = "_turn_ids", skip_serializing_if = "Vec::is_empty")]
880 turn_ids: Vec<(usize, &'a str)>,
881}
882
883impl<'a> SessionSnapshotRef<'a> {
884 fn of(s: &'a Session, generation: Option<&'a str>) -> Self {
885 Self {
886 _journal_generation: generation,
887 id: &s.id,
888 title: &s.title,
889 name: &s.name,
890 model: &s.model,
891 thinking_level: &s.thinking_level,
892 system_prompt: &s.system_prompt,
893 created_at: &s.created_at,
894 updated_at: &s.updated_at,
895 total_input_tokens: s.total_input_tokens,
896 total_output_tokens: s.total_output_tokens,
897 session_cost: s.session_cost,
898 keepwarm_cost: s.keepwarm_cost,
899 keepwarm_tokens: s.keepwarm_tokens,
900 total_cache_read_tokens: s.total_cache_read_tokens,
901 total_cache_creation_tokens: s.total_cache_creation_tokens,
902 message_count: s.api_messages.len(),
903 parent_session: &s.parent_session,
904 compacted_into: &s.compacted_into,
905 env: scrub_secret_env(s.env.as_ref()),
906 env_stripped: &s.env_stripped,
907 api_messages: StoredMessages(&s.api_messages),
908 abort_context: &s.abort_context,
909 prompt_provenance: &s.prompt_provenance,
910 compaction: &s.compaction,
911 turn_ids: turn_ids(&s.api_messages),
912 }
913 }
914}
915
916fn scrub_secret_env(env: Option<&Vec<(String, String)>>) -> Option<Vec<(String, String)>> {
922 use crate::core::config::is_secret_env;
923 env.map(|pairs| {
924 pairs
925 .iter()
926 .filter(|(k, v)| !is_secret_env(k, v))
927 .cloned()
928 .collect()
929 })
930}
931
932fn snapshot_json(session: &Session, generation: Option<&str>) -> std::io::Result<String> {
934 serde_json::to_string(&SessionSnapshotRef::of(session, generation))
935 .map_err(std::io::Error::other)
936}
937
938fn full_snapshot_reset(
939 handle: &SessionsDirHandle,
940 session: &Session,
941) -> std::io::Result<SaveReceipt> {
942 publish_snapshot(handle, session, SessionPersistence::Journal, false, true)
943}
944
945fn publish_snapshot(
946 handle: &SessionsDirHandle,
947 session: &Session,
948 mode: SessionPersistence,
949 durable: bool,
950 bind: bool,
951) -> std::io::Result<SaveReceipt> {
952 let generation = bind.then(|| uuid::Uuid::new_v4().to_string());
953 let json = snapshot_json(session, generation.as_deref())?;
954 let mut journal = Vec::new();
956 if mode == SessionPersistence::Journal {
957 let open = JournalRecord::Open {
958 v: JOURNAL_SCHEMA_VERSION,
959 base: session.api_messages.len(),
960 generation,
961 history_hash: Some(history_hash(&session.api_messages)?),
962 turns_hash: turns_hash(&session.api_messages)?,
963 };
964 serde_json::to_writer(&mut journal, &open).map_err(std::io::Error::other)?;
965 journal.push(b'\n');
966 }
967 open_artifact(handle, &format!("{}.journal", session.id))?;
970 handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
971 #[cfg(unix)]
975 if durable || bind {
976 sync_sessions_dir(handle)?;
977 }
978 if mode == SessionPersistence::Journal {
979 handle.write_atomic(&format!("{}.journal", session.id), &journal)?;
980 } else {
981 remove_artifact_if_exists(handle, &format!("{}.journal", session.id))?;
982 }
983 if durable {
984 sync_sessions_dir(handle)?;
985 }
986 Ok(SaveReceipt {
987 mode: SaveMode::FullSnapshot,
988 bytes_written: json.len() as u64 + journal.len() as u64,
989 })
990}
991
992fn sync_sessions_dir(handle: &SessionsDirHandle) -> std::io::Result<()> {
993 #[cfg(unix)]
994 {
995 handle.sync_all()
996 }
997 #[cfg(not(unix))]
998 {
999 let _ = handle;
1000 Err(std::io::Error::new(
1001 std::io::ErrorKind::Unsupported,
1002 "durable session checkpoints require Unix directory fsync",
1003 ))
1004 }
1005}
1006
1007pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
1014 let handle = open_sessions_dir(dir)?.ok_or_else(|| {
1017 std::io::Error::new(
1018 std::io::ErrorKind::NotFound,
1019 format!("no sessions directory for '{id}'"),
1020 )
1021 })?;
1022 let snapshot = read_artifact_bytes(&handle, &format!("{id}.json"))?.ok_or_else(|| {
1023 std::io::Error::new(
1024 std::io::ErrorKind::NotFound,
1025 format!("no session snapshot for '{id}'"),
1026 )
1027 })?;
1028 let binding: SnapshotBinding =
1029 serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
1030 let mut session: Session = serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
1031 session.clamp_keepwarm_to_totals();
1032 for (i, id) in stored_turn_ids(binding.turn_ids) {
1033 if let Some(message) = session.api_messages.get_mut(i) {
1034 set_turn_id(std::sync::Arc::make_mut(message), id);
1035 }
1036 }
1037
1038 let Some(raw) = read_artifact_bytes(&handle, &format!("{id}.journal"))? else {
1039 return Ok(session);
1040 };
1041 let text = String::from_utf8_lossy(&raw);
1042 let mut lines = text.lines();
1043 let version = match lines
1044 .next()
1045 .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
1046 .and_then(|open| matching_open(&open, binding.generation.as_deref()))
1047 {
1048 Some(version) => version,
1049 None => return Ok(session),
1050 };
1051 for line in lines {
1052 match serde_json::from_str::<JournalRecord>(line) {
1053 Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
1055 if v != version =>
1056 {
1057 break;
1058 }
1059 Ok(JournalRecord::Msg { i, mut m, t, .. }) => {
1060 if i == session.api_messages.len() {
1061 if let Some(serde_json::Value::String(id)) = t {
1062 set_turn_id(&mut m, id);
1063 }
1064 session.api_messages.push(std::sync::Arc::new(m));
1065 } else if i > session.api_messages.len() {
1066 break; }
1068 }
1070 Ok(JournalRecord::Meta { meta, .. }) => {
1071 if meta.id == session.id && meta.updated_at >= session.updated_at {
1072 meta.apply(&mut session);
1073 }
1074 }
1075 Ok(JournalRecord::Open { .. }) | Err(_) => break, }
1077 }
1078 session.clamp_keepwarm_to_totals();
1081 Ok(session)
1082}
1083
1084pub fn journal_meta_tail(dir: &Path, id: &str) -> Option<JournalMetaTail> {
1089 let handle = open_sessions_dir(dir).ok()??;
1090 let binding = snapshot_binding(open_artifact(&handle, &format!("{id}.json")).ok()??).ok()?;
1091 journal_meta_tail_from_handle(&handle, id, binding.generation.as_deref())
1092}
1093
1094pub(crate) fn journal_meta_tail_for_generation(
1097 dir: &Path,
1098 id: &str,
1099 generation: Option<&str>,
1100) -> Option<JournalMetaTail> {
1101 let handle = open_sessions_dir(dir).ok()??;
1102 journal_meta_tail_from_handle(&handle, id, generation)
1103}
1104
1105fn journal_meta_tail_from_handle(
1106 handle: &SessionsDirHandle,
1107 id: &str,
1108 generation: Option<&str>,
1109) -> Option<JournalMetaTail> {
1110 use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
1111 let mut file = open_artifact(handle, &format!("{id}.journal")).ok()??;
1112 let mut first = String::new();
1113 BufReader::new((&mut file).take(4096))
1114 .read_line(&mut first)
1115 .ok()?;
1116 let open: JournalRecord = serde_json::from_str(&first).ok()?;
1117 let version = matching_open(&open, generation)?;
1118 let len = file.metadata().ok()?.len();
1119 let start = len.saturating_sub(META_TAIL_WINDOW);
1120 file.seek(SeekFrom::Start(start)).ok()?;
1121 let mut tail = String::new();
1122 file.take(META_TAIL_WINDOW).read_to_string(&mut tail).ok()?;
1123
1124 let mut freshest = None;
1125 for line in tail.lines() {
1126 if let Ok(JournalRecord::Meta { v, meta, .. }) = serde_json::from_str::<JournalRecord>(line)
1127 {
1128 if v == version && meta.id == id {
1129 freshest = Some(JournalMetaTail {
1130 updated_at: meta.updated_at,
1131 session_cost: meta.session_cost,
1132 message_count: meta.message_count,
1133 });
1134 }
1135 }
1136 }
1137 freshest
1138}
1139
1140pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
1146 let Some(handle) = open_sessions_dir(dir)? else {
1147 return Ok(()); };
1149 remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
1150 remove_artifact_if_exists(&handle, &format!("{id}.journal"))?;
1151 remove_artifact_if_exists(&handle, &format!("{id}.turn"))
1152}
1153
1154fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
1155 match handle.remove_file(name) {
1156 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1157 other => other,
1158 }
1159}
1160
1161#[cfg(test)]
1165pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
1166 let handle = create_sessions_dir(dir)?;
1167 handle.write_atomic(&format!("{id}.json"), json)
1168}
1169
1170#[cfg(test)]
1173mod tests {
1174 use super::*;
1175
1176 #[test]
1181 fn snapshot_json_matches_session_schema() {
1182 let mut s = Session::new("model-x", "medium", Some("prompt"));
1183 s.name = Some("named".into());
1184 s.abort_context = Some("ctx".into());
1185 s.parent_session = Some("parent".into());
1186 s.compacted_into = Some("child".into());
1187 s.api_messages.push(std::sync::Arc::new(
1188 serde_json::json!({"role": "user", "content": "hi"}),
1189 ));
1190 s.message_count = 0; let mut expected = s.clone();
1193 expected.message_count = expected.api_messages.len();
1194 let expected = serde_json::to_string(&expected).unwrap();
1195 assert_eq!(snapshot_json(&s, None).unwrap(), expected);
1196 let back: Session = serde_json::from_str(&snapshot_json(&s, None).unwrap()).unwrap();
1197 assert_eq!(back.message_count, 1);
1198 let s = Session::new("model-x", "medium", None);
1200 let mut expected = s.clone();
1201 expected.message_count = 0;
1202 assert_eq!(
1203 snapshot_json(&s, None).unwrap(),
1204 serde_json::to_string(&expected).unwrap()
1205 );
1206 }
1207
1208 #[test]
1209 fn session_meta_stays_in_sync_with_session_schema() {
1210 let mut s = Session::new("model-x", "medium", Some("prompt"));
1211 s.name = Some("named".into());
1212 s.abort_context = Some("ctx".into());
1213 s.parent_session = Some("parent".into());
1214 s.compacted_into = Some("child".into());
1215 s.keepwarm_cost = 0.25;
1216 s.keepwarm_tokens.cache_read = 9;
1217 let mut value = serde_json::to_value(&s).unwrap();
1218 value.as_object_mut().unwrap().remove("api_messages");
1219 for f in SNAPSHOT_ONLY_FIELDS {
1220 value.as_object_mut().unwrap().remove(*f);
1221 }
1222 let meta: SessionMeta = serde_json::from_value(value)
1223 .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
1224 assert_eq!(meta.id, s.id);
1225 assert_eq!(meta.name.as_deref(), Some("named"));
1226 }
1227
1228 #[test]
1229 fn meta_roundtrip_applies_every_field() {
1230 let mut a = Session::new("model-a", "high", Some("sys"));
1231 a.title = "t".into();
1232 a.total_input_tokens = 7;
1233 a.session_cost = 0.5;
1234 a.updated_at = chrono::Utc::now();
1235 let mut b = Session::new("model-b", "low", None);
1236 b.id = a.id.clone();
1237 SessionMeta::of(&a).apply(&mut b);
1238 assert_eq!(b.model, "model-a");
1239 assert_eq!(b.title, "t");
1240 assert_eq!(b.total_input_tokens, 7);
1241 assert_eq!(b.session_cost, 0.5);
1242 assert_eq!(b.updated_at, a.updated_at);
1243 }
1244
1245 #[test]
1246 fn env_roundtrip_and_secret_scrub() {
1247 let tmp = tempfile::TempDir::new().unwrap();
1248 let dir = tmp.path().join("sessions");
1249 let mut s = Session::new("m", "medium", None);
1250 s.env = Some(vec![
1252 ("PATH".into(), "/usr/bin".into()),
1253 ("GH_TOKEN".into(), "ghp_supersecret42".into()),
1254 ]);
1255 s.env_stripped = vec!["GH_TOKEN".into()];
1256 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1257 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1259 let env = loaded.env.as_ref().expect("env must round-trip");
1261 assert!(
1262 env.iter().any(|(k, v)| k == "PATH" && v == "/usr/bin"),
1263 "non-secret key must survive"
1264 );
1265 let snap_bytes =
1267 std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
1268 assert!(
1269 !snap_bytes.contains("ghp_supersecret42"),
1270 "secret VALUE must never appear on disk"
1271 );
1272 assert!(
1275 snap_bytes.contains("\"env\"") && snap_bytes.contains("/usr/bin"),
1276 "snapshot must carry the scrubbed env: {snap_bytes}"
1277 );
1278 assert_eq!(loaded.env_stripped, vec!["GH_TOKEN".to_string()]);
1280 assert!(
1282 !env.iter().any(|(k, _)| k == "GH_TOKEN"),
1283 "secret key must be scrubbed from env pairs"
1284 );
1285 }
1286
1287 #[test]
1288 fn credential_url_values_never_reach_disk_whatever_the_key() {
1289 let tmp = tempfile::TempDir::new().unwrap();
1290 let dir = tmp.path().join("sessions");
1291 let mut s = Session::new("m", "medium", None);
1292 s.env = Some(vec![
1293 ("PATH".into(), "/usr/bin".into()),
1294 (
1295 "DATABASE_URL".into(),
1296 "postgres://app:hunter2@db/prod".into(),
1297 ),
1298 (
1299 "SENTRY_DSN".into(),
1300 "https://abc123dsnkey@o1.ingest.sentry.io/42".into(),
1301 ),
1302 ]);
1303 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1304 for entry in std::fs::read_dir(&dir).unwrap() {
1306 let path = entry.unwrap().path();
1307 let bytes = std::fs::read(&path).unwrap();
1308 let text = String::from_utf8_lossy(&bytes);
1309 assert!(
1310 !text.contains("hunter2"),
1311 "credential URL leaked to {}",
1312 path.display()
1313 );
1314 assert!(
1315 !text.contains("abc123dsnkey"),
1316 "DSN key leaked to {}",
1317 path.display()
1318 );
1319 }
1320 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1321 let env = loaded.env.as_ref().expect("env must round-trip");
1322 assert!(
1323 env.iter().any(|(k, _)| k == "PATH"),
1324 "non-secret key must survive"
1325 );
1326 assert!(!env
1327 .iter()
1328 .any(|(k, _)| k == "DATABASE_URL" || k == "SENTRY_DSN"));
1329 }
1330
1331 pub(super) fn with_messages(n: usize) -> Session {
1332 let mut s = Session::new("checkpoint-test", "medium", None);
1333 for i in 0..n {
1334 push_message(&mut s, &format!("old {i}"));
1335 }
1336 s
1337 }
1338
1339 pub(super) fn push_message(s: &mut Session, text: &str) {
1340 s.api_messages.push(std::sync::Arc::new(
1341 serde_json::json!({"role": "user", "content": text}),
1342 ));
1343 }
1344
1345 fn assert_saved(dir: &Path, expected: &Session) {
1346 let loaded = load_session_in_dir(dir, &expected.id).unwrap();
1347 let mut expected = expected.clone();
1348 expected.message_count = expected.api_messages.len();
1349 let mut loaded = loaded;
1351 loaded.message_count = loaded.api_messages.len();
1352 assert_eq!(
1353 serde_json::to_value(loaded).unwrap(),
1354 serde_json::to_value(expected).unwrap()
1355 );
1356 }
1357
1358 fn disk_generation(dir: &Path, id: &str) -> String {
1359 let snapshot: serde_json::Value =
1360 serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
1361 .unwrap();
1362 snapshot["_journal_generation"]
1363 .as_str()
1364 .unwrap()
1365 .to_string()
1366 }
1367
1368 #[test]
1369 fn bound_snapshot_is_session_shaped_and_storage_metadata_is_not_mirrored() {
1370 let s = with_messages(2);
1371 let json = snapshot_json(&s, Some("test-generation")).unwrap();
1372 let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1373 assert_eq!(
1374 value
1375 .as_object_mut()
1376 .unwrap()
1377 .remove("_journal_generation")
1378 .unwrap(),
1379 "test-generation"
1380 );
1381 let mut expected = s.clone();
1382 expected.message_count = 2;
1383 assert_eq!(value, serde_json::to_value(&expected).unwrap());
1384 let back: Session = serde_json::from_str(&json).unwrap();
1385 assert_eq!(serde_json::to_value(back).unwrap(), value);
1386 value.as_object_mut().unwrap().remove("api_messages");
1387 serde_json::from_value::<SessionMeta>(value).unwrap();
1388 }
1389
1390 fn replacement_crash_case(mode: SessionPersistence, durable: bool, shorten: bool) {
1394 let tmp = tempfile::TempDir::new().unwrap();
1395 let dir = tmp.path().canonicalize().unwrap();
1396 let mut s = with_messages(2);
1397 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1398 for _ in 0..4 {
1399 push_message(&mut s, "obsolete journal message");
1400 }
1401 s.title = "obsolete metadata".into();
1402 s.updated_at += chrono::Duration::hours(1);
1403 s.session_cost = 99.0;
1404 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1405 let old = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1406 let old_generation = disk_generation(&dir, &s.id);
1407
1408 s.api_messages[0] =
1410 std::sync::Arc::new(serde_json::json!({"role":"user","content":"new head"}));
1411 if shorten {
1412 s.api_messages.truncate(3);
1413 }
1414 s.title = "candidate".into();
1415 s.updated_at -= chrono::Duration::hours(2); s.session_cost = 1.0;
1417 let receipt = if durable {
1418 save_session_durable_in_dir(&dir, &s, mode).unwrap()
1419 } else {
1420 save_session_in_dir(&dir, &s, mode).unwrap()
1421 };
1422 assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1423 assert_ne!(disk_generation(&dir, &s.id), old_generation);
1424 std::fs::write(journal_path(&dir, &s.id), old).unwrap();
1425 assert_saved(&dir, &s);
1426 assert!(journal_meta_tail(&dir, &s.id).is_none());
1427
1428 push_message(&mut s, "post-checkpoint");
1430 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1431 assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1432 assert_saved(&dir, &s);
1433 push_message(&mut s, "new generation delta");
1434 assert_eq!(
1435 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1436 .unwrap()
1437 .mode,
1438 SaveMode::Append { messages: 1 }
1439 );
1440 assert_saved(&dir, &s);
1441 }
1442
1443 #[test]
1444 fn normal_replacements_ignore_stale_journal_after_snapshot_publication() {
1445 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1446 for shorten in [false, true] {
1447 replacement_crash_case(mode, false, shorten);
1448 }
1449 }
1450 }
1451
1452 #[cfg(unix)]
1453 #[test]
1454 fn durable_replacements_ignore_stale_journal_after_snapshot_publication() {
1455 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1456 for shorten in [false, true] {
1457 replacement_crash_case(mode, true, shorten);
1458 }
1459 }
1460 }
1461
1462 #[test]
1463 fn legacy_v1_reads_then_migrates_and_cannot_replay_over_replacement() {
1464 let tmp = tempfile::TempDir::new().unwrap();
1465 let dir = tmp.path().canonicalize().unwrap();
1466 let mut s = with_messages(1);
1467 std::fs::write(
1468 dir.join(format!("{}.json", s.id)),
1469 snapshot_json(&s, None).unwrap(),
1470 )
1471 .unwrap();
1472 let mut legacy = vec![JournalRecord::Open {
1473 v: 1,
1474 base: 1,
1475 generation: None,
1476 history_hash: None,
1477 turns_hash: None,
1478 }];
1479 for i in 1..5 {
1480 push_message(&mut s, "legacy delta");
1481 legacy.push(JournalRecord::Msg {
1482 v: 1,
1483 i,
1484 m: s.api_messages[i].as_ref().clone(),
1485 t: None,
1486 });
1487 }
1488 s.title = "legacy title".into();
1489 s.updated_at += chrono::Duration::hours(1);
1490 legacy.push(JournalRecord::Meta {
1491 v: 1,
1492 meta: Box::new(SessionMeta::of(&s)),
1493 history_hash: None,
1494 turns_hash: None,
1495 });
1496 let mut bytes = Vec::new();
1497 for record in legacy {
1498 serde_json::to_writer(&mut bytes, &record).unwrap();
1499 bytes.push(b'\n');
1500 }
1501 std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1502 assert_saved(&dir, &s);
1503 assert_eq!(
1504 journal_meta_tail(&dir, &s.id).unwrap().message_count,
1505 Some(5)
1506 );
1507 let migrated = load_session_in_dir(&dir, &s.id).unwrap();
1508 assert_eq!(
1509 save_session_in_dir(&dir, &migrated, SessionPersistence::Journal)
1510 .unwrap()
1511 .mode,
1512 SaveMode::FullSnapshot
1513 );
1514 assert_saved(&dir, &s);
1515 assert!(!disk_generation(&dir, &s.id).is_empty());
1516
1517 s.api_messages.truncate(2);
1518 s.title = "new window".into();
1519 s.updated_at -= chrono::Duration::hours(2);
1520 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1521 std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1522 assert_saved(&dir, &s);
1523 assert!(journal_meta_tail(&dir, &s.id).is_none());
1524 }
1525
1526 #[test]
1527 fn same_length_edits_in_snapshot_or_earlier_journal_message_resnapshot() {
1528 for appended in [false, true] {
1529 let tmp = tempfile::TempDir::new().unwrap();
1530 let dir = tmp.path().canonicalize().unwrap();
1531 let mut s = with_messages(3);
1532 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1533 let edited = if appended {
1534 push_message(&mut s, "editable");
1535 push_message(&mut s, "unchanged last message");
1536 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1537 3
1538 } else {
1539 0
1540 };
1541 let old_generation = disk_generation(&dir, &s.id);
1542 std::sync::Arc::make_mut(&mut s.api_messages[edited])["content"] = "replaced".into();
1543 assert_eq!(
1544 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1545 .unwrap()
1546 .mode,
1547 SaveMode::FullSnapshot
1548 );
1549 assert_ne!(disk_generation(&dir, &s.id), old_generation);
1550 assert_saved(&dir, &s);
1551 }
1552 }
1553
1554 #[test]
1555 fn v2_never_replays_without_binding_or_across_record_versions() {
1556 let tmp = tempfile::TempDir::new().unwrap();
1557 let dir = tmp.path().canonicalize().unwrap();
1558 let mut s = with_messages(1);
1559 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1560 let first = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1561 push_message(&mut s, "v2 delta");
1562 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1563 let snapshot: Session =
1564 serde_json::from_slice(&std::fs::read(dir.join(format!("{}.json", s.id))).unwrap())
1565 .unwrap();
1566 std::fs::write(
1568 dir.join(format!("{}.json", s.id)),
1569 serde_json::to_vec(&snapshot).unwrap(),
1570 )
1571 .unwrap();
1572 assert_saved(&dir, &snapshot); save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1574 let bound_snapshot = s.clone();
1575 let mut journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1576 journal.extend_from_slice(
1577 b"{\"v\":1,\"k\":\"msg\",\"i\":2,\"m\":{\"content\":\"wrong version\"}}\n",
1578 );
1579 std::fs::write(journal_path(&dir, &s.id), &journal).unwrap();
1580 assert_saved(&dir, &bound_snapshot);
1581 assert_eq!(
1582 save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1583 .unwrap()
1584 .mode,
1585 SaveMode::FullSnapshot
1586 );
1587 std::fs::write(journal_path(&dir, &s.id), first).unwrap();
1589 assert_saved(&dir, &s);
1590 }
1591
1592 #[test]
1593 fn json_saves_keep_binding_even_after_journal_deletion() {
1594 let tmp = tempfile::TempDir::new().unwrap();
1595 let dir = tmp.path().canonicalize().unwrap();
1596 let mut s = with_messages(2);
1597 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1598 push_message(&mut s, "old delta");
1599 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1600 let journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1601 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1602 let generation = disk_generation(&dir, &s.id);
1603 s.api_messages.truncate(2);
1604 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1605 assert_ne!(generation, disk_generation(&dir, &s.id));
1606 std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
1607 assert_saved(&dir, &s);
1608 }
1609
1610 #[cfg(unix)]
1611 #[test]
1612 fn durable_error_after_rename_keeps_candidate_and_retry_is_safe() {
1613 let tmp = tempfile::TempDir::new().unwrap();
1614 let dir = tmp.path().canonicalize().unwrap();
1615 let mut s = with_messages(2);
1616 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1617 push_message(&mut s, "old delta");
1618 push_message(&mut s, "more old history");
1619 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1620 let old_journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1621 let old_generation = disk_generation(&dir, &s.id);
1622 s.api_messages.truncate(2);
1623 s.title = "durable candidate".into();
1624 let blocker = dir.join(format!("{}.journal.tmp", s.id));
1626 std::fs::create_dir(&blocker).unwrap();
1627 let error = save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap_err();
1628 assert_ne!(error.kind(), std::io::ErrorKind::NotFound);
1629 assert_ne!(old_generation, disk_generation(&dir, &s.id));
1630 assert_eq!(
1631 std::fs::read(journal_path(&dir, &s.id)).unwrap(),
1632 old_journal
1633 );
1634 assert_saved(&dir, &s); std::fs::remove_dir(blocker).unwrap();
1636 save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1637 assert_saved(&dir, &s);
1638 }
1639
1640 #[cfg(unix)]
1643 mod private_modes {
1644 use super::*;
1645 use crate::core::private_fs::test_support::UmaskGuard;
1646 use serial_test::serial;
1647 use std::os::unix::fs::PermissionsExt;
1648
1649 fn mode_of(path: &Path) -> u32 {
1650 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1651 }
1652
1653 #[test]
1654 #[serial(umask)]
1655 fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
1656 let _umask = UmaskGuard::set(0);
1657 let tmp = tempfile::TempDir::new().unwrap();
1658 let dir = tmp.path().join("sessions");
1659 let mut s = Session::new("m", "medium", None);
1660 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1661 s.api_messages.push(std::sync::Arc::new(
1662 serde_json::json!({"role":"user","content":"x"}),
1663 ));
1664 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1665
1666 assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1667 assert_eq!(
1668 mode_of(&dir.join(format!("{}.json", s.id))),
1669 0o600,
1670 "snapshot must be 0600"
1671 );
1672 assert_eq!(
1673 mode_of(&journal_path(&dir, &s.id)),
1674 0o600,
1675 "journal must be 0600"
1676 );
1677 }
1678
1679 #[test]
1680 #[serial(umask)]
1681 fn durable_files_are_private_in_both_modes_and_repair_leaf_modes() {
1682 let _umask = UmaskGuard::set(0);
1683 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1684 let tmp = tempfile::TempDir::new().unwrap();
1685 let dir = tmp
1686 .path()
1687 .canonicalize()
1688 .unwrap()
1689 .join("new/profile/sessions");
1690 let s = with_messages(2);
1691 save_session_durable_in_dir(&dir, &s, mode).unwrap();
1692 assert_eq!(mode_of(dir.parent().unwrap()), 0o700);
1693 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
1694 let snapshot = dir.join(format!("{}.json", s.id));
1695 std::fs::set_permissions(&snapshot, std::fs::Permissions::from_mode(0o666))
1696 .unwrap();
1697 save_session_durable_in_dir(&dir, &s, mode).unwrap();
1698 assert_eq!(mode_of(&dir), 0o700);
1699 assert_eq!(mode_of(&snapshot), 0o600);
1700 if mode == SessionPersistence::Journal {
1701 assert_eq!(mode_of(&journal_path(&dir, &s.id)), 0o600);
1702 }
1703 assert_saved(&dir, &s);
1704 }
1705 }
1706
1707 #[test]
1708 fn durable_saves_refuse_artifact_and_ancestor_symlinks_in_both_modes() {
1709 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1710 for extension in ["json", "journal"] {
1711 let tmp = tempfile::TempDir::new().unwrap();
1712 let root = tmp.path().canonicalize().unwrap();
1713 let dir = root.join("sessions");
1714 std::fs::create_dir(&dir).unwrap();
1715 let victim = root.join("victim");
1716 std::fs::write(&victim, "original").unwrap();
1717 let s = with_messages(1);
1718 std::os::unix::fs::symlink(
1719 &victim,
1720 dir.join(format!("{}.{}", s.id, extension)),
1721 )
1722 .unwrap();
1723 assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1724 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1725 }
1726 let tmp = tempfile::TempDir::new().unwrap();
1727 let root = tmp.path().canonicalize().unwrap();
1728 let victim_dir = root.join("victim-dir");
1729 std::fs::create_dir(&victim_dir).unwrap();
1730 let link = root.join("link");
1731 std::os::unix::fs::symlink(&victim_dir, &link).unwrap();
1732 let s = with_messages(1);
1733 for dir in [link.clone(), link.join("profile/sessions")] {
1734 assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1735 }
1736 assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
1737 }
1738 }
1739
1740 #[test]
1741 fn journal_append_refuses_symlink_target() {
1742 let tmp = tempfile::TempDir::new().unwrap();
1743 let dir = tmp.path().join("sessions");
1744 std::fs::create_dir_all(&dir).unwrap();
1745 let mut s = Session::new("m", "medium", None);
1746 let victim = tmp.path().join("victim");
1747 std::fs::write(&victim, "original").unwrap();
1748 std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
1749 s.api_messages.push(std::sync::Arc::new(
1751 serde_json::json!({"role":"user","content":"x"}),
1752 ));
1753 let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
1754 assert!(res.is_err(), "journal write onto a symlink must fail");
1755 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1756 }
1757 }
1758}
1759
1760#[cfg(test)]
1766mod save_cost_bench {
1767 use super::*;
1768 use std::sync::Arc;
1769 use std::time::{Duration, Instant};
1770
1771 fn session(bytes: usize) -> Session {
1772 let mut s = Session::new("claude-sonnet-4-5", "low", None);
1773 let chunk = "x".repeat(4000);
1776 let n = bytes / 4096;
1777 s.api_messages = (0..n)
1778 .map(|i| {
1779 let role = if i % 2 == 0 { "user" } else { "assistant" };
1780 Arc::new(serde_json::json!({"role": role, "content": format!("{i} {chunk}")}))
1781 })
1782 .collect();
1783 s
1784 }
1785
1786 fn pct(sorted: &[Duration], p: f64) -> Duration {
1787 sorted[((sorted.len() - 1) as f64 * p).round() as usize]
1788 }
1789
1790 #[test]
1791 #[ignore = "benchmark: needs SYNAPS_SAVE_BENCH_DIR on a real disk"]
1792 fn save_cost_bench() {
1793 let Ok(root) = std::env::var("SYNAPS_SAVE_BENCH_DIR") else {
1794 eprintln!("SYNAPS_SAVE_BENCH_DIR not set; skipping");
1795 return;
1796 };
1797 let dir = tempfile::tempdir_in(root).unwrap();
1798 for (label, bytes) in [("200 KB", 200_000), ("2.3 MB", 2_300_000), ("10 MB", 10_000_000)] {
1799 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1800 let mut s = session(bytes);
1801 save_session_in_dir(dir.path(), &s, mode).unwrap(); let mut samples = Vec::new();
1803 for round in 0..40 {
1804 for role in ["assistant", "user"] {
1806 s.api_messages.push(Arc::new(
1807 serde_json::json!({"role": role, "content": format!("round {round}")}),
1808 ));
1809 }
1810 let t = Instant::now();
1811 save_session_in_dir(dir.path(), &s, mode).unwrap();
1812 samples.push(t.elapsed());
1813 }
1814 samples.sort();
1815 eprintln!(
1816 "{label:>7} {mode:?}: p50 {:?} p95 {:?} max {:?}",
1817 pct(&samples, 0.5),
1818 pct(&samples, 0.95),
1819 samples[samples.len() - 1]
1820 );
1821 let _ = delete_session_files_in_dir(dir.path(), &s.id);
1822 }
1823 }
1824 }
1825}
1826
1827#[cfg(test)]
1830mod snapshot_only_fields {
1831 use super::tests::{push_message, with_messages};
1832 use super::*;
1833
1834 #[test]
1837 fn keepwarm_cost_survives_save_and_load() {
1838 let tmp = tempfile::TempDir::new().unwrap();
1839 let dir = tmp.path().join("sessions");
1840 for persistence in [SessionPersistence::Json, SessionPersistence::Journal] {
1841 let mut s = Session::new("m", "medium", None);
1842 s.api_messages.push(std::sync::Arc::new(
1843 serde_json::json!({"role": "user", "content": "q"}),
1844 ));
1845 s.session_cost = 1.5;
1846 s.total_input_tokens = 100;
1847 s.total_output_tokens = 10;
1848 s.keepwarm_cost = 0.25;
1849 s.keepwarm_tokens = KeepwarmTokens {
1850 input: 4,
1851 output: 2,
1852 cache_read: 900,
1853 cache_creation: 0,
1854 };
1855 save_session_in_dir(&dir, &s, persistence).unwrap();
1856 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1857 assert_eq!(loaded.keepwarm_cost, 0.25, "{persistence:?}");
1858 assert_eq!(loaded.keepwarm_tokens, s.keepwarm_tokens, "{persistence:?}");
1859 assert_eq!(loaded.session_cost, 1.5, "{persistence:?}");
1860 }
1861 }
1862
1863 #[derive(Deserialize)]
1868 #[serde(deny_unknown_fields)]
1869 #[allow(dead_code)]
1870 struct SessionMetaV0_10_1 {
1871 id: String,
1872 title: String,
1873 #[serde(default)]
1874 name: Option<String>,
1875 model: String,
1876 thinking_level: String,
1877 system_prompt: Option<String>,
1878 created_at: DateTime<Utc>,
1879 updated_at: DateTime<Utc>,
1880 total_input_tokens: u64,
1881 total_output_tokens: u64,
1882 session_cost: f64,
1883 #[serde(default)]
1884 message_count: Option<usize>,
1885 #[serde(default)]
1886 abort_context: Option<String>,
1887 #[serde(default)]
1888 parent_session: Option<String>,
1889 #[serde(default)]
1890 compacted_into: Option<String>,
1891 #[serde(default)]
1892 env: Option<Vec<(String, String)>>,
1893 #[serde(default)]
1894 env_stripped: Vec<String>,
1895 #[serde(default)]
1896 prompt_provenance: Option<serde_json::Value>,
1897 #[serde(default)]
1898 compaction: Option<serde_json::Value>,
1899 }
1900
1901 #[derive(Deserialize)]
1905 #[serde(tag = "k")]
1906 #[allow(dead_code)]
1907 enum JournalRecordV0_10_1 {
1908 #[serde(rename = "open")]
1909 Open {
1910 v: u8,
1911 base: usize,
1912 #[serde(default)]
1913 generation: Option<String>,
1914 #[serde(default)]
1915 history_hash: Option<String>,
1916 },
1917 #[serde(rename = "msg")]
1918 Msg {
1919 v: u8,
1920 i: usize,
1921 m: serde_json::Value,
1922 },
1923 #[serde(rename = "meta")]
1924 Meta {
1925 v: u8,
1926 meta: Box<SessionMetaV0_10_1>,
1927 #[serde(default)]
1928 history_hash: Option<String>,
1929 },
1930 }
1931
1932 #[test]
1933 fn journal_written_by_this_build_parses_with_the_0_10_1_reader() {
1934 let tmp = tempfile::TempDir::new().unwrap();
1935 let dir = tmp.path().join("sessions");
1936 let mut s = Session::new("m", "medium", Some("sys"));
1937 s.name = Some("n".into());
1938 s.parent_session = Some("p".into());
1939 s.env = Some(vec![("PATH".into(), "/bin".into())]);
1940 s.api_messages.push(std::sync::Arc::new(
1941 serde_json::json!({"role": "user", "content": "q"}),
1942 ));
1943 s.session_cost = 1.0;
1944 s.total_input_tokens = 50;
1945 s.keepwarm_cost = 0.5;
1946 s.keepwarm_tokens.input = 5;
1947 s.keepwarm_tokens.cache_read = 1_000;
1948 s.total_cache_read_tokens = 2_000;
1949 s.total_cache_creation_tokens = 300;
1950 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1953 for turn in 0..2 {
1954 s.api_messages.push(std::sync::Arc::new(
1955 serde_json::json!({"role": "assistant", "content": format!("a{turn}")}),
1956 ));
1957 s.session_cost += 0.25;
1958 s.keepwarm_cost += 0.125;
1959 s.total_cache_read_tokens += 1_000;
1960 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1961 assert!(matches!(receipt.mode, SaveMode::Append { .. }));
1962 }
1963 let raw = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
1964 let mut metas = 0;
1965 for line in raw.lines() {
1966 let record: JournalRecordV0_10_1 = serde_json::from_str(line)
1967 .unwrap_or_else(|e| panic!("0.10.1 cannot parse {line}: {e}"));
1968 if matches!(record, JournalRecordV0_10_1::Meta { .. }) {
1969 metas += 1;
1970 }
1971 assert!(
1972 !line.contains("keepwarm"),
1973 "journaled a snapshot-only field: {line}"
1974 );
1975 assert!(
1976 !line.contains("total_cache_"),
1977 "journaled a snapshot-only field: {line}"
1978 );
1979 }
1980 assert_eq!(metas, 2);
1981 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1984 assert_eq!(loaded.api_messages.len(), 3);
1985 assert_eq!(loaded.session_cost, 1.5);
1986 assert_eq!(loaded.keepwarm_cost, 0.5);
1987 assert_eq!(loaded.total_cache_read_tokens, 2_000);
1988 assert_eq!(loaded.total_cache_creation_tokens, 300);
1989 }
1990
1991 #[test]
1996 fn cache_totals_survive_save_and_load() {
1997 let tmp = tempfile::TempDir::new().unwrap();
1998 let dir = tmp.path().join("sessions");
1999 for persistence in [SessionPersistence::Json, SessionPersistence::Journal] {
2000 let mut s = Session::new("m", "medium", None);
2001 s.api_messages.push(std::sync::Arc::new(
2002 serde_json::json!({"role": "user", "content": "q"}),
2003 ));
2004 s.total_input_tokens = 100;
2005 s.total_output_tokens = 10;
2006 s.total_cache_read_tokens = 85_679;
2007 s.total_cache_creation_tokens = 6_250;
2008 save_session_in_dir(&dir, &s, persistence).unwrap();
2009 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2010 assert_eq!(loaded.total_cache_read_tokens, 85_679, "{persistence:?}");
2011 assert_eq!(loaded.total_cache_creation_tokens, 6_250, "{persistence:?}");
2012 assert_eq!(loaded.total_input_tokens, 100, "{persistence:?}");
2013 }
2014 }
2015
2016 #[test]
2019 fn cache_totals_are_optional_in_the_snapshot() {
2020 let s = Session::new("m", "medium", None);
2021 let value = serde_json::to_value(&s).unwrap();
2022 assert!(value.get("total_cache_read_tokens").is_none());
2023 assert!(value.get("total_cache_creation_tokens").is_none());
2024 let legacy: Session = serde_json::from_value(value).unwrap();
2025 assert_eq!(legacy.total_cache_read_tokens, 0);
2026 assert_eq!(legacy.total_cache_creation_tokens, 0);
2027 }
2028
2029 #[test]
2030 fn keepwarm_labels_clamp_under_the_totals_on_load() {
2031 let tmp = tempfile::TempDir::new().unwrap();
2032 let dir = tmp.path().join("sessions");
2033 let mut s = Session::new("m", "medium", None);
2034 s.api_messages.push(std::sync::Arc::new(
2035 serde_json::json!({"role": "user", "content": "q"}),
2036 ));
2037 s.session_cost = 0.25;
2038 s.total_input_tokens = 3;
2039 s.total_output_tokens = 1;
2040 s.keepwarm_cost = 0.75; s.keepwarm_tokens.input = 10;
2042 s.keepwarm_tokens.output = 10;
2043 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
2044 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2045 assert_eq!(loaded.keepwarm_cost, 0.25);
2046 assert_eq!(loaded.keepwarm_tokens.input, 3);
2047 assert_eq!(loaded.keepwarm_tokens.output, 1);
2048 }
2049
2050 fn tagged(text: &str, id: &str) -> SharedMessage {
2053 std::sync::Arc::new(serde_json::json!({
2054 "role": "user", "content": text, MESSAGE_TURN_KEY: {"id": id}
2055 }))
2056 }
2057
2058 fn assert_no_host_keys(m: &serde_json::Value, ctx: &str) {
2059 let keys: Vec<_> = m
2060 .as_object()
2061 .map(|o| o.keys().filter(|k| k.starts_with("_synaps_turn")).cloned().collect())
2062 .unwrap_or_default();
2063 assert!(keys.is_empty(), "{ctx}: persisted message carries {keys:?}: {m}");
2064 }
2065
2066 fn old_reader_view(dir: &Path, id: &str) -> (Vec<serde_json::Value>, Option<String>) {
2071 let snap: Session =
2072 serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
2073 .unwrap();
2074 let mut messages: Vec<_> = snap.api_messages.iter().map(|m| (**m).clone()).collect();
2075 let mut hash = None;
2076 if let Ok(raw) = std::fs::read_to_string(journal_path(dir, id)) {
2077 for line in raw.lines() {
2078 match serde_json::from_str::<JournalRecordV0_10_1>(line)
2079 .unwrap_or_else(|e| panic!("0.10.2 cannot parse {line}: {e}"))
2080 {
2081 JournalRecordV0_10_1::Msg { i, m, .. } if i == messages.len() => {
2082 messages.push(m)
2083 }
2084 JournalRecordV0_10_1::Open { history_hash, .. }
2085 | JournalRecordV0_10_1::Meta { history_hash, .. } => hash = history_hash,
2086 _ => {}
2087 }
2088 }
2089 }
2090 (messages, hash)
2091 }
2092
2093 #[test]
2094 fn turn_ids_are_persisted_beside_messages_not_inside_them() {
2095 for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
2096 let tmp = tempfile::TempDir::new().unwrap();
2097 let dir = tmp.path().join("sessions");
2098 let mut s = Session::new("m", "medium", None);
2099 s.api_messages.push(tagged("first", "t-1"));
2100 push_message(&mut s, "reply");
2101 save_session_in_dir(&dir, &s, mode).unwrap();
2102 s.api_messages.push(tagged("second", "t-2"));
2103 let receipt = save_session_in_dir(&dir, &s, mode).unwrap();
2104 if mode == SessionPersistence::Journal {
2105 assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }));
2106 }
2107
2108 let (old, hash) = old_reader_view(&dir, &s.id);
2109 assert_eq!(old.len(), 3, "{mode:?}");
2110 for m in &old {
2111 assert_no_host_keys(m, &format!("{mode:?}"));
2112 }
2113 if mode == SessionPersistence::Journal {
2114 let old: Vec<SharedMessage> =
2117 old.into_iter().map(std::sync::Arc::new).collect();
2118 assert_eq!(hash, Some(history_hash(&old).unwrap()), "{mode:?}");
2119 }
2120
2121 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2123 assert_eq!(
2124 serde_json::to_value(&loaded.api_messages).unwrap(),
2125 serde_json::to_value(&s.api_messages).unwrap(),
2126 "{mode:?}"
2127 );
2128 }
2129 }
2130
2131 fn dev_build_hash(messages: &[SharedMessage]) -> String {
2133 use sha2::{Digest, Sha256};
2134 format!("{:x}", Sha256::digest(serde_json::to_vec(messages).unwrap()))
2135 }
2136
2137 #[test]
2138 fn inline_turn_ids_from_earlier_dev_builds_still_load_and_migrate() {
2139 let tmp = tempfile::TempDir::new().unwrap();
2140 let dir = tmp.path().join("sessions");
2141 let mut s = Session::new("m", "medium", None);
2142 s.api_messages.push(tagged("first", "t-1"));
2143 let id = s.id.clone();
2145 let generation = "g-1";
2146 let mut snap = serde_json::to_value(&s).unwrap();
2147 snap["_journal_generation"] = generation.into();
2148 snap["message_count"] = 1.into();
2149 write_json_snapshot(&dir, &id, snap.to_string().as_bytes()).unwrap();
2150 let inline_hash = dev_build_hash(&s.api_messages);
2151 s.api_messages.push(tagged("second", "t-2"));
2152 let mut journal = String::new();
2153 for record in [
2154 serde_json::json!({"k":"open","v":2,"base":1,"generation":generation,"history_hash":inline_hash}),
2155 serde_json::json!({"k":"msg","v":2,"i":1,"m":&*s.api_messages[1]}),
2156 serde_json::json!({"k":"meta","v":2,"meta":SessionMeta::of(&s),
2157 "history_hash":dev_build_hash(&s.api_messages)}),
2158 ] {
2159 journal.push_str(&record.to_string());
2160 journal.push('\n');
2161 }
2162 std::fs::write(journal_path(&dir, &id), journal).unwrap();
2163
2164 let loaded = load_session_in_dir(&dir, &id).unwrap();
2165 assert_eq!(
2166 serde_json::to_value(&loaded.api_messages).unwrap(),
2167 serde_json::to_value(&s.api_messages).unwrap()
2168 );
2169 let receipt = save_session_in_dir(&dir, &loaded, SessionPersistence::Journal).unwrap();
2171 assert!(matches!(receipt.mode, SaveMode::FullSnapshot));
2172 for m in old_reader_view(&dir, &id).0 {
2173 assert_no_host_keys(&m, "migrated");
2174 }
2175 let again = load_session_in_dir(&dir, &id).unwrap();
2176 assert_eq!(
2177 serde_json::to_value(&again.api_messages).unwrap(),
2178 serde_json::to_value(&s.api_messages).unwrap()
2179 );
2180 }
2181
2182 #[test]
2183 fn tagging_an_already_saved_message_forces_a_snapshot() {
2184 let tmp = tempfile::TempDir::new().unwrap();
2185 let dir = tmp.path().join("sessions");
2186 let mut s = with_messages(2);
2187 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2188 s.api_messages[1] = tagged("old 1", "late");
2189 let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2190 assert!(matches!(receipt.mode, SaveMode::FullSnapshot));
2191 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2192 assert_eq!(loaded.api_messages[1][MESSAGE_TURN_KEY]["id"], "late");
2193 }
2194
2195 #[test]
2198 fn rollback_round_trip_through_a_0_10_2_append() {
2199 let tmp = tempfile::TempDir::new().unwrap();
2200 let dir = tmp.path().join("sessions");
2201 let mut s = Session::new("m", "medium", None);
2202 s.api_messages.push(tagged("first", "t-1"));
2203 push_message(&mut s, "reply");
2204 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2205 s.api_messages.push(tagged("second", "t-2"));
2206 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2207
2208 let (old, hash) = old_reader_view(&dir, &s.id);
2212 let mut old: Vec<SharedMessage> = old.into_iter().map(std::sync::Arc::new).collect();
2213 assert_eq!(hash, Some(history_hash(&old).unwrap()));
2214 old.push(std::sync::Arc::new(
2215 serde_json::json!({"role": "user", "content": "asked on 0.10.2"}),
2216 ));
2217 let mut old_session = s.clone();
2218 old_session.api_messages = old.clone();
2219 old_session.updated_at += chrono::Duration::minutes(1);
2220 let mut journal = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
2221 for record in [
2222 serde_json::json!({"k":"msg","v":2,"i":3,"m":&*old[3]}),
2223 serde_json::json!({"k":"meta","v":2,"meta":SessionMeta::of(&old_session),
2224 "history_hash":history_hash(&old).unwrap()}),
2225 ] {
2226 journal.push_str(&record.to_string());
2227 journal.push('\n');
2228 }
2229 std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
2230
2231 let mut loaded = load_session_in_dir(&dir, &s.id).unwrap();
2233 assert_eq!(loaded.api_messages.len(), 4);
2234 let ids: Vec<_> = loaded.api_messages.iter().map(|m| message_turn_id(m)).collect();
2235 assert_eq!(ids, vec![Some("t-1"), None, Some("t-2"), None]);
2236 assert_eq!(loaded.api_messages[3]["content"], "asked on 0.10.2");
2237 let receipt = save_session_in_dir(&dir, &loaded, SessionPersistence::Journal).unwrap();
2239 assert!(matches!(receipt.mode, SaveMode::FullSnapshot), "{:?}", receipt.mode);
2240 loaded.api_messages.push(tagged("third", "t-3"));
2241 let receipt = save_session_in_dir(&dir, &loaded, SessionPersistence::Journal).unwrap();
2242 assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }), "{:?}", receipt.mode);
2243 let again = load_session_in_dir(&dir, &s.id).unwrap();
2244 assert_eq!(
2245 serde_json::to_value(&again.api_messages).unwrap(),
2246 serde_json::to_value(&loaded.api_messages).unwrap()
2247 );
2248 }
2249
2250 #[test]
2251 fn a_malformed_journal_turn_id_does_not_end_replay() {
2252 let tmp = tempfile::TempDir::new().unwrap();
2253 let dir = tmp.path().join("sessions");
2254 let mut s = with_messages(1);
2255 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2256 s.api_messages.push(tagged("second", "t-2"));
2257 push_message(&mut s, "third");
2258 save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2259 let path = journal_path(&dir, &s.id);
2260 let raw = std::fs::read_to_string(&path).unwrap();
2261 assert!(raw.contains(r#""t":"t-2""#));
2262 std::fs::write(&path, raw.replace(r#""t":"t-2""#, r#""t":{"bad":1}"#)).unwrap();
2263 let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2264 assert_eq!(loaded.api_messages.len(), 3, "replay continued past the bad id");
2265 assert_eq!(message_turn_id(&loaded.api_messages[1]), None);
2266 }
2267
2268 #[test]
2271 fn malformed_turn_ids_are_dropped_not_fatal() {
2272 for bad in [
2273 serde_json::json!("garbage"),
2274 serde_json::json!([[0, "t-1"], ["x", 1], [99, "past-the-end"], 7]),
2275 ] {
2276 let tmp = tempfile::TempDir::new().unwrap();
2277 let dir = tmp.path().join("sessions");
2278 let mut s = Session::new("m", "medium", None);
2279 s.api_messages.push(tagged("first", "t-1"));
2280 push_message(&mut s, "reply");
2281 save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
2282 let path = dir.join(format!("{}.json", s.id));
2283 let mut snap: serde_json::Value =
2284 serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
2285 snap["_turn_ids"] = bad.clone();
2286 std::fs::write(&path, snap.to_string()).unwrap();
2287 let loaded = load_session_in_dir(&dir, &s.id)
2288 .unwrap_or_else(|e| panic!("{bad}: session not loadable: {e}"));
2289 assert_eq!(loaded.api_messages.len(), 2, "{bad}");
2290 let well_formed = bad.as_array().is_some();
2291 assert_eq!(
2292 message_turn_id(&loaded.api_messages[0]),
2293 well_formed.then_some("t-1"),
2294 "{bad}: valid entries survive"
2295 );
2296 }
2297 }
2298}