Skip to main content

agent_core/core/
session_journal.rs

1//! Session snapshots with opt-in delta journals and durable checkpoints.
2//!
3//! New journal snapshots carry a storage-only `_journal_generation` UUID;
4//! v2 journals replay ONLY against that exact generation. Publishing a new
5//! snapshot is the logical commit, even if a crash leaves the old journal.
6//! Unmarked legacy snapshots still accept v1 journals. Their pre-migration
7//! crash ambiguity cannot be repaired retrospectively. Old binaries can read
8//! the Session-shaped snapshot but cannot replay v2 deltas; downgrade/export
9//! should first fold the journal with a JSON save using this implementation.
10//!
11//! Normal JSON saves retain the legacy bytes when no journal/generation has
12//! ever been present. Once bound, all snapshot replacements (including JSON
13//! saves) rotate the generation so leftover journals cannot resurrect history.
14//! Metadata is not a Session field: serde exports/mirrors ignore it. Stripping
15//! it loses v2 deltas, rather than applying a potentially unrelated journal.
16//!
17//! Journal appends write O(delta) bytes; a streaming history hash checks the
18//! entire saved prefix for edits (O(history) CPU, no history-sized allocation).
19//! Journal-bound snapshot rotation syncs the snapshot directory before removing
20//! old deltas; JSON-only normal saves retain their prior best-effort behavior.
21//! Durable saves always publish a full snapshot and sync through cleanup.
22//! Errors can follow logical commit;
23//! callers must stop and reload/retry, not assume rollback. All saves of one
24//! session must be externally serialized; no multi-writer transaction is offered.
25//!
26//! Private 0700/0600, no-symlink handle-relative I/O is preserved on Unix.
27//! Normal saves retain the documented non-Unix best effort; durable saves
28//! fail closed there rather than pretend directory fsync is supported.
29
30use 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
37/// Journal record schema version (line-level `"v"` field).
38pub const JOURNAL_SCHEMA_VERSION: u8 = 2;
39
40/// Journal size floor before a periodic snapshot is forced.
41pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
42
43/// A snapshot is due when the journal outgrows `snapshot / RATIO` (bounded
44/// write amplification: large sessions stretch the threshold proportionally).
45pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
46
47/// Bounded tail window scanned for the freshest `meta` record.
48const META_TAIL_WINDOW: u64 = 64 * 1024;
49
50/// Hard cap on any single persisted-session artifact read (snapshot or
51/// journal). Reads are bounded on the OPENED handle; an artifact past the
52/// cap is refused rather than slurped.
53pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB
54
55// ─── confined resolution (fix1 I1 + fix2) ───────────────────────────────────
56//
57// STRICT TRUSTED-ROOT SEMANTICS (fix2): the sessions directory path is
58// resolved with EVERY component — ancestors AND the final one — opened
59// handle-relatively from `/` with symlinks refused
60// (`ConfinedDir::{open,create}_absolute_no_symlinks`; Linux uses one atomic
61// `openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS` from the root handle).
62// Nothing on the path is trusted; there is no check-then-open race — a
63// component swapped to a symlink at any moment fails the open itself.
64// Artifacts inside the directory are then opened/written/removed relative
65// to that ONE handle. Operators whose base dir legitimately sits behind
66// ancestor symlinks (e.g. `/home` → `var/home`) must point
67// `SYNAPS_BASE_DIR` at the canonical path.
68//
69// Non-unix keeps the crate's documented best-effort pathname fallback
70// (final-component symlink refusal only).
71
72#[cfg(unix)]
73type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
74
75/// Open the sessions dir strictly. `Ok(None)` when a path component does
76/// not exist; symlinks anywhere are errors.
77#[cfg(unix)]
78fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
79    match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
80        Ok(handle) => Ok(Some(handle)),
81        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
82        Err(e) => Err(e),
83    }
84}
85
86/// Create-or-open the sessions dir strictly (0700 leaf).
87#[cfg(unix)]
88fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
89    crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)
90}
91
92/// Open `<name>` inside an already-strictly-opened sessions dir.
93/// `Ok(None)` when the file does not exist; a symlinked artifact errors.
94#[cfg(unix)]
95fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
96    match handle.open_file(&[name.to_string()]) {
97        Ok(file) => Ok(Some(file)),
98        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
99        Err(e) => Err(e),
100    }
101}
102
103/// One-shot strict open of `<name>` inside `dir`.
104#[cfg(unix)]
105pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
106    let Some(handle) = open_sessions_dir(dir)? else {
107        return Ok(None);
108    };
109    open_artifact(&handle, name)
110}
111
112// ── Non-unix best-effort fallbacks (documented, matching `private_fs`) ──
113
114#[cfg(not(unix))]
115struct SessionsDirHandle {
116    dir: std::path::PathBuf,
117}
118
119#[cfg(not(unix))]
120fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
121    if !dir.exists() {
122        return Ok(None);
123    }
124    Ok(Some(SessionsDirHandle {
125        dir: dir.to_path_buf(),
126    }))
127}
128
129#[cfg(not(unix))]
130fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
131    crate::core::private_fs::ensure_private_dir(dir)?;
132    Ok(SessionsDirHandle {
133        dir: dir.to_path_buf(),
134    })
135}
136
137#[cfg(not(unix))]
138impl SessionsDirHandle {
139    fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
140        crate::core::private_fs::write_atomic_private(&self.dir.join(name), data)
141            .map_err(std::io::Error::other)
142    }
143    fn append_file(&self, name: &str) -> std::io::Result<std::fs::File> {
144        crate::core::private_fs::open_private_append(&self.dir.join(name))
145            .map_err(std::io::Error::other)
146    }
147    fn remove_file(&self, name: &str) -> std::io::Result<()> {
148        std::fs::remove_file(self.dir.join(name))
149    }
150}
151
152#[cfg(not(unix))]
153fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
154    let path = handle.dir.join(name);
155    match std::fs::symlink_metadata(&path) {
156        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
157        Ok(meta) if meta.file_type().is_symlink() => {
158            return Err(std::io::Error::other(format!(
159                "refusing symlinked session artifact {name:?}"
160            )))
161        }
162        _ => {}
163    }
164    match std::fs::File::open(&path) {
165        Ok(f) => Ok(Some(f)),
166        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
167        Err(e) => Err(e),
168    }
169}
170
171#[cfg(not(unix))]
172pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
173    let Some(handle) = open_sessions_dir(dir)? else {
174        return Ok(None);
175    };
176    open_artifact(&handle, name)
177}
178
179/// Bounded read of a whole artifact from an already-confined handle.
180fn read_artifact_bytes(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<Vec<u8>>> {
181    use std::io::Read;
182    let Some(file) = open_artifact(handle, name)? else {
183        return Ok(None);
184    };
185    let mut buf = Vec::new();
186    file.take(MAX_PERSISTED_READ_BYTES + 1)
187        .read_to_end(&mut buf)?;
188    if buf.len() as u64 > MAX_PERSISTED_READ_BYTES {
189        return Err(std::io::Error::other(format!(
190            "session artifact {name:?} exceeds the {MAX_PERSISTED_READ_BYTES}-byte read bound"
191        )));
192    }
193    Ok(Some(buf))
194}
195
196/// One strictly-resolved directory entry for session listings.
197#[derive(Debug, Clone)]
198pub struct SessionDirEntry {
199    pub name: String,
200    pub mtime: Option<std::time::SystemTime>,
201    /// Stored bytes for metadata-only accounting. Obtained from the directory
202    /// entry stat; reading it never opens session content.
203    pub byte_len: u64,
204}
205
206/// Enumerate the sessions dir through the SAME strict handle resolution as
207/// every other T35 operation (fix2): handle-relative `readdir` +
208/// `fstatat(AT_SYMLINK_NOFOLLOW)` — a symlinked ancestor refuses, a
209/// missing dir lists empty, and entry mtimes are of the entries
210/// themselves, never symlink targets.
211pub fn session_dir_entries(dir: &Path) -> std::io::Result<Vec<SessionDirEntry>> {
212    #[cfg(unix)]
213    {
214        let Some(handle) = open_sessions_dir(dir)? else {
215            return Ok(Vec::new());
216        };
217        Ok(handle
218            .entries()?
219            .into_iter()
220            .filter(|e| e.is_file)
221            .map(|e| SessionDirEntry {
222                name: e.name,
223                mtime: e.mtime_unix_ms.map(|ms| {
224                    std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(ms)
225                }),
226                byte_len: e.byte_len,
227            })
228            .collect())
229    }
230    #[cfg(not(unix))]
231    {
232        if !dir.exists() {
233            return Ok(Vec::new());
234        }
235        let mut out = Vec::new();
236        for entry in std::fs::read_dir(dir)? {
237            let entry = entry?;
238            if !entry.path().is_file() {
239                continue;
240            }
241            let Ok(name) = entry.file_name().into_string() else {
242                continue;
243            };
244            let byte_len = entry
245                .metadata()
246                .map(|metadata| metadata.len())
247                .unwrap_or_default();
248            out.push(SessionDirEntry {
249                name,
250                mtime: entry.metadata().and_then(|m| m.modified()).ok(),
251                byte_len,
252            });
253        }
254        Ok(out)
255    }
256}
257
258/// Which on-disk persistence strategy `Session::save` uses.
259///
260/// `Json` (the default) writes a full Session-shaped snapshot. `Journal` is
261/// the spec §9.8 opt-in and is only ever selected explicitly via the
262/// `session_persistence = journal` config key.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
264pub enum SessionPersistence {
265    #[default]
266    Json,
267    Journal,
268}
269
270impl SessionPersistence {
271    /// Parse a config value; anything unrecognized yields `None` so the
272    /// caller keeps the safe default and surfaces a typed warning.
273    pub fn parse(val: &str) -> Option<Self> {
274        match val.trim() {
275            "json" => Some(Self::Json),
276            "journal" => Some(Self::Journal),
277            _ => None,
278        }
279    }
280}
281
282/// How a save landed on disk.
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum SaveMode {
285    /// Full Session-shaped snapshot written (and, in journal mode, the
286    /// journal reset to a lone `open` record).
287    FullSnapshot,
288    /// Delta append: `messages` new history entries plus one meta record.
289    Append { messages: usize },
290}
291
292/// Machine-readable receipt for benchmarks and delta-proportionality tests.
293#[derive(Debug, Clone, Copy)]
294pub struct SaveReceipt {
295    pub mode: SaveMode,
296    pub bytes_written: u64,
297}
298
299/// Freshest journaled metadata, read from a bounded tail window — lets
300/// session listings stay accurate without parsing the full journal.
301#[derive(Debug, Clone)]
302pub struct JournalMetaTail {
303    pub updated_at: DateTime<Utc>,
304    pub session_cost: f64,
305    /// `None` for meta records written before the count was journaled.
306    pub message_count: Option<usize>,
307}
308
309/// `sessions/<id>.journal` — single-extension name so the session id stays
310/// the file stem (chain-head protection and retention pair on the stem).
311pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
312    dir.join(format!("{id}.journal"))
313}
314
315/// Snapshot threshold: journal bytes ≥ max(256 KiB, snapshot / 4).
316pub fn snapshot_due(journal_bytes: u64, snapshot_bytes: u64) -> bool {
317    journal_bytes >= JOURNAL_SNAPSHOT_MIN_BYTES.max(snapshot_bytes / JOURNAL_SNAPSHOT_RATIO)
318}
319
320// ─── journal records ─────────────────────────────────────────────────────────
321
322#[derive(Serialize, Deserialize)]
323#[serde(tag = "k")]
324enum JournalRecord {
325    /// First line of every journal: the snapshot held `base` messages.
326    #[serde(rename = "open")]
327    Open {
328        v: u8,
329        base: usize,
330        #[serde(default, skip_serializing_if = "Option::is_none")]
331        generation: Option<String>,
332        #[serde(default, skip_serializing_if = "Option::is_none")]
333        history_hash: Option<String>,
334    },
335    /// Message at ABSOLUTE history index `i`.
336    #[serde(rename = "msg")]
337    Msg {
338        v: u8,
339        i: usize,
340        m: serde_json::Value,
341    },
342    /// Full session metadata (the `Session` object minus `api_messages`).
343    #[serde(rename = "meta")]
344    Meta {
345        v: u8,
346        meta: Box<SessionMeta>,
347        #[serde(default, skip_serializing_if = "Option::is_none")]
348        history_hash: Option<String>,
349    },
350}
351
352fn is_zero_cost(v: &f64) -> bool {
353    *v == 0.0
354}
355
356/// `Session` fields that live ONLY in the `.json` snapshot and are never
357/// journaled. `SessionMeta` is `deny_unknown_fields` in every shipped build
358/// (0.10.1 included): a new field in a meta record makes those builds read
359/// it as a torn tail, truncate the loaded history, and then overwrite the
360/// journal with that truncation on the next save. The snapshot `Session`
361/// tolerates unknown fields, so new metadata goes there instead.
362#[cfg_attr(not(test), allow(dead_code))]
363const SNAPSHOT_ONLY_FIELDS: &[&str] = &["keepwarm_cost", "keepwarm_tokens"];
364
365/// Mirror of [`Session`] WITHOUT `api_messages` and the
366/// [`SNAPSHOT_ONLY_FIELDS`], with identical serde attributes.
367/// `deny_unknown_fields` + the `session_meta_stays_in_sync_…` test make
368/// silent drift between the two types impossible. Do NOT add fields here:
369/// older builds reject them (see [`SNAPSHOT_ONLY_FIELDS`]).
370#[derive(Clone, Serialize, Deserialize)]
371#[serde(deny_unknown_fields)]
372struct SessionMeta {
373    id: String,
374    title: String,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    name: Option<String>,
377    model: String,
378    thinking_level: String,
379    system_prompt: Option<String>,
380    created_at: DateTime<Utc>,
381    updated_at: DateTime<Utc>,
382    total_input_tokens: u64,
383    total_output_tokens: u64,
384    session_cost: f64,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    message_count: Option<usize>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    abort_context: Option<String>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    parent_session: Option<String>,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    compacted_into: Option<String>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    env: Option<Vec<(String, String)>>,
395    #[serde(default, skip_serializing_if = "Vec::is_empty")]
396    env_stripped: Vec<String>,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    prompt_provenance: Option<crate::prompt::PromptProvenance>,
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    compaction: Option<crate::core::compaction::CompactionRecord>,
401}
402
403impl SessionMeta {
404    fn of(s: &Session) -> Self {
405        // Belt-and-braces: scrub any secret values from the env on the
406        // write path so the journal on disk NEVER contains a credential,
407        // even if a future client forgets to strip.
408        let env = scrub_secret_env(s.env.as_ref());
409        Self {
410            id: s.id.clone(),
411            title: s.title.clone(),
412            name: s.name.clone(),
413            model: s.model.clone(),
414            thinking_level: s.thinking_level.clone(),
415            system_prompt: s.system_prompt.clone(),
416            created_at: s.created_at,
417            updated_at: s.updated_at,
418            total_input_tokens: s.total_input_tokens,
419            total_output_tokens: s.total_output_tokens,
420            session_cost: s.session_cost,
421            message_count: Some(s.api_messages.len()),
422            abort_context: s.abort_context.clone(),
423            parent_session: s.parent_session.clone(),
424            compacted_into: s.compacted_into.clone(),
425            env,
426            env_stripped: s.env_stripped.clone(),
427            prompt_provenance: s.prompt_provenance.clone(),
428            compaction: s.compaction.clone(),
429        }
430    }
431
432    /// Apply onto a loaded session. Only metadata moves — never history.
433    fn apply(self, s: &mut Session) {
434        s.title = self.title;
435        s.name = self.name;
436        s.model = self.model;
437        s.thinking_level = self.thinking_level;
438        s.system_prompt = self.system_prompt;
439        s.created_at = self.created_at;
440        s.updated_at = self.updated_at;
441        s.total_input_tokens = self.total_input_tokens;
442        s.total_output_tokens = self.total_output_tokens;
443        s.session_cost = self.session_cost;
444        s.abort_context = self.abort_context;
445        s.parent_session = self.parent_session;
446        s.compacted_into = self.compacted_into;
447        s.env = self.env;
448        s.env_stripped = self.env_stripped;
449        s.prompt_provenance = self.prompt_provenance;
450        s.compaction = self.compaction;
451    }
452}
453
454/// A binding is deliberately outside Session/SessionMeta. It is emitted
455/// before `api_messages`, so listings and append checks need only the header.
456#[derive(Default, Deserialize)]
457struct SnapshotBinding {
458    #[serde(default, rename = "_journal_generation")]
459    generation: Option<String>,
460}
461
462fn snapshot_binding(file: std::fs::File) -> std::io::Result<SnapshotBinding> {
463    let header = crate::core::session::read_session_header_from_file(file)
464        .ok_or_else(|| std::io::Error::other("cannot read session snapshot header"))?;
465    serde_json::from_str(&header).map_err(std::io::Error::other)
466}
467
468/// v1 is interpretable only against an unmarked legacy snapshot. A bound
469/// snapshot must never fall back to v1, even for an empty/torn v2 journal.
470fn matching_open(record: &JournalRecord, snapshot_generation: Option<&str>) -> Option<u8> {
471    match record {
472        JournalRecord::Open {
473            v: 1,
474            generation: None,
475            ..
476        } if snapshot_generation.is_none() => Some(1),
477        JournalRecord::Open {
478            v,
479            generation: Some(generation),
480            ..
481        } if *v == JOURNAL_SCHEMA_VERSION
482            && !generation.is_empty()
483            && Some(generation.as_str()) == snapshot_generation =>
484        {
485            Some(*v)
486        }
487        _ => None,
488    }
489}
490
491/// Hash the exact ordered prefix, including all message fields. Unlike the
492/// old last-message tripwire this detects same-length edits anywhere, even
493/// when the journal contains only its open record. Uses the existing sha2 dep.
494fn history_hash(messages: &[SharedMessage]) -> std::io::Result<String> {
495    use sha2::{Digest, Sha256};
496    struct HashWriter(Sha256);
497    impl Write for HashWriter {
498        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
499            self.0.update(bytes);
500            Ok(bytes.len())
501        }
502        fn flush(&mut self) -> std::io::Result<()> {
503            Ok(())
504        }
505    }
506    let mut writer = HashWriter(Sha256::new());
507    serde_json::to_writer(&mut writer, messages).map_err(std::io::Error::other)?;
508    Ok(format!("{:x}", writer.0.finalize()))
509}
510
511/// Only clean, generation-matched v2 journals can be appended to. Legacy
512/// saves migrate through a full snapshot; torn/gapped tails resnapshot too.
513struct JournalState {
514    durable_len: usize,
515    history_hash: String,
516    bytes: u64,
517}
518
519fn read_journal_state(
520    handle: &SessionsDirHandle,
521    id: &str,
522    generation: Option<&str>,
523) -> std::io::Result<Option<JournalState>> {
524    let Some(raw) = read_artifact_bytes(handle, &format!("{id}.journal"))? else {
525        return Ok(None);
526    };
527    if !raw.ends_with(b"\n") {
528        return Ok(None); // appending after an unterminated line would hide data
529    }
530    let Ok(text) = std::str::from_utf8(&raw) else {
531        return Ok(None);
532    };
533    let mut lines = text.lines();
534    let Some(open) = lines
535        .next()
536        .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
537    else {
538        return Ok(None);
539    };
540    if matching_open(&open, generation) != Some(JOURNAL_SCHEMA_VERSION) {
541        return Ok(None);
542    }
543    let JournalRecord::Open {
544        base,
545        history_hash: Some(mut hash),
546        ..
547    } = open
548    else {
549        return Ok(None);
550    };
551    let mut durable_len = base;
552    let mut hashed_len = base;
553    for line in lines {
554        match serde_json::from_str::<JournalRecord>(line) {
555            Ok(JournalRecord::Msg { v, i, .. })
556                if v == JOURNAL_SCHEMA_VERSION && i == durable_len =>
557            {
558                durable_len += 1;
559            }
560            Ok(JournalRecord::Meta {
561                v,
562                meta,
563                history_hash: Some(next_hash),
564            }) if v == JOURNAL_SCHEMA_VERSION
565                && meta.id == id
566                && meta.message_count == Some(durable_len) =>
567            {
568                hash = next_hash;
569                hashed_len = durable_len;
570            }
571            _ => return Ok(None),
572        }
573    }
574    if hashed_len != durable_len {
575        return Ok(None); // messages without a complete committing meta record
576    }
577    Ok(Some(JournalState {
578        durable_len,
579        history_hash: hash,
580        bytes: raw.len() as u64,
581    }))
582}
583
584// ─── save ────────────────────────────────────────────────────────────────────
585
586/// Persist under the configured mode. Journal-bound replacement syncs the
587/// snapshot directory before discarding old deltas; normal JSON-only saves do
588/// not promise directory durability. Use [`save_session_durable_in_dir`] for a
589/// full context-head barrier. Snapshot replacements are generation-safe in both.
590pub fn save_session_in_dir(
591    dir: &Path,
592    session: &Session,
593    mode: SessionPersistence,
594) -> std::io::Result<SaveReceipt> {
595    let handle = create_sessions_dir(dir)?;
596    match mode {
597        SessionPersistence::Json => {
598            // Keep virgin JSON-only saves byte-compatible. Once a journal or
599            // generation exists, never publish an unmarked snapshot again.
600            let journal = open_artifact(&handle, &format!("{}.journal", session.id))?;
601            let bound =
602                open_artifact(&handle, &format!("{}.json", session.id))?.is_some_and(|file| {
603                    // An unreadable/oversized header must not prevent repair
604                    // by a full save; conservatively rotate the generation.
605                    snapshot_binding(file).map_or(true, |b| b.generation.is_some())
606                });
607            publish_snapshot(&handle, session, mode, false, journal.is_some() || bound)
608        }
609        SessionPersistence::Journal => save_journal_mode(&handle, session),
610    }
611}
612
613/// Blocking body of `Session::save_durable`: a full generation-bound snapshot
614/// in either mode. Fsync the file, rename it, fsync the directory, then clean
615/// up the journal and fsync the directory again. Snapshot rename is the logical
616/// commit; any later error is returned without rollback. Retry/reload is safe
617/// with stale journals. Concurrent saves of the same session are NOT supported.
618pub fn save_session_durable_in_dir(
619    dir: &Path,
620    session: &Session,
621    mode: SessionPersistence,
622) -> std::io::Result<SaveReceipt> {
623    #[cfg(unix)]
624    {
625        let handle = SessionsDirHandle::create_absolute_no_symlinks_durable(dir)?;
626        publish_snapshot(&handle, session, mode, true, true)
627    }
628    #[cfg(not(unix))]
629    {
630        let _ = (dir, session, mode);
631        Err(std::io::Error::new(
632            std::io::ErrorKind::Unsupported,
633            "durable session checkpoints require Unix directory fsync",
634        ))
635    }
636}
637
638fn save_journal_mode(
639    handle: &SessionsDirHandle,
640    session: &Session,
641) -> std::io::Result<SaveReceipt> {
642    let journal_name = format!("{}.journal", session.id);
643
644    let snapshot = open_artifact(handle, &format!("{}.json", session.id))?;
645    let Some(snapshot) = snapshot else {
646        return full_snapshot_reset(handle, session);
647    };
648    let snapshot_bytes = snapshot.metadata()?.len();
649    let Ok(binding) = snapshot_binding(snapshot) else {
650        return full_snapshot_reset(handle, session);
651    };
652    let Some(state) = read_journal_state(handle, &session.id, binding.generation.as_deref())?
653    else {
654        return full_snapshot_reset(handle, session);
655    };
656
657    if session.api_messages.len() < state.durable_len
658        || history_hash(&session.api_messages[..state.durable_len])? != state.history_hash
659    {
660        return full_snapshot_reset(handle, session);
661    }
662
663    // Delta append: new messages (absolute indices) + one meta record.
664    let mut buf = Vec::new();
665    for (offset, msg) in session.api_messages[state.durable_len..].iter().enumerate() {
666        let record = JournalRecord::Msg {
667            v: JOURNAL_SCHEMA_VERSION,
668            i: state.durable_len + offset,
669            m: msg.as_ref().clone(),
670        };
671        serde_json::to_writer(&mut buf, &record).map_err(std::io::Error::other)?;
672        buf.push(b'\n');
673    }
674    let meta = JournalRecord::Meta {
675        v: JOURNAL_SCHEMA_VERSION,
676        meta: Box::new(SessionMeta::of(session)),
677        history_hash: Some(history_hash(&session.api_messages)?),
678    };
679    serde_json::to_writer(&mut buf, &meta).map_err(std::io::Error::other)?;
680    buf.push(b'\n');
681
682    let appended = session.api_messages.len() - state.durable_len;
683    let mut file = handle.append_file(&journal_name)?;
684    file.write_all(&buf)?;
685    file.sync_data()?;
686    drop(file);
687
688    // Periodic snapshot: its fresh generation invalidates the old journal
689    // at publication, before the journal reset can occur.
690    let journal_bytes = state.bytes + buf.len() as u64;
691    if snapshot_due(journal_bytes, snapshot_bytes) {
692        let reset = full_snapshot_reset(handle, session)?;
693        return Ok(SaveReceipt {
694            mode: SaveMode::FullSnapshot,
695            bytes_written: buf.len() as u64 + reset.bytes_written,
696        });
697    }
698
699    Ok(SaveReceipt {
700        mode: SaveMode::Append { messages: appended },
701        bytes_written: buf.len() as u64,
702    })
703}
704
705/// Borrowing mirror of [`Session`] used ONLY for snapshot serialization:
706/// identical field order and serde attributes, with `message_count`
707/// computed from `api_messages.len()` at write time instead of read from
708/// the (non-authoritative) in-memory field. Field order matters —
709/// `message_count` must precede `api_messages` for `read_session_header`.
710/// The `snapshot_json_matches_session_schema` test guards drift.
711#[derive(Serialize)]
712struct SessionSnapshotRef<'a> {
713    // Storage-only metadata, omitted for legacy JSON-only saves. Session
714    // serde deliberately ignores it; it is not journaled as SessionMeta.
715    #[serde(skip_serializing_if = "Option::is_none")]
716    _journal_generation: Option<&'a str>,
717    id: &'a str,
718    title: &'a str,
719    #[serde(skip_serializing_if = "Option::is_none")]
720    name: &'a Option<String>,
721    model: &'a str,
722    thinking_level: &'a str,
723    system_prompt: &'a Option<String>,
724    created_at: &'a DateTime<Utc>,
725    updated_at: &'a DateTime<Utc>,
726    total_input_tokens: u64,
727    total_output_tokens: u64,
728    session_cost: f64,
729    #[serde(skip_serializing_if = "is_zero_cost")]
730    keepwarm_cost: f64,
731    #[serde(skip_serializing_if = "KeepwarmTokens::is_zero")]
732    keepwarm_tokens: KeepwarmTokens,
733    message_count: usize,
734    #[serde(skip_serializing_if = "Option::is_none")]
735    parent_session: &'a Option<String>,
736    #[serde(skip_serializing_if = "Option::is_none")]
737    compacted_into: &'a Option<String>,
738    /// Owned (not borrowed) because secret keys are scrubbed on the write
739    /// path — the `.json` snapshot must never carry a credential (T5).
740    #[serde(skip_serializing_if = "Option::is_none")]
741    env: Option<Vec<(String, String)>>,
742    #[serde(skip_serializing_if = "<[String]>::is_empty")]
743    env_stripped: &'a [String],
744    api_messages: &'a [SharedMessage],
745    #[serde(skip_serializing_if = "Option::is_none")]
746    abort_context: &'a Option<String>,
747    #[serde(skip_serializing_if = "Option::is_none")]
748    prompt_provenance: &'a Option<crate::prompt::PromptProvenance>,
749    #[serde(skip_serializing_if = "Option::is_none")]
750    compaction: &'a Option<crate::core::compaction::CompactionRecord>,
751}
752
753impl<'a> SessionSnapshotRef<'a> {
754    fn of(s: &'a Session, generation: Option<&'a str>) -> Self {
755        Self {
756            _journal_generation: generation,
757            id: &s.id,
758            title: &s.title,
759            name: &s.name,
760            model: &s.model,
761            thinking_level: &s.thinking_level,
762            system_prompt: &s.system_prompt,
763            created_at: &s.created_at,
764            updated_at: &s.updated_at,
765            total_input_tokens: s.total_input_tokens,
766            total_output_tokens: s.total_output_tokens,
767            session_cost: s.session_cost,
768            keepwarm_cost: s.keepwarm_cost,
769            keepwarm_tokens: s.keepwarm_tokens,
770            message_count: s.api_messages.len(),
771            parent_session: &s.parent_session,
772            compacted_into: &s.compacted_into,
773            env: scrub_secret_env(s.env.as_ref()),
774            env_stripped: &s.env_stripped,
775            api_messages: &s.api_messages,
776            abort_context: &s.abort_context,
777            prompt_provenance: &s.prompt_provenance,
778            compaction: &s.compaction,
779        }
780    }
781}
782
783/// Full-snapshot JSON with a fresh `message_count`, without cloning.
784/// Drop every pair whose key matches the secret denylist OR whose value
785/// carries credentials (a URL with userinfo). Shared by the `.json` snapshot
786/// and the journal meta tail so neither artifact can carry a credential
787/// value, whatever the client sent.
788fn scrub_secret_env(env: Option<&Vec<(String, String)>>) -> Option<Vec<(String, String)>> {
789    use crate::core::config::is_secret_env;
790    env.map(|pairs| {
791        pairs
792            .iter()
793            .filter(|(k, v)| !is_secret_env(k, v))
794            .cloned()
795            .collect()
796    })
797}
798
799/// Full-snapshot JSON with fresh count and optional storage-only binding.
800fn snapshot_json(session: &Session, generation: Option<&str>) -> std::io::Result<String> {
801    serde_json::to_string(&SessionSnapshotRef::of(session, generation))
802        .map_err(std::io::Error::other)
803}
804
805fn full_snapshot_reset(
806    handle: &SessionsDirHandle,
807    session: &Session,
808) -> std::io::Result<SaveReceipt> {
809    publish_snapshot(handle, session, SessionPersistence::Journal, false, true)
810}
811
812fn publish_snapshot(
813    handle: &SessionsDirHandle,
814    session: &Session,
815    mode: SessionPersistence,
816    durable: bool,
817    bind: bool,
818) -> std::io::Result<SaveReceipt> {
819    let generation = bind.then(|| uuid::Uuid::new_v4().to_string());
820    let json = snapshot_json(session, generation.as_deref())?;
821    // Prepare all serialization BEFORE the logical commit.
822    let mut journal = Vec::new();
823    if mode == SessionPersistence::Journal {
824        let open = JournalRecord::Open {
825            v: JOURNAL_SCHEMA_VERSION,
826            base: session.api_messages.len(),
827            generation,
828            history_hash: Some(history_hash(&session.api_messages)?),
829        };
830        serde_json::to_writer(&mut journal, &open).map_err(std::io::Error::other)?;
831        journal.push(b'\n');
832    }
833    // Preflight journal symlinks in either mode, even JSON cleanup (which
834    // otherwise only unlinks). This does not claim multi-writer isolation.
835    open_artifact(handle, &format!("{}.journal", session.id))?;
836    handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
837    // Before destroying the old journal, the new snapshot must be durable.
838    // Binding prevents new-snapshot/old-journal replay, but without this sync
839    // a power loss could recover old-snapshot/new-journal and lose deltas.
840    #[cfg(unix)]
841    if durable || bind {
842        sync_sessions_dir(handle)?;
843    }
844    if mode == SessionPersistence::Journal {
845        handle.write_atomic(&format!("{}.journal", session.id), &journal)?;
846    } else {
847        remove_artifact_if_exists(handle, &format!("{}.journal", session.id))?;
848    }
849    if durable {
850        sync_sessions_dir(handle)?;
851    }
852    Ok(SaveReceipt {
853        mode: SaveMode::FullSnapshot,
854        bytes_written: json.len() as u64 + journal.len() as u64,
855    })
856}
857
858fn sync_sessions_dir(handle: &SessionsDirHandle) -> std::io::Result<()> {
859    #[cfg(unix)]
860    {
861        handle.sync_all()
862    }
863    #[cfg(not(unix))]
864    {
865        let _ = handle;
866        Err(std::io::Error::new(
867            std::io::ErrorKind::Unsupported,
868            "durable session checkpoints require Unix directory fsync",
869        ))
870    }
871}
872
873// ─── load ────────────────────────────────────────────────────────────────────
874
875/// Load `<id>.json` and, when a journal exists, replay it idempotently.
876/// Old-format sessions (no journal) load exactly as before; a session with
877/// a torn or stale journal recovers to its last consistent state. Both
878/// reads are confined nofollow bounded handle reads (fix1 I1).
879pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
880    // ONE strict resolution per load (fix2); both artifact reads below are
881    // relative to this handle.
882    let handle = open_sessions_dir(dir)?.ok_or_else(|| {
883        std::io::Error::new(
884            std::io::ErrorKind::NotFound,
885            format!("no sessions directory for '{id}'"),
886        )
887    })?;
888    let snapshot = read_artifact_bytes(&handle, &format!("{id}.json"))?.ok_or_else(|| {
889        std::io::Error::new(
890            std::io::ErrorKind::NotFound,
891            format!("no session snapshot for '{id}'"),
892        )
893    })?;
894    let binding: SnapshotBinding =
895        serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
896    let mut session: Session = serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
897    session.clamp_keepwarm_to_totals();
898
899    let Some(raw) = read_artifact_bytes(&handle, &format!("{id}.journal"))? else {
900        return Ok(session);
901    };
902    let text = String::from_utf8_lossy(&raw);
903    let mut lines = text.lines();
904    let version = match lines
905        .next()
906        .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
907        .and_then(|open| matching_open(&open, binding.generation.as_deref()))
908    {
909        Some(version) => version,
910        None => return Ok(session),
911    };
912    for line in lines {
913        match serde_json::from_str::<JournalRecord>(line) {
914            // fix1 M1: an unknown-version record ends the valid prefix.
915            Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
916                if v != version =>
917            {
918                break;
919            }
920            Ok(JournalRecord::Msg { i, m, .. }) => {
921                if i == session.api_messages.len() {
922                    session.api_messages.push(std::sync::Arc::new(m));
923                } else if i > session.api_messages.len() {
924                    break; // gap — stop at the consistent prefix
925                }
926                // i < len: already in the snapshot — idempotent skip.
927            }
928            Ok(JournalRecord::Meta { meta, .. }) => {
929                if meta.id == session.id && meta.updated_at >= session.updated_at {
930                    meta.apply(&mut session);
931                }
932            }
933            Ok(JournalRecord::Open { .. }) | Err(_) => break, // torn tail
934        }
935    }
936    // Keep-warm labels are snapshot-only (SNAPSHOT_ONLY_FIELDS): keep them
937    // under the journal-replayed totals.
938    session.clamp_keepwarm_to_totals();
939    Ok(session)
940}
941
942/// Freshest `meta` record from a bounded journal tail window, for listing
943/// freshness without a full journal read. `None` when no journal, no
944/// complete supported-version meta record, or a refused (non-confined)
945/// artifact — a symlinked journal discloses nothing.
946pub fn journal_meta_tail(dir: &Path, id: &str) -> Option<JournalMetaTail> {
947    let handle = open_sessions_dir(dir).ok()??;
948    let binding = snapshot_binding(open_artifact(&handle, &format!("{id}.json")).ok()??).ok()?;
949    journal_meta_tail_from_handle(&handle, id, binding.generation.as_deref())
950}
951
952/// Listings already read a snapshot header: use THAT generation rather than
953/// reopening a possibly replaced snapshot and mixing two checkpoint heads.
954pub(crate) fn journal_meta_tail_for_generation(
955    dir: &Path,
956    id: &str,
957    generation: Option<&str>,
958) -> Option<JournalMetaTail> {
959    let handle = open_sessions_dir(dir).ok()??;
960    journal_meta_tail_from_handle(&handle, id, generation)
961}
962
963fn journal_meta_tail_from_handle(
964    handle: &SessionsDirHandle,
965    id: &str,
966    generation: Option<&str>,
967) -> Option<JournalMetaTail> {
968    use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
969    let mut file = open_artifact(handle, &format!("{id}.journal")).ok()??;
970    let mut first = String::new();
971    BufReader::new((&mut file).take(4096))
972        .read_line(&mut first)
973        .ok()?;
974    let open: JournalRecord = serde_json::from_str(&first).ok()?;
975    let version = matching_open(&open, generation)?;
976    let len = file.metadata().ok()?.len();
977    let start = len.saturating_sub(META_TAIL_WINDOW);
978    file.seek(SeekFrom::Start(start)).ok()?;
979    let mut tail = String::new();
980    file.take(META_TAIL_WINDOW).read_to_string(&mut tail).ok()?;
981
982    let mut freshest = None;
983    for line in tail.lines() {
984        if let Ok(JournalRecord::Meta { v, meta, .. }) = serde_json::from_str::<JournalRecord>(line)
985        {
986            if v == version && meta.id == id {
987                freshest = Some(JournalMetaTail {
988                    updated_at: meta.updated_at,
989                    session_cost: meta.session_cost,
990                    message_count: meta.message_count,
991                });
992            }
993        }
994    }
995    freshest
996}
997
998// ─── deletion ────────────────────────────────────────────────────────────────
999
1000/// Remove a session's snapshot, journal AND turn draft (`session_draft`)
1001/// (compaction rollback, retention). Idempotent — missing files are not
1002/// errors.
1003pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
1004    let Some(handle) = open_sessions_dir(dir)? else {
1005        return Ok(()); // no directory — idempotently nothing to delete
1006    };
1007    remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
1008    remove_artifact_if_exists(&handle, &format!("{id}.journal"))?;
1009    remove_artifact_if_exists(&handle, &format!("{id}.turn"))
1010}
1011
1012fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
1013    match handle.remove_file(name) {
1014        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1015        other => other,
1016    }
1017}
1018
1019/// Strict-handle snapshot write shared with the legacy
1020/// `session::save_json_in_dir` path (fix2): same root resolution and the
1021/// same handle-relative atomic private write as every journal operation.
1022#[cfg(test)]
1023pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
1024    let handle = create_sessions_dir(dir)?;
1025    handle.write_atomic(&format!("{id}.json"), json)
1026}
1027
1028// ─── tests ───────────────────────────────────────────────────────────────────
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033
1034    /// Drift guard: `SessionMeta` must mirror every non-history `Session`
1035    /// field. Serializing a full `Session`, dropping `api_messages`, and
1036    /// parsing the rest as `SessionMeta` (deny_unknown_fields) fails the
1037    /// moment `Session` grows a field this module does not journal.
1038    #[test]
1039    fn snapshot_json_matches_session_schema() {
1040        let mut s = Session::new("model-x", "medium", Some("prompt"));
1041        s.name = Some("named".into());
1042        s.abort_context = Some("ctx".into());
1043        s.parent_session = Some("parent".into());
1044        s.compacted_into = Some("child".into());
1045        s.api_messages.push(std::sync::Arc::new(
1046            serde_json::json!({"role": "user", "content": "hi"}),
1047        ));
1048        s.message_count = 0; // stale in memory — snapshot must not trust it
1049                             // Expected = the old clone-and-refresh path, byte for byte.
1050        let mut expected = s.clone();
1051        expected.message_count = expected.api_messages.len();
1052        let expected = serde_json::to_string(&expected).unwrap();
1053        assert_eq!(snapshot_json(&s, None).unwrap(), expected);
1054        let back: Session = serde_json::from_str(&snapshot_json(&s, None).unwrap()).unwrap();
1055        assert_eq!(back.message_count, 1);
1056        // Same check with every optional absent.
1057        let s = Session::new("model-x", "medium", None);
1058        let mut expected = s.clone();
1059        expected.message_count = 0;
1060        assert_eq!(
1061            snapshot_json(&s, None).unwrap(),
1062            serde_json::to_string(&expected).unwrap()
1063        );
1064    }
1065
1066    #[test]
1067    fn session_meta_stays_in_sync_with_session_schema() {
1068        let mut s = Session::new("model-x", "medium", Some("prompt"));
1069        s.name = Some("named".into());
1070        s.abort_context = Some("ctx".into());
1071        s.parent_session = Some("parent".into());
1072        s.compacted_into = Some("child".into());
1073        s.keepwarm_cost = 0.25;
1074        s.keepwarm_tokens.cache_read = 9;
1075        let mut value = serde_json::to_value(&s).unwrap();
1076        value.as_object_mut().unwrap().remove("api_messages");
1077        for f in SNAPSHOT_ONLY_FIELDS {
1078            value.as_object_mut().unwrap().remove(*f);
1079        }
1080        let meta: SessionMeta = serde_json::from_value(value)
1081            .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
1082        assert_eq!(meta.id, s.id);
1083        assert_eq!(meta.name.as_deref(), Some("named"));
1084    }
1085
1086    #[test]
1087    fn meta_roundtrip_applies_every_field() {
1088        let mut a = Session::new("model-a", "high", Some("sys"));
1089        a.title = "t".into();
1090        a.total_input_tokens = 7;
1091        a.session_cost = 0.5;
1092        a.updated_at = chrono::Utc::now();
1093        let mut b = Session::new("model-b", "low", None);
1094        b.id = a.id.clone();
1095        SessionMeta::of(&a).apply(&mut b);
1096        assert_eq!(b.model, "model-a");
1097        assert_eq!(b.title, "t");
1098        assert_eq!(b.total_input_tokens, 7);
1099        assert_eq!(b.session_cost, 0.5);
1100        assert_eq!(b.updated_at, a.updated_at);
1101    }
1102
1103    #[test]
1104    fn env_roundtrip_and_secret_scrub() {
1105        let tmp = tempfile::TempDir::new().unwrap();
1106        let dir = tmp.path().join("sessions");
1107        let mut s = Session::new("m", "medium", None);
1108        // Set env with a normal var and a secret-looking var.
1109        s.env = Some(vec![
1110            ("PATH".into(), "/usr/bin".into()),
1111            ("GH_TOKEN".into(), "ghp_supersecret42".into()),
1112        ]);
1113        s.env_stripped = vec!["GH_TOKEN".into()];
1114        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1115        // Load back.
1116        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1117        // Non-secret survives.
1118        let env = loaded.env.as_ref().expect("env must round-trip");
1119        assert!(
1120            env.iter().any(|(k, v)| k == "PATH" && v == "/usr/bin"),
1121            "non-secret key must survive"
1122        );
1123        // Secret value MUST be absent from the on-disk bytes.
1124        let snap_bytes =
1125            std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
1126        assert!(
1127            !snap_bytes.contains("ghp_supersecret42"),
1128            "secret VALUE must never appear on disk"
1129        );
1130        // …and the non-secret env IS in the `.json` snapshot itself (not only
1131        // the journal meta tail) — the snapshot is what a fresh daemon reads.
1132        assert!(
1133            snap_bytes.contains("\"env\"") && snap_bytes.contains("/usr/bin"),
1134            "snapshot must carry the scrubbed env: {snap_bytes}"
1135        );
1136        // Secret name IS present in env_stripped.
1137        assert_eq!(loaded.env_stripped, vec!["GH_TOKEN".to_string()]);
1138        // The secret KEY should not appear in the env pairs.
1139        assert!(
1140            !env.iter().any(|(k, _)| k == "GH_TOKEN"),
1141            "secret key must be scrubbed from env pairs"
1142        );
1143    }
1144
1145    #[test]
1146    fn credential_url_values_never_reach_disk_whatever_the_key() {
1147        let tmp = tempfile::TempDir::new().unwrap();
1148        let dir = tmp.path().join("sessions");
1149        let mut s = Session::new("m", "medium", None);
1150        s.env = Some(vec![
1151            ("PATH".into(), "/usr/bin".into()),
1152            (
1153                "DATABASE_URL".into(),
1154                "postgres://app:hunter2@db/prod".into(),
1155            ),
1156            (
1157                "SENTRY_DSN".into(),
1158                "https://abc123dsnkey@o1.ingest.sentry.io/42".into(),
1159            ),
1160        ]);
1161        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1162        // Every file the save wrote (snapshot + journal) — none may carry the credential.
1163        for entry in std::fs::read_dir(&dir).unwrap() {
1164            let path = entry.unwrap().path();
1165            let bytes = std::fs::read(&path).unwrap();
1166            let text = String::from_utf8_lossy(&bytes);
1167            assert!(
1168                !text.contains("hunter2"),
1169                "credential URL leaked to {}",
1170                path.display()
1171            );
1172            assert!(
1173                !text.contains("abc123dsnkey"),
1174                "DSN key leaked to {}",
1175                path.display()
1176            );
1177        }
1178        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1179        let env = loaded.env.as_ref().expect("env must round-trip");
1180        assert!(
1181            env.iter().any(|(k, _)| k == "PATH"),
1182            "non-secret key must survive"
1183        );
1184        assert!(!env
1185            .iter()
1186            .any(|(k, _)| k == "DATABASE_URL" || k == "SENTRY_DSN"));
1187    }
1188
1189    fn with_messages(n: usize) -> Session {
1190        let mut s = Session::new("checkpoint-test", "medium", None);
1191        for i in 0..n {
1192            push_message(&mut s, &format!("old {i}"));
1193        }
1194        s
1195    }
1196
1197    fn push_message(s: &mut Session, text: &str) {
1198        s.api_messages.push(std::sync::Arc::new(
1199            serde_json::json!({"role": "user", "content": text}),
1200        ));
1201    }
1202
1203    fn assert_saved(dir: &Path, expected: &Session) {
1204        let loaded = load_session_in_dir(dir, &expected.id).unwrap();
1205        let mut expected = expected.clone();
1206        expected.message_count = expected.api_messages.len();
1207        // message_count is a snapshot hint, not authoritative after replay.
1208        let mut loaded = loaded;
1209        loaded.message_count = loaded.api_messages.len();
1210        assert_eq!(
1211            serde_json::to_value(loaded).unwrap(),
1212            serde_json::to_value(expected).unwrap()
1213        );
1214    }
1215
1216    fn disk_generation(dir: &Path, id: &str) -> String {
1217        let snapshot: serde_json::Value =
1218            serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
1219                .unwrap();
1220        snapshot["_journal_generation"]
1221            .as_str()
1222            .unwrap()
1223            .to_string()
1224    }
1225
1226    #[test]
1227    fn bound_snapshot_is_session_shaped_and_storage_metadata_is_not_mirrored() {
1228        let s = with_messages(2);
1229        let json = snapshot_json(&s, Some("test-generation")).unwrap();
1230        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1231        assert_eq!(
1232            value
1233                .as_object_mut()
1234                .unwrap()
1235                .remove("_journal_generation")
1236                .unwrap(),
1237            "test-generation"
1238        );
1239        let mut expected = s.clone();
1240        expected.message_count = 2;
1241        assert_eq!(value, serde_json::to_value(&expected).unwrap());
1242        let back: Session = serde_json::from_str(&json).unwrap();
1243        assert_eq!(serde_json::to_value(back).unwrap(), value);
1244        value.as_object_mut().unwrap().remove("api_messages");
1245        serde_json::from_value::<SessionMeta>(value).unwrap();
1246    }
1247
1248    /// Recreate the exact two-file state of a crash after snapshot rename but
1249    /// before journal reset/unlink: keep the newly published snapshot and put
1250    /// back the old journal bytes. No clocks, processes, config, or real data.
1251    fn replacement_crash_case(mode: SessionPersistence, durable: bool, shorten: bool) {
1252        let tmp = tempfile::TempDir::new().unwrap();
1253        let dir = tmp.path().canonicalize().unwrap();
1254        let mut s = with_messages(2);
1255        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1256        for _ in 0..4 {
1257            push_message(&mut s, "obsolete journal message");
1258        }
1259        s.title = "obsolete metadata".into();
1260        s.updated_at += chrono::Duration::hours(1);
1261        s.session_cost = 99.0;
1262        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1263        let old = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1264        let old_generation = disk_generation(&dir, &s.id);
1265
1266        // Even same-length edits with an unchanged final message must commit.
1267        s.api_messages[0] =
1268            std::sync::Arc::new(serde_json::json!({"role":"user","content":"new head"}));
1269        if shorten {
1270            s.api_messages.truncate(3);
1271        }
1272        s.title = "candidate".into();
1273        s.updated_at -= chrono::Duration::hours(2); // old metadata would win by timestamp alone
1274        s.session_cost = 1.0;
1275        let receipt = if durable {
1276            save_session_durable_in_dir(&dir, &s, mode).unwrap()
1277        } else {
1278            save_session_in_dir(&dir, &s, mode).unwrap()
1279        };
1280        assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1281        assert_ne!(disk_generation(&dir, &s.id), old_generation);
1282        std::fs::write(journal_path(&dir, &s.id), old).unwrap();
1283        assert_saved(&dir, &s);
1284        assert!(journal_meta_tail(&dir, &s.id).is_none());
1285
1286        // A subsequent normal save must not append into the stale generation.
1287        push_message(&mut s, "post-checkpoint");
1288        let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1289        assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1290        assert_saved(&dir, &s);
1291        push_message(&mut s, "new generation delta");
1292        assert_eq!(
1293            save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1294                .unwrap()
1295                .mode,
1296            SaveMode::Append { messages: 1 }
1297        );
1298        assert_saved(&dir, &s);
1299    }
1300
1301    #[test]
1302    fn normal_replacements_ignore_stale_journal_after_snapshot_publication() {
1303        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1304            for shorten in [false, true] {
1305                replacement_crash_case(mode, false, shorten);
1306            }
1307        }
1308    }
1309
1310    #[cfg(unix)]
1311    #[test]
1312    fn durable_replacements_ignore_stale_journal_after_snapshot_publication() {
1313        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1314            for shorten in [false, true] {
1315                replacement_crash_case(mode, true, shorten);
1316            }
1317        }
1318    }
1319
1320    #[test]
1321    fn legacy_v1_reads_then_migrates_and_cannot_replay_over_replacement() {
1322        let tmp = tempfile::TempDir::new().unwrap();
1323        let dir = tmp.path().canonicalize().unwrap();
1324        let mut s = with_messages(1);
1325        std::fs::write(
1326            dir.join(format!("{}.json", s.id)),
1327            snapshot_json(&s, None).unwrap(),
1328        )
1329        .unwrap();
1330        let mut legacy = vec![JournalRecord::Open {
1331            v: 1,
1332            base: 1,
1333            generation: None,
1334            history_hash: None,
1335        }];
1336        for i in 1..5 {
1337            push_message(&mut s, "legacy delta");
1338            legacy.push(JournalRecord::Msg {
1339                v: 1,
1340                i,
1341                m: s.api_messages[i].as_ref().clone(),
1342            });
1343        }
1344        s.title = "legacy title".into();
1345        s.updated_at += chrono::Duration::hours(1);
1346        legacy.push(JournalRecord::Meta {
1347            v: 1,
1348            meta: Box::new(SessionMeta::of(&s)),
1349            history_hash: None,
1350        });
1351        let mut bytes = Vec::new();
1352        for record in legacy {
1353            serde_json::to_writer(&mut bytes, &record).unwrap();
1354            bytes.push(b'\n');
1355        }
1356        std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1357        assert_saved(&dir, &s);
1358        assert_eq!(
1359            journal_meta_tail(&dir, &s.id).unwrap().message_count,
1360            Some(5)
1361        );
1362        let migrated = load_session_in_dir(&dir, &s.id).unwrap();
1363        assert_eq!(
1364            save_session_in_dir(&dir, &migrated, SessionPersistence::Journal)
1365                .unwrap()
1366                .mode,
1367            SaveMode::FullSnapshot
1368        );
1369        assert_saved(&dir, &s);
1370        assert!(!disk_generation(&dir, &s.id).is_empty());
1371
1372        s.api_messages.truncate(2);
1373        s.title = "new window".into();
1374        s.updated_at -= chrono::Duration::hours(2);
1375        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1376        std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1377        assert_saved(&dir, &s);
1378        assert!(journal_meta_tail(&dir, &s.id).is_none());
1379    }
1380
1381    #[test]
1382    fn same_length_edits_in_snapshot_or_earlier_journal_message_resnapshot() {
1383        for appended in [false, true] {
1384            let tmp = tempfile::TempDir::new().unwrap();
1385            let dir = tmp.path().canonicalize().unwrap();
1386            let mut s = with_messages(3);
1387            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1388            let edited = if appended {
1389                push_message(&mut s, "editable");
1390                push_message(&mut s, "unchanged last message");
1391                save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1392                3
1393            } else {
1394                0
1395            };
1396            let old_generation = disk_generation(&dir, &s.id);
1397            std::sync::Arc::make_mut(&mut s.api_messages[edited])["content"] = "replaced".into();
1398            assert_eq!(
1399                save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1400                    .unwrap()
1401                    .mode,
1402                SaveMode::FullSnapshot
1403            );
1404            assert_ne!(disk_generation(&dir, &s.id), old_generation);
1405            assert_saved(&dir, &s);
1406        }
1407    }
1408
1409    #[test]
1410    fn v2_never_replays_without_binding_or_across_record_versions() {
1411        let tmp = tempfile::TempDir::new().unwrap();
1412        let dir = tmp.path().canonicalize().unwrap();
1413        let mut s = with_messages(1);
1414        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1415        let first = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1416        push_message(&mut s, "v2 delta");
1417        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1418        let snapshot: Session =
1419            serde_json::from_slice(&std::fs::read(dir.join(format!("{}.json", s.id))).unwrap())
1420                .unwrap();
1421        // A generic Session serde mirror drops only the storage metadata.
1422        std::fs::write(
1423            dir.join(format!("{}.json", s.id)),
1424            serde_json::to_vec(&snapshot).unwrap(),
1425        )
1426        .unwrap();
1427        assert_saved(&dir, &snapshot); // never apply an unbound v2 delta
1428        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1429        let bound_snapshot = s.clone();
1430        let mut journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1431        journal.extend_from_slice(
1432            b"{\"v\":1,\"k\":\"msg\",\"i\":2,\"m\":{\"content\":\"wrong version\"}}\n",
1433        );
1434        std::fs::write(journal_path(&dir, &s.id), &journal).unwrap();
1435        assert_saved(&dir, &bound_snapshot);
1436        assert_eq!(
1437            save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1438                .unwrap()
1439                .mode,
1440            SaveMode::FullSnapshot
1441        );
1442        // Restoring an older *v2* open also invalidates the whole overlay.
1443        std::fs::write(journal_path(&dir, &s.id), first).unwrap();
1444        assert_saved(&dir, &s);
1445    }
1446
1447    #[test]
1448    fn json_saves_keep_binding_even_after_journal_deletion() {
1449        let tmp = tempfile::TempDir::new().unwrap();
1450        let dir = tmp.path().canonicalize().unwrap();
1451        let mut s = with_messages(2);
1452        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1453        push_message(&mut s, "old delta");
1454        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1455        let journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1456        save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1457        let generation = disk_generation(&dir, &s.id);
1458        s.api_messages.truncate(2);
1459        save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1460        assert_ne!(generation, disk_generation(&dir, &s.id));
1461        std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
1462        assert_saved(&dir, &s);
1463    }
1464
1465    #[cfg(unix)]
1466    #[test]
1467    fn durable_error_after_rename_keeps_candidate_and_retry_is_safe() {
1468        let tmp = tempfile::TempDir::new().unwrap();
1469        let dir = tmp.path().canonicalize().unwrap();
1470        let mut s = with_messages(2);
1471        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1472        push_message(&mut s, "old delta");
1473        push_message(&mut s, "more old history");
1474        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1475        let old_journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1476        let old_generation = disk_generation(&dir, &s.id);
1477        s.api_messages.truncate(2);
1478        s.title = "durable candidate".into();
1479        // Snapshot fsync/rename/dir fsync succeeds; journal temp cleanup fails.
1480        let blocker = dir.join(format!("{}.journal.tmp", s.id));
1481        std::fs::create_dir(&blocker).unwrap();
1482        let error = save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap_err();
1483        assert_ne!(error.kind(), std::io::ErrorKind::NotFound);
1484        assert_ne!(old_generation, disk_generation(&dir, &s.id));
1485        assert_eq!(
1486            std::fs::read(journal_path(&dir, &s.id)).unwrap(),
1487            old_journal
1488        );
1489        assert_saved(&dir, &s); // Err did NOT roll back the published head
1490        std::fs::remove_dir(blocker).unwrap();
1491        save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1492        assert_saved(&dir, &s);
1493    }
1494
1495    /// Private modes (spec §5.4): journal-mode saves keep the 0700 dir and
1496    /// 0600 files under a permissive umask, exactly like the legacy path.
1497    #[cfg(unix)]
1498    mod private_modes {
1499        use super::*;
1500        use crate::core::private_fs::test_support::UmaskGuard;
1501        use serial_test::serial;
1502        use std::os::unix::fs::PermissionsExt;
1503
1504        fn mode_of(path: &Path) -> u32 {
1505            std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1506        }
1507
1508        #[test]
1509        #[serial(umask)]
1510        fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
1511            let _umask = UmaskGuard::set(0);
1512            let tmp = tempfile::TempDir::new().unwrap();
1513            let dir = tmp.path().join("sessions");
1514            let mut s = Session::new("m", "medium", None);
1515            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1516            s.api_messages.push(std::sync::Arc::new(
1517                serde_json::json!({"role":"user","content":"x"}),
1518            ));
1519            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1520
1521            assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1522            assert_eq!(
1523                mode_of(&dir.join(format!("{}.json", s.id))),
1524                0o600,
1525                "snapshot must be 0600"
1526            );
1527            assert_eq!(
1528                mode_of(&journal_path(&dir, &s.id)),
1529                0o600,
1530                "journal must be 0600"
1531            );
1532        }
1533
1534        #[test]
1535        #[serial(umask)]
1536        fn durable_files_are_private_in_both_modes_and_repair_leaf_modes() {
1537            let _umask = UmaskGuard::set(0);
1538            for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1539                let tmp = tempfile::TempDir::new().unwrap();
1540                let dir = tmp
1541                    .path()
1542                    .canonicalize()
1543                    .unwrap()
1544                    .join("new/profile/sessions");
1545                let s = with_messages(2);
1546                save_session_durable_in_dir(&dir, &s, mode).unwrap();
1547                assert_eq!(mode_of(dir.parent().unwrap()), 0o700);
1548                std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
1549                let snapshot = dir.join(format!("{}.json", s.id));
1550                std::fs::set_permissions(&snapshot, std::fs::Permissions::from_mode(0o666))
1551                    .unwrap();
1552                save_session_durable_in_dir(&dir, &s, mode).unwrap();
1553                assert_eq!(mode_of(&dir), 0o700);
1554                assert_eq!(mode_of(&snapshot), 0o600);
1555                if mode == SessionPersistence::Journal {
1556                    assert_eq!(mode_of(&journal_path(&dir, &s.id)), 0o600);
1557                }
1558                assert_saved(&dir, &s);
1559            }
1560        }
1561
1562        #[test]
1563        fn durable_saves_refuse_artifact_and_ancestor_symlinks_in_both_modes() {
1564            for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1565                for extension in ["json", "journal"] {
1566                    let tmp = tempfile::TempDir::new().unwrap();
1567                    let root = tmp.path().canonicalize().unwrap();
1568                    let dir = root.join("sessions");
1569                    std::fs::create_dir(&dir).unwrap();
1570                    let victim = root.join("victim");
1571                    std::fs::write(&victim, "original").unwrap();
1572                    let s = with_messages(1);
1573                    std::os::unix::fs::symlink(
1574                        &victim,
1575                        dir.join(format!("{}.{}", s.id, extension)),
1576                    )
1577                    .unwrap();
1578                    assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1579                    assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1580                }
1581                let tmp = tempfile::TempDir::new().unwrap();
1582                let root = tmp.path().canonicalize().unwrap();
1583                let victim_dir = root.join("victim-dir");
1584                std::fs::create_dir(&victim_dir).unwrap();
1585                let link = root.join("link");
1586                std::os::unix::fs::symlink(&victim_dir, &link).unwrap();
1587                let s = with_messages(1);
1588                for dir in [link.clone(), link.join("profile/sessions")] {
1589                    assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1590                }
1591                assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
1592            }
1593        }
1594
1595        #[test]
1596        fn journal_append_refuses_symlink_target() {
1597            let tmp = tempfile::TempDir::new().unwrap();
1598            let dir = tmp.path().join("sessions");
1599            std::fs::create_dir_all(&dir).unwrap();
1600            let mut s = Session::new("m", "medium", None);
1601            let victim = tmp.path().join("victim");
1602            std::fs::write(&victim, "original").unwrap();
1603            std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
1604            // Preflight refuses the journal before publishing a snapshot.
1605            s.api_messages.push(std::sync::Arc::new(
1606                serde_json::json!({"role":"user","content":"x"}),
1607            ));
1608            let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
1609            assert!(res.is_err(), "journal write onto a symlink must fail");
1610            assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1611        }
1612    }
1613}
1614
1615/// What one session save costs ON A REAL DISK — the number the per-round
1616/// save (`agent_engine` round checkpoints) pays. Not run by default: set
1617/// `SYNAPS_SAVE_BENCH_DIR` to a directory on the filesystem to measure (NOT
1618/// tmpfs, whose fsync is free), then
1619/// `cargo test -p synaps-core --release --lib save_cost_bench -- --ignored --nocapture`.
1620#[cfg(test)]
1621mod save_cost_bench {
1622    use super::*;
1623    use std::sync::Arc;
1624    use std::time::{Duration, Instant};
1625
1626    fn session(bytes: usize) -> Session {
1627        let mut s = Session::new("claude-sonnet-4-5", "low", None);
1628        // ~4 KiB messages, alternating roles: a realistic mix of short
1629        // turns and tool output.
1630        let chunk = "x".repeat(4000);
1631        let n = bytes / 4096;
1632        s.api_messages = (0..n)
1633            .map(|i| {
1634                let role = if i % 2 == 0 { "user" } else { "assistant" };
1635                Arc::new(serde_json::json!({"role": role, "content": format!("{i} {chunk}")}))
1636            })
1637            .collect();
1638        s
1639    }
1640
1641    fn pct(sorted: &[Duration], p: f64) -> Duration {
1642        sorted[((sorted.len() - 1) as f64 * p).round() as usize]
1643    }
1644
1645    #[test]
1646    #[ignore = "benchmark: needs SYNAPS_SAVE_BENCH_DIR on a real disk"]
1647    fn save_cost_bench() {
1648        let Ok(root) = std::env::var("SYNAPS_SAVE_BENCH_DIR") else {
1649            eprintln!("SYNAPS_SAVE_BENCH_DIR not set; skipping");
1650            return;
1651        };
1652        let dir = tempfile::tempdir_in(root).unwrap();
1653        for (label, bytes) in [("200 KB", 200_000), ("2.3 MB", 2_300_000), ("10 MB", 10_000_000)] {
1654            for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1655                let mut s = session(bytes);
1656                save_session_in_dir(dir.path(), &s, mode).unwrap(); // warm up
1657                let mut samples = Vec::new();
1658                for round in 0..40 {
1659                    // Each round appends an assistant reply + tool result.
1660                    for role in ["assistant", "user"] {
1661                        s.api_messages.push(Arc::new(
1662                            serde_json::json!({"role": role, "content": format!("round {round}")}),
1663                        ));
1664                    }
1665                    let t = Instant::now();
1666                    save_session_in_dir(dir.path(), &s, mode).unwrap();
1667                    samples.push(t.elapsed());
1668                }
1669                samples.sort();
1670                eprintln!(
1671                    "{label:>7} {mode:?}: p50 {:?}  p95 {:?}  max {:?}",
1672                    pct(&samples, 0.5),
1673                    pct(&samples, 0.95),
1674                    samples[samples.len() - 1]
1675                );
1676                let _ = delete_session_files_in_dir(dir.path(), &s.id);
1677            }
1678        }
1679    }
1680}
1681
1682/// Task #444, C-1: keep-warm labels are snapshot-only, and the journal stays
1683/// readable by 0.10.1.
1684#[cfg(test)]
1685mod snapshot_only_fields {
1686    use super::*;
1687
1688    /// Task #444: keep-warm spend persists through the snapshot in both
1689    /// modes (journal mode publishes a snapshot on the first save).
1690    #[test]
1691    fn keepwarm_cost_survives_save_and_load() {
1692        let tmp = tempfile::TempDir::new().unwrap();
1693        let dir = tmp.path().join("sessions");
1694        for persistence in [SessionPersistence::Json, SessionPersistence::Journal] {
1695            let mut s = Session::new("m", "medium", None);
1696            s.api_messages.push(std::sync::Arc::new(
1697                serde_json::json!({"role": "user", "content": "q"}),
1698            ));
1699            s.session_cost = 1.5;
1700            s.total_input_tokens = 100;
1701            s.total_output_tokens = 10;
1702            s.keepwarm_cost = 0.25;
1703            s.keepwarm_tokens = KeepwarmTokens {
1704                input: 4,
1705                output: 2,
1706                cache_read: 900,
1707                cache_creation: 0,
1708            };
1709            save_session_in_dir(&dir, &s, persistence).unwrap();
1710            let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1711            assert_eq!(loaded.keepwarm_cost, 0.25, "{persistence:?}");
1712            assert_eq!(loaded.keepwarm_tokens, s.keepwarm_tokens, "{persistence:?}");
1713            assert_eq!(loaded.session_cost, 1.5, "{persistence:?}");
1714        }
1715    }
1716
1717    /// The exact `SessionMeta` field set shipped in 0.10.1 (5c86d2ca), with
1718    /// its `deny_unknown_fields`. Kept frozen on purpose: it stands in for
1719    /// the older reader, which must keep parsing every meta record this
1720    /// build writes (C-1, task #444).
1721    #[derive(Deserialize)]
1722    #[serde(deny_unknown_fields)]
1723    #[allow(dead_code)]
1724    struct SessionMetaV0_10_1 {
1725        id: String,
1726        title: String,
1727        #[serde(default)]
1728        name: Option<String>,
1729        model: String,
1730        thinking_level: String,
1731        system_prompt: Option<String>,
1732        created_at: DateTime<Utc>,
1733        updated_at: DateTime<Utc>,
1734        total_input_tokens: u64,
1735        total_output_tokens: u64,
1736        session_cost: f64,
1737        #[serde(default)]
1738        message_count: Option<usize>,
1739        #[serde(default)]
1740        abort_context: Option<String>,
1741        #[serde(default)]
1742        parent_session: Option<String>,
1743        #[serde(default)]
1744        compacted_into: Option<String>,
1745        #[serde(default)]
1746        env: Option<Vec<(String, String)>>,
1747        #[serde(default)]
1748        env_stripped: Vec<String>,
1749        #[serde(default)]
1750        prompt_provenance: Option<serde_json::Value>,
1751        #[serde(default)]
1752        compaction: Option<serde_json::Value>,
1753    }
1754
1755    /// 0.10.1's journal reader, reduced to the part that matters: every
1756    /// record must parse (an `Err` there is a torn tail that truncates the
1757    /// session and is then rewritten by `full_snapshot_reset`).
1758    #[derive(Deserialize)]
1759    #[serde(tag = "k")]
1760    #[allow(dead_code)]
1761    enum JournalRecordV0_10_1 {
1762        #[serde(rename = "open")]
1763        Open {
1764            v: u8,
1765            base: usize,
1766            #[serde(default)]
1767            generation: Option<String>,
1768            #[serde(default)]
1769            history_hash: Option<String>,
1770        },
1771        #[serde(rename = "msg")]
1772        Msg {
1773            v: u8,
1774            i: usize,
1775            m: serde_json::Value,
1776        },
1777        #[serde(rename = "meta")]
1778        Meta {
1779            v: u8,
1780            meta: Box<SessionMetaV0_10_1>,
1781            #[serde(default)]
1782            history_hash: Option<String>,
1783        },
1784    }
1785
1786    #[test]
1787    fn journal_written_by_this_build_parses_with_the_0_10_1_reader() {
1788        let tmp = tempfile::TempDir::new().unwrap();
1789        let dir = tmp.path().join("sessions");
1790        let mut s = Session::new("m", "medium", Some("sys"));
1791        s.name = Some("n".into());
1792        s.parent_session = Some("p".into());
1793        s.env = Some(vec![("PATH".into(), "/bin".into())]);
1794        s.api_messages.push(std::sync::Arc::new(
1795            serde_json::json!({"role": "user", "content": "q"}),
1796        ));
1797        s.session_cost = 1.0;
1798        s.total_input_tokens = 50;
1799        s.keepwarm_cost = 0.5;
1800        s.keepwarm_tokens.input = 5;
1801        s.keepwarm_tokens.cache_read = 1_000;
1802        // First save publishes the snapshot; the next two append deltas
1803        // (messages + meta records carrying every non-zero field).
1804        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1805        for turn in 0..2 {
1806            s.api_messages.push(std::sync::Arc::new(
1807                serde_json::json!({"role": "assistant", "content": format!("a{turn}")}),
1808            ));
1809            s.session_cost += 0.25;
1810            s.keepwarm_cost += 0.125;
1811            let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1812            assert!(matches!(receipt.mode, SaveMode::Append { .. }));
1813        }
1814        let raw = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
1815        let mut metas = 0;
1816        for line in raw.lines() {
1817            let record: JournalRecordV0_10_1 = serde_json::from_str(line)
1818                .unwrap_or_else(|e| panic!("0.10.1 cannot parse {line}: {e}"));
1819            if matches!(record, JournalRecordV0_10_1::Meta { .. }) {
1820                metas += 1;
1821            }
1822            assert!(
1823                !line.contains("keepwarm"),
1824                "journaled a snapshot-only field: {line}"
1825            );
1826        }
1827        assert_eq!(metas, 2);
1828        // And this build still reads back the full history, with the
1829        // keep-warm label as of the snapshot (stale until the next one).
1830        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1831        assert_eq!(loaded.api_messages.len(), 3);
1832        assert_eq!(loaded.session_cost, 1.5);
1833        assert_eq!(loaded.keepwarm_cost, 0.5);
1834    }
1835
1836    #[test]
1837    fn keepwarm_labels_clamp_under_the_totals_on_load() {
1838        let tmp = tempfile::TempDir::new().unwrap();
1839        let dir = tmp.path().join("sessions");
1840        let mut s = Session::new("m", "medium", None);
1841        s.api_messages.push(std::sync::Arc::new(
1842            serde_json::json!({"role": "user", "content": "q"}),
1843        ));
1844        s.session_cost = 0.25;
1845        s.total_input_tokens = 3;
1846        s.total_output_tokens = 1;
1847        s.keepwarm_cost = 0.75; // e.g. a stale snapshot label
1848        s.keepwarm_tokens.input = 10;
1849        s.keepwarm_tokens.output = 10;
1850        save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1851        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1852        assert_eq!(loaded.keepwarm_cost, 0.25);
1853        assert_eq!(loaded.keepwarm_tokens.input, 3);
1854        assert_eq!(loaded.keepwarm_tokens.output, 1);
1855    }
1856}