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/// Message key carrying the host's turn identity (`{"id": "<uuid>"}`) in
38/// memory. Never persisted inside a message: 0.10.2 strips only
39/// `_synaps_context` before a provider request, so an inline key would reach
40/// the wire after a rollback. Saves move the id to a sibling (`t` on journal
41/// `msg` records, `_turn_ids` in the snapshot), which older readers ignore.
42pub const MESSAGE_TURN_KEY: &str = "_synaps_turn";
43
44/// Journal record schema version (line-level `"v"` field).
45pub const JOURNAL_SCHEMA_VERSION: u8 = 2;
46
47/// Journal size floor before a periodic snapshot is forced.
48pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
49
50/// A snapshot is due when the journal outgrows `snapshot / RATIO` (bounded
51/// write amplification: large sessions stretch the threshold proportionally).
52pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
53
54/// Bounded tail window scanned for the freshest `meta` record.
55const META_TAIL_WINDOW: u64 = 64 * 1024;
56
57/// Hard cap on any single persisted-session artifact read (snapshot or
58/// journal). Reads are bounded on the OPENED handle; an artifact past the
59/// cap is refused rather than slurped.
60pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB
61
62// ─── confined resolution (fix1 I1 + fix2) ───────────────────────────────────
63//
64// STRICT TRUSTED-ROOT SEMANTICS (fix2): the sessions directory path is
65// resolved with EVERY component — ancestors AND the final one — opened
66// handle-relatively from `/` with symlinks refused
67// (`ConfinedDir::{open,create}_absolute_no_symlinks`; Linux uses one atomic
68// `openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS` from the root handle).
69// Nothing on the path is trusted; there is no check-then-open race — a
70// component swapped to a symlink at any moment fails the open itself.
71// Artifacts inside the directory are then opened/written/removed relative
72// to that ONE handle. Operators whose base dir legitimately sits behind
73// ancestor symlinks (e.g. `/home` → `var/home`) must point
74// `SYNAPS_BASE_DIR` at the canonical path.
75//
76// Non-unix keeps the crate's documented best-effort pathname fallback
77// (final-component symlink refusal only).
78
79#[cfg(unix)]
80type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
81
82/// Open the sessions dir strictly. `Ok(None)` when a path component does
83/// not exist; symlinks anywhere are errors.
84#[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/// Create-or-open the sessions dir strictly (0700 leaf).
94#[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/// Open `<name>` inside an already-strictly-opened sessions dir.
100/// `Ok(None)` when the file does not exist; a symlinked artifact errors.
101#[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/// One-shot strict open of `<name>` inside `dir`.
111#[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// ── Non-unix best-effort fallbacks (documented, matching `private_fs`) ──
120
121#[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
186/// Bounded read of a whole artifact from an already-confined handle.
187fn 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/// One strictly-resolved directory entry for session listings.
204#[derive(Debug, Clone)]
205pub struct SessionDirEntry {
206    pub name: String,
207    pub mtime: Option<std::time::SystemTime>,
208    /// Stored bytes for metadata-only accounting. Obtained from the directory
209    /// entry stat; reading it never opens session content.
210    pub byte_len: u64,
211}
212
213/// Enumerate the sessions dir through the SAME strict handle resolution as
214/// every other T35 operation (fix2): handle-relative `readdir` +
215/// `fstatat(AT_SYMLINK_NOFOLLOW)` — a symlinked ancestor refuses, a
216/// missing dir lists empty, and entry mtimes are of the entries
217/// themselves, never symlink targets.
218pub 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/// Which on-disk persistence strategy `Session::save` uses.
266///
267/// `Json` (the default) writes a full Session-shaped snapshot. `Journal` is
268/// the spec §9.8 opt-in and is only ever selected explicitly via the
269/// `session_persistence = journal` config key.
270#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
271pub enum SessionPersistence {
272    #[default]
273    Json,
274    Journal,
275}
276
277impl SessionPersistence {
278    /// Parse a config value; anything unrecognized yields `None` so the
279    /// caller keeps the safe default and surfaces a typed warning.
280    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/// How a save landed on disk.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum SaveMode {
292    /// Full Session-shaped snapshot written (and, in journal mode, the
293    /// journal reset to a lone `open` record).
294    FullSnapshot,
295    /// Delta append: `messages` new history entries plus one meta record.
296    Append { messages: usize },
297}
298
299/// Machine-readable receipt for benchmarks and delta-proportionality tests.
300#[derive(Debug, Clone, Copy)]
301pub struct SaveReceipt {
302    pub mode: SaveMode,
303    pub bytes_written: u64,
304}
305
306/// Freshest journaled metadata, read from a bounded tail window — lets
307/// session listings stay accurate without parsing the full journal.
308#[derive(Debug, Clone)]
309pub struct JournalMetaTail {
310    pub updated_at: DateTime<Utc>,
311    pub session_cost: f64,
312    /// `None` for meta records written before the count was journaled.
313    pub message_count: Option<usize>,
314}
315
316/// `sessions/<id>.journal` — single-extension name so the session id stays
317/// the file stem (chain-head protection and retention pair on the stem).
318pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
319    dir.join(format!("{id}.journal"))
320}
321
322/// Snapshot threshold: journal bytes ≥ max(256 KiB, snapshot / 4).
323pub 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// ─── journal records ─────────────────────────────────────────────────────────
328
329#[derive(Serialize, Deserialize)]
330#[serde(tag = "k")]
331enum JournalRecord {
332    /// First line of every journal: the snapshot held `base` messages.
333    #[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        /// [`turns_hash`] of the same prefix; absent = no turn ids.
342        #[serde(default, skip_serializing_if = "Option::is_none")]
343        turns_hash: Option<String>,
344    },
345    /// Message at ABSOLUTE history index `i`; `t` is its turn id, kept
346    /// beside the message (see [`MESSAGE_TURN_KEY`]).
347    #[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        /// Any JSON on read: a malformed id is dropped, it never ends replay.
354        t: Option<serde_json::Value>,
355    },
356    /// Full session metadata (the `Session` object minus `api_messages`).
357    #[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
372/// A message as persisted: the in-memory turn key removed.
373struct 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
404/// `(index, turn id)` of every tagged message, in order.
405fn 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
413/// Turn ids are outside the stored messages, so [`history_hash`] cannot see
414/// a tag added to an already-saved message; this hash does. `None` = none.
415fn 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/// `Session` fields that live ONLY in the `.json` snapshot and are never
440/// journaled. `SessionMeta` is `deny_unknown_fields` in every shipped build
441/// (0.10.1 included): a new field in a meta record makes those builds read
442/// it as a torn tail, truncate the loaded history, and then overwrite the
443/// journal with that truncation on the next save. The snapshot `Session`
444/// tolerates unknown fields, so new metadata goes there instead.
445#[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/// Mirror of [`Session`] WITHOUT `api_messages` and the
454/// [`SNAPSHOT_ONLY_FIELDS`], with identical serde attributes.
455/// `deny_unknown_fields` + the `session_meta_stays_in_sync_…` test make
456/// silent drift between the two types impossible. Do NOT add fields here:
457/// older builds reject them (see [`SNAPSHOT_ONLY_FIELDS`]).
458#[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        // Belt-and-braces: scrub any secret values from the env on the
494        // write path so the journal on disk NEVER contains a credential,
495        // even if a future client forgets to strip.
496        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    /// Apply onto a loaded session. Only metadata moves — never history.
521    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/// A binding is deliberately outside Session/SessionMeta. It is emitted
543/// before `api_messages`, so listings and append checks need only the header.
544#[derive(Default, Deserialize)]
545struct SnapshotBinding {
546    #[serde(default, rename = "_journal_generation")]
547    generation: Option<String>,
548    /// After `api_messages`, so header-only reads leave it empty. Parsed
549    /// leniently by [`stored_turn_ids`]: it is best-effort metadata.
550    #[serde(default, rename = "_turn_ids")]
551    turn_ids: serde_json::Value,
552}
553
554/// Well-formed `[index, id]` entries of a snapshot's `_turn_ids`. Anything
555/// else is dropped with a warning; the turn start is then re-derived.
556fn 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
582/// v1 is interpretable only against an unmarked legacy snapshot. A bound
583/// snapshot must never fall back to v1, even for an empty/torn v2 journal.
584fn 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
605/// Hash the exact ordered prefix as stored (what an older build loads and
606/// hashes), including all stored message fields. Unlike the
607/// old last-message tripwire this detects same-length edits anywhere, even
608/// when the journal contains only its open record. Uses the existing sha2 dep.
609fn 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
626/// Only clean, generation-matched v2 journals can be appended to. Legacy
627/// saves migrate through a full snapshot; torn/gapped tails resnapshot too.
628struct 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); // appending after an unterminated line would hide data
645    }
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); // messages without a complete committing meta record
695    }
696    Ok(Some(JournalState {
697        durable_len,
698        history_hash: hash,
699        turns_hash: turns,
700        bytes: raw.len() as u64,
701    }))
702}
703
704// ─── save ────────────────────────────────────────────────────────────────────
705
706/// Persist under the configured mode. Journal-bound replacement syncs the
707/// snapshot directory before discarding old deltas; normal JSON-only saves do
708/// not promise directory durability. Use [`save_session_durable_in_dir`] for a
709/// full context-head barrier. Snapshot replacements are generation-safe in both.
710pub 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            // Keep virgin JSON-only saves byte-compatible. Once a journal or
719            // generation exists, never publish an unmarked snapshot again.
720            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                    // An unreadable/oversized header must not prevent repair
724                    // by a full save; conservatively rotate the generation.
725                    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
733/// Blocking body of `Session::save_durable`: a full generation-bound snapshot
734/// in either mode. Fsync the file, rename it, fsync the directory, then clean
735/// up the journal and fsync the directory again. Snapshot rename is the logical
736/// commit; any later error is returned without rollback. Retry/reload is safe
737/// with stale journals. Concurrent saves of the same session are NOT supported.
738pub 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    // Delta append: new messages (absolute indices) + one meta record.
785    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    // Periodic snapshot: its fresh generation invalidates the old journal
812    // at publication, before the journal reset can occur.
813    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/// Borrowing mirror of [`Session`] used ONLY for snapshot serialization:
829/// identical field order and serde attributes, with `message_count`
830/// computed from `api_messages.len()` at write time instead of read from
831/// the (non-authoritative) in-memory field. Field order matters —
832/// `message_count` must precede `api_messages` for `read_session_header`.
833/// The `snapshot_json_matches_session_schema` test guards drift.
834#[derive(Serialize)]
835struct SessionSnapshotRef<'a> {
836    // Storage-only metadata, omitted for legacy JSON-only saves. Session
837    // serde deliberately ignores it; it is not journaled as SessionMeta.
838    #[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    /// Owned (not borrowed) because secret keys are scrubbed on the write
866    /// path — the `.json` snapshot must never carry a credential (T5).
867    #[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    /// Storage-only, like `_journal_generation`: `Session` ignores it.
879    #[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
916/// Full-snapshot JSON with a fresh `message_count`, without cloning.
917/// Drop every pair whose key matches the secret denylist OR whose value
918/// carries credentials (a URL with userinfo). Shared by the `.json` snapshot
919/// and the journal meta tail so neither artifact can carry a credential
920/// value, whatever the client sent.
921fn 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
932/// Full-snapshot JSON with fresh count and optional storage-only binding.
933fn 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    // Prepare all serialization BEFORE the logical commit.
955    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    // Preflight journal symlinks in either mode, even JSON cleanup (which
968    // otherwise only unlinks). This does not claim multi-writer isolation.
969    open_artifact(handle, &format!("{}.journal", session.id))?;
970    handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
971    // Before destroying the old journal, the new snapshot must be durable.
972    // Binding prevents new-snapshot/old-journal replay, but without this sync
973    // a power loss could recover old-snapshot/new-journal and lose deltas.
974    #[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
1007// ─── load ────────────────────────────────────────────────────────────────────
1008
1009/// Load `<id>.json` and, when a journal exists, replay it idempotently.
1010/// Old-format sessions (no journal) load exactly as before; a session with
1011/// a torn or stale journal recovers to its last consistent state. Both
1012/// reads are confined nofollow bounded handle reads (fix1 I1).
1013pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
1014    // ONE strict resolution per load (fix2); both artifact reads below are
1015    // relative to this handle.
1016    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            // fix1 M1: an unknown-version record ends the valid prefix.
1054            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; // gap — stop at the consistent prefix
1067                }
1068                // i < len: already in the snapshot — idempotent skip.
1069            }
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, // torn tail
1076        }
1077    }
1078    // Keep-warm labels are snapshot-only (SNAPSHOT_ONLY_FIELDS): keep them
1079    // under the journal-replayed totals.
1080    session.clamp_keepwarm_to_totals();
1081    Ok(session)
1082}
1083
1084/// Freshest `meta` record from a bounded journal tail window, for listing
1085/// freshness without a full journal read. `None` when no journal, no
1086/// complete supported-version meta record, or a refused (non-confined)
1087/// artifact — a symlinked journal discloses nothing.
1088pub 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
1094/// Listings already read a snapshot header: use THAT generation rather than
1095/// reopening a possibly replaced snapshot and mixing two checkpoint heads.
1096pub(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
1140// ─── deletion ────────────────────────────────────────────────────────────────
1141
1142/// Remove a session's snapshot, journal AND turn draft (`session_draft`)
1143/// (compaction rollback, retention). Idempotent — missing files are not
1144/// errors.
1145pub 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(()); // no directory — idempotently nothing to delete
1148    };
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/// Strict-handle snapshot write shared with the legacy
1162/// `session::save_json_in_dir` path (fix2): same root resolution and the
1163/// same handle-relative atomic private write as every journal operation.
1164#[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// ─── tests ───────────────────────────────────────────────────────────────────
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    /// Drift guard: `SessionMeta` must mirror every non-history `Session`
1177    /// field. Serializing a full `Session`, dropping `api_messages`, and
1178    /// parsing the rest as `SessionMeta` (deny_unknown_fields) fails the
1179    /// moment `Session` grows a field this module does not journal.
1180    #[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; // stale in memory — snapshot must not trust it
1191                             // Expected = the old clone-and-refresh path, byte for byte.
1192        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        // Same check with every optional absent.
1199        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        // Set env with a normal var and a secret-looking var.
1251        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        // Load back.
1258        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1259        // Non-secret survives.
1260        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        // Secret value MUST be absent from the on-disk bytes.
1266        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        // …and the non-secret env IS in the `.json` snapshot itself (not only
1273        // the journal meta tail) — the snapshot is what a fresh daemon reads.
1274        assert!(
1275            snap_bytes.contains("\"env\"") && snap_bytes.contains("/usr/bin"),
1276            "snapshot must carry the scrubbed env: {snap_bytes}"
1277        );
1278        // Secret name IS present in env_stripped.
1279        assert_eq!(loaded.env_stripped, vec!["GH_TOKEN".to_string()]);
1280        // The secret KEY should not appear in the env pairs.
1281        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        // Every file the save wrote (snapshot + journal) — none may carry the credential.
1305        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        // message_count is a snapshot hint, not authoritative after replay.
1350        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    /// Recreate the exact two-file state of a crash after snapshot rename but
1391    /// before journal reset/unlink: keep the newly published snapshot and put
1392    /// back the old journal bytes. No clocks, processes, config, or real data.
1393    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        // Even same-length edits with an unchanged final message must commit.
1409        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); // old metadata would win by timestamp alone
1416        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        // A subsequent normal save must not append into the stale generation.
1429        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        // A generic Session serde mirror drops only the storage metadata.
1567        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); // never apply an unbound v2 delta
1573        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        // Restoring an older *v2* open also invalidates the whole overlay.
1588        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        // Snapshot fsync/rename/dir fsync succeeds; journal temp cleanup fails.
1625        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); // Err did NOT roll back the published head
1635        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    /// Private modes (spec §5.4): journal-mode saves keep the 0700 dir and
1641    /// 0600 files under a permissive umask, exactly like the legacy path.
1642    #[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            // Preflight refuses the journal before publishing a snapshot.
1750            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/// What one session save costs ON A REAL DISK — the number the per-round
1761/// save (`agent_engine` round checkpoints) pays. Not run by default: set
1762/// `SYNAPS_SAVE_BENCH_DIR` to a directory on the filesystem to measure (NOT
1763/// tmpfs, whose fsync is free), then
1764/// `cargo test -p synaps-core --release --lib save_cost_bench -- --ignored --nocapture`.
1765#[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        // ~4 KiB messages, alternating roles: a realistic mix of short
1774        // turns and tool output.
1775        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(); // warm up
1802                let mut samples = Vec::new();
1803                for round in 0..40 {
1804                    // Each round appends an assistant reply + tool result.
1805                    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/// Task #444, C-1: keep-warm labels are snapshot-only, and the journal stays
1828/// readable by 0.10.1.
1829#[cfg(test)]
1830mod snapshot_only_fields {
1831    use super::tests::{push_message, with_messages};
1832    use super::*;
1833
1834    /// Task #444: keep-warm spend persists through the snapshot in both
1835    /// modes (journal mode publishes a snapshot on the first save).
1836    #[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    /// The exact `SessionMeta` field set shipped in 0.10.1 (5c86d2ca), with
1864    /// its `deny_unknown_fields`. Kept frozen on purpose: it stands in for
1865    /// the older reader, which must keep parsing every meta record this
1866    /// build writes (C-1, task #444).
1867    #[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    /// 0.10.1's journal reader, reduced to the part that matters: every
1902    /// record must parse (an `Err` there is a torn tail that truncates the
1903    /// session and is then rewritten by `full_snapshot_reset`).
1904    #[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        // First save publishes the snapshot; the next two append deltas
1951        // (messages + meta records carrying every non-zero field).
1952        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        // And this build still reads back the full history, with the
1982        // keep-warm label as of the snapshot (stale until the next one).
1983        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    /// The cache read/write totals persist with the session, so a daemon
1992    /// reload or a resume keeps the footer's session cache hit rate instead
1993    /// of restarting it at the first (usually missed) request. Both modes;
1994    /// journal mode publishes a snapshot on the first save.
1995    #[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    /// Zero cache totals are not written, and a snapshot saved before the
2017    /// fields existed loads them as 0.
2018    #[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; // e.g. a stale snapshot label
2041        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    // ── turn ids stay out of persisted message objects (03 C-2) ──────────
2051
2052    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    /// What 0.10.2 (0862f255) loads: `Session` from the snapshot (it ignores
2067    /// unknown top-level fields) plus every journal `m`, unchanged. Its
2068    /// `session_journal`/`session` are byte-identical to this build's
2069    /// pre-fix code, so the frozen reader types above stand in for it.
2070    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                // 0.10.2 hashes the messages it loaded; equal hashes let it
2115                // keep appending instead of treating the journal as edited.
2116                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            // This build gets its turn ids back.
2122            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    /// The pre-fix `history_hash`: over the messages with the tag inline.
2132    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        // The pre-fix layout: the tag inline in the snapshot and the journal.
2144        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        // The next save rewrites it in the sibling layout.
2170        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    /// The rollback this layout exists for: this build saves, 0.10.2 opens
2196    /// the session and appends a turn, then this build reads it back.
2197    #[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        // 0.10.2: loads the stored form, checks its prefix hash (equal, so
2209        // it appends rather than rewriting), and appends one turn with no
2210        // `t` and no `turns_hash`.
2211        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        // This build: ids back on the same indices, the 0.10.2 turn untagged.
2232        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        // One full snapshot (the old meta has no turns hash), then appends.
2238        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    /// `_turn_ids` is best-effort metadata: a malformed value drops the ids,
2269    /// never the session.
2270    #[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}