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/// Message key carrying when the host recorded the message (RFC 3339 UTC)
45/// in memory. Persisted beside the message exactly like
46/// [`MESSAGE_TURN_KEY`] (`at` on journal `msg` records, `_message_times` in
47/// the snapshot), never inside it, for the same rollback reason.
48pub const MESSAGE_AT_KEY: &str = "_synaps_at";
49
50/// The recorded time of a message, if it has one.
51pub fn message_time(message: &serde_json::Value) -> Option<&str> {
52    message.get(MESSAGE_AT_KEY)?.as_str()
53}
54
55/// A stored time is kept only if it parses as RFC 3339.
56fn valid_time(ts: &str) -> bool {
57    DateTime::parse_from_rfc3339(ts).is_ok()
58}
59
60/// Journal record schema version (line-level `"v"` field).
61pub const JOURNAL_SCHEMA_VERSION: u8 = 2;
62
63/// Journal size floor before a periodic snapshot is forced.
64pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
65
66/// A snapshot is due when the journal outgrows `snapshot / RATIO` (bounded
67/// write amplification: large sessions stretch the threshold proportionally).
68pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
69
70/// Bounded tail window scanned for the freshest `meta` record.
71const META_TAIL_WINDOW: u64 = 64 * 1024;
72
73/// Hard cap on any single persisted-session artifact read (snapshot or
74/// journal). Reads are bounded on the OPENED handle; an artifact past the
75/// cap is refused rather than slurped.
76pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB
77
78// ─── confined resolution (fix1 I1 + fix2) ───────────────────────────────────
79//
80// STRICT TRUSTED-ROOT SEMANTICS (fix2): the sessions directory path is
81// resolved with EVERY component — ancestors AND the final one — opened
82// handle-relatively from `/` with symlinks refused
83// (`ConfinedDir::{open,create}_absolute_no_symlinks`; Linux uses one atomic
84// `openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS` from the root handle).
85// Nothing on the path is trusted; there is no check-then-open race — a
86// component swapped to a symlink at any moment fails the open itself.
87// Artifacts inside the directory are then opened/written/removed relative
88// to that ONE handle. Operators whose base dir legitimately sits behind
89// ancestor symlinks (e.g. `/home` → `var/home`) must point
90// `SYNAPS_BASE_DIR` at the canonical path.
91//
92// Non-unix keeps the crate's documented best-effort pathname fallback
93// (final-component symlink refusal only).
94
95#[cfg(unix)]
96type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
97
98/// Open the sessions dir strictly. `Ok(None)` when a path component does
99/// not exist; symlinks anywhere are errors.
100#[cfg(unix)]
101fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
102    match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
103        Ok(handle) => Ok(Some(handle)),
104        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
105        Err(e) => Err(e),
106    }
107}
108
109/// Create-or-open the sessions dir strictly (0700 leaf).
110#[cfg(unix)]
111fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
112    crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)
113}
114
115/// Open `<name>` inside an already-strictly-opened sessions dir.
116/// `Ok(None)` when the file does not exist; a symlinked artifact errors.
117#[cfg(unix)]
118fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
119    match handle.open_file(&[name.to_string()]) {
120        Ok(file) => Ok(Some(file)),
121        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
122        Err(e) => Err(e),
123    }
124}
125
126/// One-shot strict open of `<name>` inside `dir`.
127#[cfg(unix)]
128pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
129    let Some(handle) = open_sessions_dir(dir)? else {
130        return Ok(None);
131    };
132    open_artifact(&handle, name)
133}
134
135// ── Non-unix best-effort fallbacks (documented, matching `private_fs`) ──
136
137#[cfg(not(unix))]
138struct SessionsDirHandle {
139    dir: std::path::PathBuf,
140}
141
142#[cfg(not(unix))]
143fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
144    if !dir.exists() {
145        return Ok(None);
146    }
147    Ok(Some(SessionsDirHandle {
148        dir: dir.to_path_buf(),
149    }))
150}
151
152#[cfg(not(unix))]
153fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
154    crate::core::private_fs::ensure_private_dir(dir)?;
155    Ok(SessionsDirHandle {
156        dir: dir.to_path_buf(),
157    })
158}
159
160#[cfg(not(unix))]
161impl SessionsDirHandle {
162    fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
163        crate::core::private_fs::write_atomic_private(&self.dir.join(name), data)
164            .map_err(std::io::Error::other)
165    }
166    fn append_file(&self, name: &str) -> std::io::Result<std::fs::File> {
167        crate::core::private_fs::open_private_append(&self.dir.join(name))
168            .map_err(std::io::Error::other)
169    }
170    fn remove_file(&self, name: &str) -> std::io::Result<()> {
171        std::fs::remove_file(self.dir.join(name))
172    }
173}
174
175#[cfg(not(unix))]
176fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
177    let path = handle.dir.join(name);
178    match std::fs::symlink_metadata(&path) {
179        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
180        Ok(meta) if meta.file_type().is_symlink() => {
181            return Err(std::io::Error::other(format!(
182                "refusing symlinked session artifact {name:?}"
183            )))
184        }
185        _ => {}
186    }
187    match std::fs::File::open(&path) {
188        Ok(f) => Ok(Some(f)),
189        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
190        Err(e) => Err(e),
191    }
192}
193
194#[cfg(not(unix))]
195pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
196    let Some(handle) = open_sessions_dir(dir)? else {
197        return Ok(None);
198    };
199    open_artifact(&handle, name)
200}
201
202/// Open `<name>` inside `dir` to set its times (`SessionMtimes`). Unix sets
203/// them through the read-only descriptor `confined_open` returns.
204#[cfg(not(windows))]
205pub(crate) fn confined_open_for_times(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
206    confined_open(dir, name)
207}
208
209/// Windows sets times through a handle with `FILE_WRITE_ATTRIBUTES`, which a
210/// read-only open lacks: the restore failed silently and every rename moved
211/// the session to the top of `--continue` and `/sessions`. `confined_open`
212/// still refuses a symlink and reports a missing file as `None` first.
213#[cfg(windows)]
214pub(crate) fn confined_open_for_times(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
215    use std::os::windows::fs::OpenOptionsExt;
216    if confined_open(dir, name)?.is_none() {
217        return Ok(None);
218    }
219    match std::fs::OpenOptions::new()
220        .access_mode(windows_sys::Win32::Storage::FileSystem::FILE_WRITE_ATTRIBUTES)
221        .open(dir.join(name))
222    {
223        Ok(f) => Ok(Some(f)),
224        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
225        Err(e) => Err(e),
226    }
227}
228
229/// Bounded read of a whole artifact from an already-confined handle.
230fn read_artifact_bytes(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<Vec<u8>>> {
231    use std::io::Read;
232    let Some(file) = open_artifact(handle, name)? else {
233        return Ok(None);
234    };
235    let mut buf = Vec::new();
236    file.take(MAX_PERSISTED_READ_BYTES + 1)
237        .read_to_end(&mut buf)?;
238    if buf.len() as u64 > MAX_PERSISTED_READ_BYTES {
239        return Err(std::io::Error::other(format!(
240            "session artifact {name:?} exceeds the {MAX_PERSISTED_READ_BYTES}-byte read bound"
241        )));
242    }
243    Ok(Some(buf))
244}
245
246/// One strictly-resolved directory entry for session listings.
247#[derive(Debug, Clone)]
248pub struct SessionDirEntry {
249    pub name: String,
250    pub mtime: Option<std::time::SystemTime>,
251    /// Stored bytes for metadata-only accounting. Obtained from the directory
252    /// entry stat; reading it never opens session content.
253    pub byte_len: u64,
254}
255
256/// Enumerate the sessions dir through the SAME strict handle resolution as
257/// every other T35 operation (fix2): handle-relative `readdir` +
258/// `fstatat(AT_SYMLINK_NOFOLLOW)` — a symlinked ancestor refuses, a
259/// missing dir lists empty, and entry mtimes are of the entries
260/// themselves, never symlink targets.
261pub fn session_dir_entries(dir: &Path) -> std::io::Result<Vec<SessionDirEntry>> {
262    #[cfg(unix)]
263    {
264        let Some(handle) = open_sessions_dir(dir)? else {
265            return Ok(Vec::new());
266        };
267        Ok(handle
268            .entries()?
269            .into_iter()
270            .filter(|e| e.is_file)
271            .map(|e| SessionDirEntry {
272                name: e.name,
273                mtime: e.mtime_unix_ms.map(|ms| {
274                    std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(ms)
275                }),
276                byte_len: e.byte_len,
277            })
278            .collect())
279    }
280    #[cfg(not(unix))]
281    {
282        if !dir.exists() {
283            return Ok(Vec::new());
284        }
285        let mut out = Vec::new();
286        for entry in std::fs::read_dir(dir)? {
287            let entry = entry?;
288            if !entry.path().is_file() {
289                continue;
290            }
291            let Ok(name) = entry.file_name().into_string() else {
292                continue;
293            };
294            let byte_len = entry
295                .metadata()
296                .map(|metadata| metadata.len())
297                .unwrap_or_default();
298            out.push(SessionDirEntry {
299                name,
300                mtime: entry.metadata().and_then(|m| m.modified()).ok(),
301                byte_len,
302            });
303        }
304        Ok(out)
305    }
306}
307
308/// Which on-disk persistence strategy `Session::save` uses.
309///
310/// `Json` (the default) writes a full Session-shaped snapshot. `Journal` is
311/// the spec §9.8 opt-in and is only ever selected explicitly via the
312/// `session_persistence = journal` config key.
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
314pub enum SessionPersistence {
315    #[default]
316    Json,
317    Journal,
318}
319
320impl SessionPersistence {
321    /// Parse a config value; anything unrecognized yields `None` so the
322    /// caller keeps the safe default and surfaces a typed warning.
323    pub fn parse(val: &str) -> Option<Self> {
324        match val.trim() {
325            "json" => Some(Self::Json),
326            "journal" => Some(Self::Journal),
327            _ => None,
328        }
329    }
330}
331
332/// How a save landed on disk.
333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub enum SaveMode {
335    /// Full Session-shaped snapshot written (and, in journal mode, the
336    /// journal reset to a lone `open` record).
337    FullSnapshot,
338    /// Delta append: `messages` new history entries plus one meta record.
339    Append { messages: usize },
340}
341
342/// Machine-readable receipt for benchmarks and delta-proportionality tests.
343#[derive(Debug, Clone, Copy)]
344pub struct SaveReceipt {
345    pub mode: SaveMode,
346    pub bytes_written: u64,
347}
348
349/// Freshest journaled metadata, read from a bounded tail window — lets
350/// session listings stay accurate without parsing the full journal.
351#[derive(Debug, Clone)]
352pub struct JournalMetaTail {
353    pub updated_at: DateTime<Utc>,
354    pub session_cost: f64,
355    /// `None` for meta records written before the count was journaled.
356    pub message_count: Option<usize>,
357}
358
359/// `sessions/<id>.journal` — single-extension name so the session id stays
360/// the file stem (chain-head protection and retention pair on the stem).
361pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
362    dir.join(format!("{id}.journal"))
363}
364
365/// Snapshot threshold: journal bytes ≥ max(256 KiB, snapshot / 4).
366pub fn snapshot_due(journal_bytes: u64, snapshot_bytes: u64) -> bool {
367    journal_bytes >= JOURNAL_SNAPSHOT_MIN_BYTES.max(snapshot_bytes / JOURNAL_SNAPSHOT_RATIO)
368}
369
370// ─── journal records ─────────────────────────────────────────────────────────
371
372#[derive(Serialize, Deserialize)]
373#[serde(tag = "k")]
374enum JournalRecord {
375    /// First line of every journal: the snapshot held `base` messages.
376    #[serde(rename = "open")]
377    Open {
378        v: u8,
379        base: usize,
380        #[serde(default, skip_serializing_if = "Option::is_none")]
381        generation: Option<String>,
382        #[serde(default, skip_serializing_if = "Option::is_none")]
383        history_hash: Option<String>,
384        /// [`turns_hash`] of the same prefix; absent = no turn ids.
385        #[serde(default, skip_serializing_if = "Option::is_none")]
386        turns_hash: Option<String>,
387        /// [`times_hash`] of the same prefix; absent = no message times.
388        #[serde(default, skip_serializing_if = "Option::is_none")]
389        times_hash: Option<String>,
390    },
391    /// Message at ABSOLUTE history index `i`; `t` is its turn id and `at`
392    /// its recorded time, both kept beside the message (see
393    /// [`MESSAGE_TURN_KEY`], [`MESSAGE_AT_KEY`]).
394    #[serde(rename = "msg")]
395    Msg {
396        v: u8,
397        i: usize,
398        m: serde_json::Value,
399        #[serde(default, skip_serializing_if = "Option::is_none")]
400        /// Any JSON on read: a malformed id is dropped, it never ends replay.
401        t: Option<serde_json::Value>,
402        #[serde(default, skip_serializing_if = "Option::is_none")]
403        /// Any JSON on read: a malformed time is dropped, never fatal.
404        at: Option<serde_json::Value>,
405    },
406    /// Full session metadata (the `Session` object minus `api_messages`).
407    #[serde(rename = "meta")]
408    Meta {
409        v: u8,
410        meta: Box<SessionMeta>,
411        #[serde(default, skip_serializing_if = "Option::is_none")]
412        history_hash: Option<String>,
413        #[serde(default, skip_serializing_if = "Option::is_none")]
414        turns_hash: Option<String>,
415        #[serde(default, skip_serializing_if = "Option::is_none")]
416        times_hash: Option<String>,
417    },
418}
419
420fn message_turn_id(message: &serde_json::Value) -> Option<&str> {
421    message.get(MESSAGE_TURN_KEY)?.get("id")?.as_str()
422}
423
424/// In-memory host keys that are persisted beside a message, never inside.
425const SIBLING_KEYS: [&str; 2] = [MESSAGE_TURN_KEY, MESSAGE_AT_KEY];
426
427fn has_sibling_key(object: &serde_json::Map<String, serde_json::Value>) -> bool {
428    SIBLING_KEYS.iter().any(|k| object.contains_key(*k))
429}
430
431/// A message as persisted: the in-memory turn id and time removed.
432struct Stored<'a>(&'a serde_json::Value);
433
434impl Serialize for Stored<'_> {
435    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
436        use serde::ser::SerializeMap;
437        match self.0.as_object() {
438            Some(object) if has_sibling_key(object) => {
439                let kept = object.iter().filter(|(k, _)| !SIBLING_KEYS.contains(&k.as_str()));
440                let mut map = serializer.serialize_map(Some(kept.clone().count()))?;
441                for (k, v) in kept {
442                    map.serialize_entry(k, v)?;
443                }
444                map.end()
445            }
446            _ => self.0.serialize(serializer),
447        }
448    }
449}
450
451struct StoredMessages<'a>(&'a [SharedMessage]);
452
453impl Serialize for StoredMessages<'_> {
454    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
455        serializer.collect_seq(self.0.iter().map(|m| Stored(m)))
456    }
457}
458
459fn stored_value(message: &serde_json::Value) -> serde_json::Value {
460    let mut message = message.clone();
461    if let Some(object) = message.as_object_mut() {
462        for key in SIBLING_KEYS {
463            object.remove(key);
464        }
465    }
466    message
467}
468
469/// `(index, turn id)` of every tagged message, in order.
470fn turn_ids(messages: &[SharedMessage]) -> Vec<(usize, &str)> {
471    messages
472        .iter()
473        .enumerate()
474        .filter_map(|(i, m)| message_turn_id(m).map(|id| (i, id)))
475        .collect()
476}
477
478/// Turn ids are outside the stored messages, so [`history_hash`] cannot see
479/// a tag added to an already-saved message; this hash does. `None` = none.
480fn turns_hash(messages: &[SharedMessage]) -> std::io::Result<Option<String>> {
481    use sha2::{Digest, Sha256};
482    let ids = turn_ids(messages);
483    if ids.is_empty() {
484        return Ok(None);
485    }
486    let bytes = serde_json::to_vec(&ids).map_err(std::io::Error::other)?;
487    Ok(Some(format!("{:x}", Sha256::digest(&bytes))))
488}
489
490/// `(index, time)` of every timed message, in order.
491fn message_times(messages: &[SharedMessage]) -> Vec<(usize, &str)> {
492    messages
493        .iter()
494        .enumerate()
495        .filter_map(|(i, m)| message_time(m).map(|ts| (i, ts)))
496        .collect()
497}
498
499/// Like [`turns_hash`], for message times: a time added to (or changed on)
500/// an already-saved message forces a snapshot instead of an append that a
501/// replay would apply to a base without it. `None` = no times.
502fn times_hash(messages: &[SharedMessage]) -> std::io::Result<Option<String>> {
503    use sha2::{Digest, Sha256};
504    let times = message_times(messages);
505    if times.is_empty() {
506        return Ok(None);
507    }
508    let bytes = serde_json::to_vec(&times).map_err(std::io::Error::other)?;
509    Ok(Some(format!("{:x}", Sha256::digest(&bytes))))
510}
511
512fn set_message_time(message: &mut serde_json::Value, ts: String) {
513    if let Some(object) = message.as_object_mut() {
514        object.insert(MESSAGE_AT_KEY.into(), serde_json::Value::String(ts));
515    }
516}
517
518fn set_turn_id(message: &mut serde_json::Value, id: String) {
519    if let Some(object) = message.as_object_mut() {
520        object.insert(MESSAGE_TURN_KEY.into(), serde_json::json!({ "id": id }));
521    }
522}
523
524fn is_zero_cost(v: &f64) -> bool {
525    *v == 0.0
526}
527
528fn is_zero_tokens(v: &u64) -> bool {
529    *v == 0
530}
531
532/// `Session` fields that live ONLY in the `.json` snapshot and are never
533/// journaled. `SessionMeta` is `deny_unknown_fields` in every shipped build
534/// (0.10.1 included): a new field in a meta record makes those builds read
535/// it as a torn tail, truncate the loaded history, and then overwrite the
536/// journal with that truncation on the next save. The snapshot `Session`
537/// tolerates unknown fields, so new metadata goes there instead.
538#[cfg_attr(not(test), allow(dead_code))]
539const SNAPSHOT_ONLY_FIELDS: &[&str] = &[
540    "keepwarm_cost",
541    "keepwarm_tokens",
542    "total_cache_read_tokens",
543    "total_cache_creation_tokens",
544];
545
546/// Mirror of [`Session`] WITHOUT `api_messages` and the
547/// [`SNAPSHOT_ONLY_FIELDS`], with identical serde attributes.
548/// `deny_unknown_fields` + the `session_meta_stays_in_sync_…` test make
549/// silent drift between the two types impossible. Do NOT add fields here:
550/// older builds reject them (see [`SNAPSHOT_ONLY_FIELDS`]).
551#[derive(Clone, Serialize, Deserialize)]
552#[serde(deny_unknown_fields)]
553struct SessionMeta {
554    id: String,
555    title: String,
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    name: Option<String>,
558    model: String,
559    thinking_level: String,
560    system_prompt: Option<String>,
561    created_at: DateTime<Utc>,
562    updated_at: DateTime<Utc>,
563    total_input_tokens: u64,
564    total_output_tokens: u64,
565    session_cost: f64,
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    message_count: Option<usize>,
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    abort_context: Option<String>,
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    parent_session: Option<String>,
572    #[serde(default, skip_serializing_if = "Option::is_none")]
573    compacted_into: Option<String>,
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    env: Option<Vec<(String, String)>>,
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    env_stripped: Vec<String>,
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    prompt_provenance: Option<crate::prompt::PromptProvenance>,
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    compaction: Option<crate::core::compaction::CompactionRecord>,
582}
583
584impl SessionMeta {
585    fn of(s: &Session) -> Self {
586        // Belt-and-braces: scrub any secret values from the env on the
587        // write path so the journal on disk NEVER contains a credential,
588        // even if a future client forgets to strip.
589        let env = scrub_secret_env(s.env.as_ref());
590        Self {
591            id: s.id.clone(),
592            title: s.title.clone(),
593            name: s.name.clone(),
594            model: s.model.clone(),
595            thinking_level: s.thinking_level.clone(),
596            system_prompt: s.system_prompt.clone(),
597            created_at: s.created_at,
598            updated_at: s.updated_at,
599            total_input_tokens: s.total_input_tokens,
600            total_output_tokens: s.total_output_tokens,
601            session_cost: s.session_cost,
602            message_count: Some(s.api_messages.len()),
603            abort_context: s.abort_context.clone(),
604            parent_session: s.parent_session.clone(),
605            compacted_into: s.compacted_into.clone(),
606            env,
607            env_stripped: s.env_stripped.clone(),
608            prompt_provenance: s.prompt_provenance.clone(),
609            compaction: s.compaction.clone(),
610        }
611    }
612
613    /// Apply onto a loaded session. Only metadata moves — never history.
614    fn apply(self, s: &mut Session) {
615        s.title = self.title;
616        s.name = self.name;
617        s.model = self.model;
618        s.thinking_level = self.thinking_level;
619        s.system_prompt = self.system_prompt;
620        s.created_at = self.created_at;
621        s.updated_at = self.updated_at;
622        s.total_input_tokens = self.total_input_tokens;
623        s.total_output_tokens = self.total_output_tokens;
624        s.session_cost = self.session_cost;
625        s.abort_context = self.abort_context;
626        s.parent_session = self.parent_session;
627        s.compacted_into = self.compacted_into;
628        s.env = self.env;
629        s.env_stripped = self.env_stripped;
630        s.prompt_provenance = self.prompt_provenance;
631        s.compaction = self.compaction;
632    }
633}
634
635/// A binding is deliberately outside Session/SessionMeta. It is emitted
636/// before `api_messages`, so listings and append checks need only the header.
637#[derive(Default, Deserialize)]
638struct SnapshotBinding {
639    #[serde(default, rename = "_journal_generation")]
640    generation: Option<String>,
641    /// After `api_messages`, so header-only reads leave it empty. Parsed
642    /// leniently by [`stored_turn_ids`]: it is best-effort metadata.
643    #[serde(default, rename = "_turn_ids")]
644    turn_ids: serde_json::Value,
645    /// Same placement and leniency as `_turn_ids` ([`stored_message_times`]).
646    #[serde(default, rename = "_message_times")]
647    message_times: serde_json::Value,
648}
649
650/// Well-formed `[index, RFC 3339 time]` entries of a snapshot's
651/// `_message_times`. Anything else is dropped with a warning: the message
652/// then simply shows no time.
653fn stored_message_times(value: serde_json::Value) -> Vec<(usize, String)> {
654    let entries = match value {
655        serde_json::Value::Null => return Vec::new(),
656        serde_json::Value::Array(entries) => entries,
657        _ => {
658            tracing::warn!("session snapshot has a malformed _message_times; times dropped");
659            return Vec::new();
660        }
661    };
662    let total = entries.len();
663    let times: Vec<(usize, String)> = entries
664        .into_iter()
665        .filter_map(|entry| serde_json::from_value::<(usize, String)>(entry).ok())
666        .filter(|(_, ts)| valid_time(ts))
667        .collect();
668    if times.len() != total {
669        tracing::warn!(dropped = total - times.len(), "malformed _message_times entries dropped");
670    }
671    times
672}
673
674/// A journal `at`: kept only when it is an RFC 3339 string.
675fn journal_time(at: Option<serde_json::Value>) -> Option<String> {
676    match at? {
677        serde_json::Value::String(ts) if valid_time(&ts) => Some(ts),
678        _ => {
679            tracing::warn!("malformed journal message time dropped");
680            None
681        }
682    }
683}
684
685/// Well-formed `[index, id]` entries of a snapshot's `_turn_ids`. Anything
686/// else is dropped with a warning; the turn start is then re-derived.
687fn stored_turn_ids(value: serde_json::Value) -> Vec<(usize, String)> {
688    let entries = match value {
689        serde_json::Value::Null => return Vec::new(),
690        serde_json::Value::Array(entries) => entries,
691        _ => {
692            tracing::warn!("session snapshot has a malformed _turn_ids; turn ids dropped");
693            return Vec::new();
694        }
695    };
696    let total = entries.len();
697    let ids: Vec<(usize, String)> = entries
698        .into_iter()
699        .filter_map(|entry| serde_json::from_value(entry).ok())
700        .collect();
701    if ids.len() != total {
702        tracing::warn!(dropped = total - ids.len(), "malformed _turn_ids entries dropped");
703    }
704    ids
705}
706
707fn snapshot_binding(file: std::fs::File) -> std::io::Result<SnapshotBinding> {
708    let header = crate::core::session::read_session_header_from_file(file)
709        .ok_or_else(|| std::io::Error::other("cannot read session snapshot header"))?;
710    serde_json::from_str(&header).map_err(std::io::Error::other)
711}
712
713/// v1 is interpretable only against an unmarked legacy snapshot. A bound
714/// snapshot must never fall back to v1, even for an empty/torn v2 journal.
715fn matching_open(record: &JournalRecord, snapshot_generation: Option<&str>) -> Option<u8> {
716    match record {
717        JournalRecord::Open {
718            v: 1,
719            generation: None,
720            ..
721        } if snapshot_generation.is_none() => Some(1),
722        JournalRecord::Open {
723            v,
724            generation: Some(generation),
725            ..
726        } if *v == JOURNAL_SCHEMA_VERSION
727            && !generation.is_empty()
728            && Some(generation.as_str()) == snapshot_generation =>
729        {
730            Some(*v)
731        }
732        _ => None,
733    }
734}
735
736/// Hash the exact ordered prefix as stored (what an older build loads and
737/// hashes), including all stored message fields. Unlike the
738/// old last-message tripwire this detects same-length edits anywhere, even
739/// when the journal contains only its open record. Uses the existing sha2 dep.
740fn history_hash(messages: &[SharedMessage]) -> std::io::Result<String> {
741    use sha2::{Digest, Sha256};
742    struct HashWriter(Sha256);
743    impl Write for HashWriter {
744        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
745            self.0.update(bytes);
746            Ok(bytes.len())
747        }
748        fn flush(&mut self) -> std::io::Result<()> {
749            Ok(())
750        }
751    }
752    let mut writer = HashWriter(Sha256::new());
753    serde_json::to_writer(&mut writer, &StoredMessages(messages)).map_err(std::io::Error::other)?;
754    Ok(format!("{:x}", writer.0.finalize()))
755}
756
757/// Only clean, generation-matched v2 journals can be appended to. Legacy
758/// saves migrate through a full snapshot; torn/gapped tails resnapshot too.
759struct JournalState {
760    durable_len: usize,
761    history_hash: String,
762    turns_hash: Option<String>,
763    times_hash: Option<String>,
764    bytes: u64,
765}
766
767fn read_journal_state(
768    handle: &SessionsDirHandle,
769    id: &str,
770    generation: Option<&str>,
771) -> std::io::Result<Option<JournalState>> {
772    let Some(raw) = read_artifact_bytes(handle, &format!("{id}.journal"))? else {
773        return Ok(None);
774    };
775    if !raw.ends_with(b"\n") {
776        return Ok(None); // appending after an unterminated line would hide data
777    }
778    let Ok(text) = std::str::from_utf8(&raw) else {
779        return Ok(None);
780    };
781    let mut lines = text.lines();
782    let Some(open) = lines
783        .next()
784        .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
785    else {
786        return Ok(None);
787    };
788    if matching_open(&open, generation) != Some(JOURNAL_SCHEMA_VERSION) {
789        return Ok(None);
790    }
791    let JournalRecord::Open {
792        base,
793        history_hash: Some(mut hash),
794        turns_hash: mut turns,
795        times_hash: mut times,
796        ..
797    } = open
798    else {
799        return Ok(None);
800    };
801    let mut durable_len = base;
802    let mut hashed_len = base;
803    for line in lines {
804        match serde_json::from_str::<JournalRecord>(line) {
805            Ok(JournalRecord::Msg { v, i, .. })
806                if v == JOURNAL_SCHEMA_VERSION && i == durable_len =>
807            {
808                durable_len += 1;
809            }
810            Ok(JournalRecord::Meta {
811                v,
812                meta,
813                history_hash: Some(next_hash),
814                turns_hash: next_turns,
815                times_hash: next_times,
816            }) if v == JOURNAL_SCHEMA_VERSION
817                && meta.id == id
818                && meta.message_count == Some(durable_len) =>
819            {
820                hash = next_hash;
821                turns = next_turns;
822                times = next_times;
823                hashed_len = durable_len;
824            }
825            _ => return Ok(None),
826        }
827    }
828    if hashed_len != durable_len {
829        return Ok(None); // messages without a complete committing meta record
830    }
831    Ok(Some(JournalState {
832        durable_len,
833        history_hash: hash,
834        turns_hash: turns,
835        times_hash: times,
836        bytes: raw.len() as u64,
837    }))
838}
839
840// ─── save ────────────────────────────────────────────────────────────────────
841
842/// Persist under the configured mode. Journal-bound replacement syncs the
843/// snapshot directory before discarding old deltas; normal JSON-only saves do
844/// not promise directory durability. Use [`save_session_durable_in_dir`] for a
845/// full context-head barrier. Snapshot replacements are generation-safe in both.
846pub fn save_session_in_dir(
847    dir: &Path,
848    session: &Session,
849    mode: SessionPersistence,
850) -> std::io::Result<SaveReceipt> {
851    let handle = create_sessions_dir(dir)?;
852    match mode {
853        SessionPersistence::Json => {
854            // Keep virgin JSON-only saves byte-compatible. Once a journal or
855            // generation exists, never publish an unmarked snapshot again.
856            let journal = open_artifact(&handle, &format!("{}.journal", session.id))?;
857            let bound =
858                open_artifact(&handle, &format!("{}.json", session.id))?.is_some_and(|file| {
859                    // An unreadable/oversized header must not prevent repair
860                    // by a full save; conservatively rotate the generation.
861                    snapshot_binding(file).map_or(true, |b| b.generation.is_some())
862                });
863            publish_snapshot(&handle, session, mode, false, journal.is_some() || bound)
864        }
865        SessionPersistence::Journal => save_journal_mode(&handle, session),
866    }
867}
868
869/// Blocking body of `Session::save_durable`: a full generation-bound snapshot
870/// in either mode. Fsync the file, rename it, fsync the directory, then clean
871/// up the journal and fsync the directory again. Snapshot rename is the logical
872/// commit; any later error is returned without rollback. Retry/reload is safe
873/// with stale journals. Concurrent saves of the same session are NOT supported.
874pub fn save_session_durable_in_dir(
875    dir: &Path,
876    session: &Session,
877    mode: SessionPersistence,
878) -> std::io::Result<SaveReceipt> {
879    #[cfg(unix)]
880    {
881        let handle = SessionsDirHandle::create_absolute_no_symlinks_durable(dir)?;
882        publish_snapshot(&handle, session, mode, true, true)
883    }
884    #[cfg(not(unix))]
885    {
886        // The file is fsynced before its rename; see `sync_sessions_dir`
887        // for the directory step.
888        let handle = create_sessions_dir(dir)?;
889        publish_snapshot(&handle, session, mode, true, true)
890    }
891}
892
893fn save_journal_mode(
894    handle: &SessionsDirHandle,
895    session: &Session,
896) -> std::io::Result<SaveReceipt> {
897    let journal_name = format!("{}.journal", session.id);
898
899    let snapshot = open_artifact(handle, &format!("{}.json", session.id))?;
900    let Some(snapshot) = snapshot else {
901        return full_snapshot_reset(handle, session);
902    };
903    let snapshot_bytes = snapshot.metadata()?.len();
904    let Ok(binding) = snapshot_binding(snapshot) else {
905        return full_snapshot_reset(handle, session);
906    };
907    let Some(state) = read_journal_state(handle, &session.id, binding.generation.as_deref())?
908    else {
909        return full_snapshot_reset(handle, session);
910    };
911
912    if session.api_messages.len() < state.durable_len
913        || history_hash(&session.api_messages[..state.durable_len])? != state.history_hash
914        || turns_hash(&session.api_messages[..state.durable_len])? != state.turns_hash
915        || times_hash(&session.api_messages[..state.durable_len])? != state.times_hash
916    {
917        return full_snapshot_reset(handle, session);
918    }
919
920    // Delta append: new messages (absolute indices) + one meta record.
921    let mut buf = Vec::new();
922    for (offset, msg) in session.api_messages[state.durable_len..].iter().enumerate() {
923        let record = JournalRecord::Msg {
924            v: JOURNAL_SCHEMA_VERSION,
925            i: state.durable_len + offset,
926            m: stored_value(msg),
927            t: message_turn_id(msg).map(serde_json::Value::from),
928            at: message_time(msg).map(serde_json::Value::from),
929        };
930        serde_json::to_writer(&mut buf, &record).map_err(std::io::Error::other)?;
931        buf.push(b'\n');
932    }
933    let meta = JournalRecord::Meta {
934        v: JOURNAL_SCHEMA_VERSION,
935        meta: Box::new(SessionMeta::of(session)),
936        history_hash: Some(history_hash(&session.api_messages)?),
937        turns_hash: turns_hash(&session.api_messages)?,
938        times_hash: times_hash(&session.api_messages)?,
939    };
940    serde_json::to_writer(&mut buf, &meta).map_err(std::io::Error::other)?;
941    buf.push(b'\n');
942
943    let appended = session.api_messages.len() - state.durable_len;
944    let mut file = handle.append_file(&journal_name)?;
945    file.write_all(&buf)?;
946    file.sync_data()?;
947    drop(file);
948
949    // Periodic snapshot: its fresh generation invalidates the old journal
950    // at publication, before the journal reset can occur.
951    let journal_bytes = state.bytes + buf.len() as u64;
952    if snapshot_due(journal_bytes, snapshot_bytes) {
953        let reset = full_snapshot_reset(handle, session)?;
954        return Ok(SaveReceipt {
955            mode: SaveMode::FullSnapshot,
956            bytes_written: buf.len() as u64 + reset.bytes_written,
957        });
958    }
959
960    Ok(SaveReceipt {
961        mode: SaveMode::Append { messages: appended },
962        bytes_written: buf.len() as u64,
963    })
964}
965
966/// Borrowing mirror of [`Session`] used ONLY for snapshot serialization:
967/// identical field order and serde attributes, with `message_count`
968/// computed from `api_messages.len()` at write time instead of read from
969/// the (non-authoritative) in-memory field. Field order matters —
970/// `message_count` must precede `api_messages` for `read_session_header`.
971/// The `snapshot_json_matches_session_schema` test guards drift.
972#[derive(Serialize)]
973struct SessionSnapshotRef<'a> {
974    // Storage-only metadata, omitted for legacy JSON-only saves. Session
975    // serde deliberately ignores it; it is not journaled as SessionMeta.
976    #[serde(skip_serializing_if = "Option::is_none")]
977    _journal_generation: Option<&'a str>,
978    id: &'a str,
979    title: &'a str,
980    #[serde(skip_serializing_if = "Option::is_none")]
981    name: &'a Option<String>,
982    model: &'a str,
983    thinking_level: &'a str,
984    system_prompt: &'a Option<String>,
985    created_at: &'a DateTime<Utc>,
986    updated_at: &'a DateTime<Utc>,
987    total_input_tokens: u64,
988    total_output_tokens: u64,
989    session_cost: f64,
990    #[serde(skip_serializing_if = "is_zero_cost")]
991    keepwarm_cost: f64,
992    #[serde(skip_serializing_if = "KeepwarmTokens::is_zero")]
993    keepwarm_tokens: KeepwarmTokens,
994    #[serde(skip_serializing_if = "is_zero_tokens")]
995    total_cache_read_tokens: u64,
996    #[serde(skip_serializing_if = "is_zero_tokens")]
997    total_cache_creation_tokens: u64,
998    message_count: usize,
999    #[serde(skip_serializing_if = "Option::is_none")]
1000    parent_session: &'a Option<String>,
1001    #[serde(skip_serializing_if = "Option::is_none")]
1002    compacted_into: &'a Option<String>,
1003    /// Owned (not borrowed) because secret keys are scrubbed on the write
1004    /// path — the `.json` snapshot must never carry a credential (T5).
1005    #[serde(skip_serializing_if = "Option::is_none")]
1006    env: Option<Vec<(String, String)>>,
1007    #[serde(skip_serializing_if = "<[String]>::is_empty")]
1008    env_stripped: &'a [String],
1009    api_messages: StoredMessages<'a>,
1010    #[serde(skip_serializing_if = "Option::is_none")]
1011    abort_context: &'a Option<String>,
1012    #[serde(skip_serializing_if = "Option::is_none")]
1013    prompt_provenance: &'a Option<crate::prompt::PromptProvenance>,
1014    #[serde(skip_serializing_if = "Option::is_none")]
1015    compaction: &'a Option<crate::core::compaction::CompactionRecord>,
1016    /// Storage-only, like `_journal_generation`: `Session` ignores it.
1017    #[serde(rename = "_turn_ids", skip_serializing_if = "Vec::is_empty")]
1018    turn_ids: Vec<(usize, &'a str)>,
1019    /// Storage-only too: each message's recorded time, beside it.
1020    #[serde(rename = "_message_times", skip_serializing_if = "Vec::is_empty")]
1021    message_times: Vec<(usize, &'a str)>,
1022}
1023
1024impl<'a> SessionSnapshotRef<'a> {
1025    fn of(s: &'a Session, generation: Option<&'a str>) -> Self {
1026        Self {
1027            _journal_generation: generation,
1028            id: &s.id,
1029            title: &s.title,
1030            name: &s.name,
1031            model: &s.model,
1032            thinking_level: &s.thinking_level,
1033            system_prompt: &s.system_prompt,
1034            created_at: &s.created_at,
1035            updated_at: &s.updated_at,
1036            total_input_tokens: s.total_input_tokens,
1037            total_output_tokens: s.total_output_tokens,
1038            session_cost: s.session_cost,
1039            keepwarm_cost: s.keepwarm_cost,
1040            keepwarm_tokens: s.keepwarm_tokens,
1041            total_cache_read_tokens: s.total_cache_read_tokens,
1042            total_cache_creation_tokens: s.total_cache_creation_tokens,
1043            message_count: s.api_messages.len(),
1044            parent_session: &s.parent_session,
1045            compacted_into: &s.compacted_into,
1046            env: scrub_secret_env(s.env.as_ref()),
1047            env_stripped: &s.env_stripped,
1048            api_messages: StoredMessages(&s.api_messages),
1049            abort_context: &s.abort_context,
1050            prompt_provenance: &s.prompt_provenance,
1051            compaction: &s.compaction,
1052            turn_ids: turn_ids(&s.api_messages),
1053            message_times: message_times(&s.api_messages),
1054        }
1055    }
1056}
1057
1058/// Full-snapshot JSON with a fresh `message_count`, without cloning.
1059/// Drop every pair whose key matches the secret denylist OR whose value
1060/// carries credentials (a URL with userinfo). Shared by the `.json` snapshot
1061/// and the journal meta tail so neither artifact can carry a credential
1062/// value, whatever the client sent.
1063fn scrub_secret_env(env: Option<&Vec<(String, String)>>) -> Option<Vec<(String, String)>> {
1064    use crate::core::config::is_secret_env;
1065    env.map(|pairs| {
1066        pairs
1067            .iter()
1068            .filter(|(k, v)| !is_secret_env(k, v))
1069            .cloned()
1070            .collect()
1071    })
1072}
1073
1074/// Full-snapshot JSON with fresh count and optional storage-only binding.
1075fn snapshot_json(session: &Session, generation: Option<&str>) -> std::io::Result<String> {
1076    serde_json::to_string(&SessionSnapshotRef::of(session, generation))
1077        .map_err(std::io::Error::other)
1078}
1079
1080fn full_snapshot_reset(
1081    handle: &SessionsDirHandle,
1082    session: &Session,
1083) -> std::io::Result<SaveReceipt> {
1084    publish_snapshot(handle, session, SessionPersistence::Journal, false, true)
1085}
1086
1087fn publish_snapshot(
1088    handle: &SessionsDirHandle,
1089    session: &Session,
1090    mode: SessionPersistence,
1091    durable: bool,
1092    bind: bool,
1093) -> std::io::Result<SaveReceipt> {
1094    let generation = bind.then(|| uuid::Uuid::new_v4().to_string());
1095    let json = snapshot_json(session, generation.as_deref())?;
1096    // Prepare all serialization BEFORE the logical commit.
1097    let mut journal = Vec::new();
1098    if mode == SessionPersistence::Journal {
1099        let open = JournalRecord::Open {
1100            v: JOURNAL_SCHEMA_VERSION,
1101            base: session.api_messages.len(),
1102            generation,
1103            history_hash: Some(history_hash(&session.api_messages)?),
1104            turns_hash: turns_hash(&session.api_messages)?,
1105            times_hash: times_hash(&session.api_messages)?,
1106        };
1107        serde_json::to_writer(&mut journal, &open).map_err(std::io::Error::other)?;
1108        journal.push(b'\n');
1109    }
1110    // Preflight journal symlinks in either mode, even JSON cleanup (which
1111    // otherwise only unlinks). This does not claim multi-writer isolation.
1112    open_artifact(handle, &format!("{}.journal", session.id))?;
1113    handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
1114    // Before destroying the old journal, the new snapshot must be durable.
1115    // Binding prevents new-snapshot/old-journal replay, but without this sync
1116    // a power loss could recover old-snapshot/new-journal and lose deltas.
1117    #[cfg(unix)]
1118    if durable || bind {
1119        sync_sessions_dir(handle)?;
1120    }
1121    if mode == SessionPersistence::Journal {
1122        handle.write_atomic(&format!("{}.journal", session.id), &journal)?;
1123    } else {
1124        remove_artifact_if_exists(handle, &format!("{}.journal", session.id))?;
1125    }
1126    if durable {
1127        sync_sessions_dir(handle)?;
1128    }
1129    Ok(SaveReceipt {
1130        mode: SaveMode::FullSnapshot,
1131        bytes_written: json.len() as u64 + journal.len() as u64,
1132    })
1133}
1134
1135fn sync_sessions_dir(handle: &SessionsDirHandle) -> std::io::Result<()> {
1136    #[cfg(unix)]
1137    {
1138        handle.sync_all()
1139    }
1140    // Windows has no directory fsync: a directory can't be opened for
1141    // `FlushFileBuffers`, and NTFS journals the rename's metadata itself.
1142    // Each file was fsynced before its rename. PostgreSQL makes the same
1143    // call there. Refusing instead failed every durable save, so a rename,
1144    // and a compaction or rollover's context-head save, which then blocks
1145    // the session until it is reloaded.
1146    #[cfg(not(unix))]
1147    {
1148        let _ = handle;
1149        Ok(())
1150    }
1151}
1152
1153// ─── load ────────────────────────────────────────────────────────────────────
1154
1155/// Load `<id>.json` and, when a journal exists, replay it idempotently.
1156/// Old-format sessions (no journal) load exactly as before; a session with
1157/// a torn or stale journal recovers to its last consistent state. Both
1158/// reads are confined nofollow bounded handle reads (fix1 I1).
1159pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
1160    // ONE strict resolution per load (fix2); both artifact reads below are
1161    // relative to this handle.
1162    let handle = open_sessions_dir(dir)?.ok_or_else(|| {
1163        std::io::Error::new(
1164            std::io::ErrorKind::NotFound,
1165            format!("no sessions directory for '{id}'"),
1166        )
1167    })?;
1168    let snapshot = read_artifact_bytes(&handle, &format!("{id}.json"))?.ok_or_else(|| {
1169        std::io::Error::new(
1170            std::io::ErrorKind::NotFound,
1171            format!("no session snapshot for '{id}'"),
1172        )
1173    })?;
1174    let binding: SnapshotBinding =
1175        serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
1176    let mut session: Session = serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
1177    session.clamp_keepwarm_to_totals();
1178    for (i, id) in stored_turn_ids(binding.turn_ids) {
1179        if let Some(message) = session.api_messages.get_mut(i) {
1180            set_turn_id(std::sync::Arc::make_mut(message), id);
1181        }
1182    }
1183    for (i, ts) in stored_message_times(binding.message_times) {
1184        if let Some(message) = session.api_messages.get_mut(i) {
1185            set_message_time(std::sync::Arc::make_mut(message), ts);
1186        }
1187    }
1188
1189    let Some(raw) = read_artifact_bytes(&handle, &format!("{id}.journal"))? else {
1190        return Ok(session);
1191    };
1192    let text = String::from_utf8_lossy(&raw);
1193    let mut lines = text.lines();
1194    let version = match lines
1195        .next()
1196        .and_then(|s| serde_json::from_str::<JournalRecord>(s).ok())
1197        .and_then(|open| matching_open(&open, binding.generation.as_deref()))
1198    {
1199        Some(version) => version,
1200        None => return Ok(session),
1201    };
1202    for line in lines {
1203        match serde_json::from_str::<JournalRecord>(line) {
1204            // fix1 M1: an unknown-version record ends the valid prefix.
1205            Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
1206                if v != version =>
1207            {
1208                break;
1209            }
1210            Ok(JournalRecord::Msg { i, mut m, t, at, .. }) => {
1211                if i == session.api_messages.len() {
1212                    if let Some(serde_json::Value::String(id)) = t {
1213                        set_turn_id(&mut m, id);
1214                    }
1215                    if let Some(ts) = journal_time(at) {
1216                        set_message_time(&mut m, ts);
1217                    }
1218                    session.api_messages.push(std::sync::Arc::new(m));
1219                } else if i > session.api_messages.len() {
1220                    break; // gap — stop at the consistent prefix
1221                }
1222                // i < len: already in the snapshot — idempotent skip.
1223            }
1224            Ok(JournalRecord::Meta { meta, .. }) => {
1225                if meta.id == session.id && meta.updated_at >= session.updated_at {
1226                    meta.apply(&mut session);
1227                }
1228            }
1229            Ok(JournalRecord::Open { .. }) | Err(_) => break, // torn tail
1230        }
1231    }
1232    // Keep-warm labels are snapshot-only (SNAPSHOT_ONLY_FIELDS): keep them
1233    // under the journal-replayed totals.
1234    session.clamp_keepwarm_to_totals();
1235    Ok(session)
1236}
1237
1238/// Freshest `meta` record from a bounded journal tail window, for listing
1239/// freshness without a full journal read. `None` when no journal, no
1240/// complete supported-version meta record, or a refused (non-confined)
1241/// artifact — a symlinked journal discloses nothing.
1242pub fn journal_meta_tail(dir: &Path, id: &str) -> Option<JournalMetaTail> {
1243    let handle = open_sessions_dir(dir).ok()??;
1244    let binding = snapshot_binding(open_artifact(&handle, &format!("{id}.json")).ok()??).ok()?;
1245    journal_meta_tail_from_handle(&handle, id, binding.generation.as_deref())
1246}
1247
1248/// Listings already read a snapshot header: use THAT generation rather than
1249/// reopening a possibly replaced snapshot and mixing two checkpoint heads.
1250pub(crate) fn journal_meta_tail_for_generation(
1251    dir: &Path,
1252    id: &str,
1253    generation: Option<&str>,
1254) -> Option<JournalMetaTail> {
1255    let handle = open_sessions_dir(dir).ok()??;
1256    journal_meta_tail_from_handle(&handle, id, generation)
1257}
1258
1259fn journal_meta_tail_from_handle(
1260    handle: &SessionsDirHandle,
1261    id: &str,
1262    generation: Option<&str>,
1263) -> Option<JournalMetaTail> {
1264    use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
1265    let mut file = open_artifact(handle, &format!("{id}.journal")).ok()??;
1266    let mut first = String::new();
1267    BufReader::new((&mut file).take(4096))
1268        .read_line(&mut first)
1269        .ok()?;
1270    let open: JournalRecord = serde_json::from_str(&first).ok()?;
1271    let version = matching_open(&open, generation)?;
1272    let len = file.metadata().ok()?.len();
1273    let start = len.saturating_sub(META_TAIL_WINDOW);
1274    file.seek(SeekFrom::Start(start)).ok()?;
1275    let mut tail = String::new();
1276    file.take(META_TAIL_WINDOW).read_to_string(&mut tail).ok()?;
1277
1278    let mut freshest = None;
1279    for line in tail.lines() {
1280        if let Ok(JournalRecord::Meta { v, meta, .. }) = serde_json::from_str::<JournalRecord>(line)
1281        {
1282            if v == version && meta.id == id {
1283                freshest = Some(JournalMetaTail {
1284                    updated_at: meta.updated_at,
1285                    session_cost: meta.session_cost,
1286                    message_count: meta.message_count,
1287                });
1288            }
1289        }
1290    }
1291    freshest
1292}
1293
1294// ─── deletion ────────────────────────────────────────────────────────────────
1295
1296/// Remove a session's snapshot, journal AND turn draft (`session_draft`)
1297/// (compaction rollback, retention). Idempotent — missing files are not
1298/// errors.
1299pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
1300    let Some(handle) = open_sessions_dir(dir)? else {
1301        return Ok(()); // no directory — idempotently nothing to delete
1302    };
1303    remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
1304    remove_artifact_if_exists(&handle, &format!("{id}.journal"))?;
1305    remove_artifact_if_exists(&handle, &format!("{id}.turn"))
1306}
1307
1308fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
1309    match handle.remove_file(name) {
1310        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1311        other => other,
1312    }
1313}
1314
1315/// Strict-handle snapshot write shared with the legacy
1316/// `session::save_json_in_dir` path (fix2): same root resolution and the
1317/// same handle-relative atomic private write as every journal operation.
1318#[cfg(test)]
1319pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
1320    let handle = create_sessions_dir(dir)?;
1321    handle.write_atomic(&format!("{id}.json"), json)
1322}
1323
1324// ─── tests ───────────────────────────────────────────────────────────────────
1325
1326#[cfg(test)]
1327mod tests {
1328    use super::*;
1329
1330    /// Drift guard: `SessionMeta` must mirror every non-history `Session`
1331    /// field. Serializing a full `Session`, dropping `api_messages`, and
1332    /// parsing the rest as `SessionMeta` (deny_unknown_fields) fails the
1333    /// moment `Session` grows a field this module does not journal.
1334    #[test]
1335    fn snapshot_json_matches_session_schema() {
1336        let mut s = Session::new("model-x", "medium", Some("prompt"));
1337        s.name = Some("named".into());
1338        s.abort_context = Some("ctx".into());
1339        s.parent_session = Some("parent".into());
1340        s.compacted_into = Some("child".into());
1341        s.api_messages.push(std::sync::Arc::new(
1342            serde_json::json!({"role": "user", "content": "hi"}),
1343        ));
1344        s.message_count = 0; // stale in memory — snapshot must not trust it
1345                             // Expected = the old clone-and-refresh path, byte for byte.
1346        let mut expected = s.clone();
1347        expected.message_count = expected.api_messages.len();
1348        let expected = serde_json::to_string(&expected).unwrap();
1349        assert_eq!(snapshot_json(&s, None).unwrap(), expected);
1350        let back: Session = serde_json::from_str(&snapshot_json(&s, None).unwrap()).unwrap();
1351        assert_eq!(back.message_count, 1);
1352        // Same check with every optional absent.
1353        let s = Session::new("model-x", "medium", None);
1354        let mut expected = s.clone();
1355        expected.message_count = 0;
1356        assert_eq!(
1357            snapshot_json(&s, None).unwrap(),
1358            serde_json::to_string(&expected).unwrap()
1359        );
1360    }
1361
1362    #[test]
1363    fn session_meta_stays_in_sync_with_session_schema() {
1364        let mut s = Session::new("model-x", "medium", Some("prompt"));
1365        s.name = Some("named".into());
1366        s.abort_context = Some("ctx".into());
1367        s.parent_session = Some("parent".into());
1368        s.compacted_into = Some("child".into());
1369        s.keepwarm_cost = 0.25;
1370        s.keepwarm_tokens.cache_read = 9;
1371        let mut value = serde_json::to_value(&s).unwrap();
1372        value.as_object_mut().unwrap().remove("api_messages");
1373        for f in SNAPSHOT_ONLY_FIELDS {
1374            value.as_object_mut().unwrap().remove(*f);
1375        }
1376        let meta: SessionMeta = serde_json::from_value(value)
1377            .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
1378        assert_eq!(meta.id, s.id);
1379        assert_eq!(meta.name.as_deref(), Some("named"));
1380    }
1381
1382    #[test]
1383    fn meta_roundtrip_applies_every_field() {
1384        let mut a = Session::new("model-a", "high", Some("sys"));
1385        a.title = "t".into();
1386        a.total_input_tokens = 7;
1387        a.session_cost = 0.5;
1388        a.updated_at = chrono::Utc::now();
1389        let mut b = Session::new("model-b", "low", None);
1390        b.id = a.id.clone();
1391        SessionMeta::of(&a).apply(&mut b);
1392        assert_eq!(b.model, "model-a");
1393        assert_eq!(b.title, "t");
1394        assert_eq!(b.total_input_tokens, 7);
1395        assert_eq!(b.session_cost, 0.5);
1396        assert_eq!(b.updated_at, a.updated_at);
1397    }
1398
1399    #[test]
1400    fn env_roundtrip_and_secret_scrub() {
1401        let tmp = tempfile::TempDir::new().unwrap();
1402        let dir = tmp.path().join("sessions");
1403        let mut s = Session::new("m", "medium", None);
1404        // Set env with a normal var and a secret-looking var.
1405        s.env = Some(vec![
1406            ("PATH".into(), "/usr/bin".into()),
1407            ("GH_TOKEN".into(), "ghp_supersecret42".into()),
1408        ]);
1409        s.env_stripped = vec!["GH_TOKEN".into()];
1410        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1411        // Load back.
1412        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1413        // Non-secret survives.
1414        let env = loaded.env.as_ref().expect("env must round-trip");
1415        assert!(
1416            env.iter().any(|(k, v)| k == "PATH" && v == "/usr/bin"),
1417            "non-secret key must survive"
1418        );
1419        // Secret value MUST be absent from the on-disk bytes.
1420        let snap_bytes =
1421            std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
1422        assert!(
1423            !snap_bytes.contains("ghp_supersecret42"),
1424            "secret VALUE must never appear on disk"
1425        );
1426        // …and the non-secret env IS in the `.json` snapshot itself (not only
1427        // the journal meta tail) — the snapshot is what a fresh daemon reads.
1428        assert!(
1429            snap_bytes.contains("\"env\"") && snap_bytes.contains("/usr/bin"),
1430            "snapshot must carry the scrubbed env: {snap_bytes}"
1431        );
1432        // Secret name IS present in env_stripped.
1433        assert_eq!(loaded.env_stripped, vec!["GH_TOKEN".to_string()]);
1434        // The secret KEY should not appear in the env pairs.
1435        assert!(
1436            !env.iter().any(|(k, _)| k == "GH_TOKEN"),
1437            "secret key must be scrubbed from env pairs"
1438        );
1439    }
1440
1441    #[test]
1442    fn credential_url_values_never_reach_disk_whatever_the_key() {
1443        let tmp = tempfile::TempDir::new().unwrap();
1444        let dir = tmp.path().join("sessions");
1445        let mut s = Session::new("m", "medium", None);
1446        s.env = Some(vec![
1447            ("PATH".into(), "/usr/bin".into()),
1448            (
1449                "DATABASE_URL".into(),
1450                "postgres://app:hunter2@db/prod".into(),
1451            ),
1452            (
1453                "SENTRY_DSN".into(),
1454                "https://abc123dsnkey@o1.ingest.sentry.io/42".into(),
1455            ),
1456        ]);
1457        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1458        // Every file the save wrote (snapshot + journal) — none may carry the credential.
1459        for entry in std::fs::read_dir(&dir).unwrap() {
1460            let path = entry.unwrap().path();
1461            let bytes = std::fs::read(&path).unwrap();
1462            let text = String::from_utf8_lossy(&bytes);
1463            assert!(
1464                !text.contains("hunter2"),
1465                "credential URL leaked to {}",
1466                path.display()
1467            );
1468            assert!(
1469                !text.contains("abc123dsnkey"),
1470                "DSN key leaked to {}",
1471                path.display()
1472            );
1473        }
1474        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
1475        let env = loaded.env.as_ref().expect("env must round-trip");
1476        assert!(
1477            env.iter().any(|(k, _)| k == "PATH"),
1478            "non-secret key must survive"
1479        );
1480        assert!(!env
1481            .iter()
1482            .any(|(k, _)| k == "DATABASE_URL" || k == "SENTRY_DSN"));
1483    }
1484
1485    pub(super) fn with_messages(n: usize) -> Session {
1486        let mut s = Session::new("checkpoint-test", "medium", None);
1487        for i in 0..n {
1488            push_message(&mut s, &format!("old {i}"));
1489        }
1490        s
1491    }
1492
1493    pub(super) fn push_message(s: &mut Session, text: &str) {
1494        s.api_messages.push(std::sync::Arc::new(
1495            serde_json::json!({"role": "user", "content": text}),
1496        ));
1497    }
1498
1499    fn assert_saved(dir: &Path, expected: &Session) {
1500        let loaded = load_session_in_dir(dir, &expected.id).unwrap();
1501        let mut expected = expected.clone();
1502        expected.message_count = expected.api_messages.len();
1503        // message_count is a snapshot hint, not authoritative after replay.
1504        let mut loaded = loaded;
1505        loaded.message_count = loaded.api_messages.len();
1506        assert_eq!(
1507            serde_json::to_value(loaded).unwrap(),
1508            serde_json::to_value(expected).unwrap()
1509        );
1510    }
1511
1512    fn disk_generation(dir: &Path, id: &str) -> String {
1513        let snapshot: serde_json::Value =
1514            serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
1515                .unwrap();
1516        snapshot["_journal_generation"]
1517            .as_str()
1518            .unwrap()
1519            .to_string()
1520    }
1521
1522    #[test]
1523    fn bound_snapshot_is_session_shaped_and_storage_metadata_is_not_mirrored() {
1524        let s = with_messages(2);
1525        let json = snapshot_json(&s, Some("test-generation")).unwrap();
1526        let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
1527        assert_eq!(
1528            value
1529                .as_object_mut()
1530                .unwrap()
1531                .remove("_journal_generation")
1532                .unwrap(),
1533            "test-generation"
1534        );
1535        let mut expected = s.clone();
1536        expected.message_count = 2;
1537        assert_eq!(value, serde_json::to_value(&expected).unwrap());
1538        let back: Session = serde_json::from_str(&json).unwrap();
1539        assert_eq!(serde_json::to_value(back).unwrap(), value);
1540        value.as_object_mut().unwrap().remove("api_messages");
1541        serde_json::from_value::<SessionMeta>(value).unwrap();
1542    }
1543
1544    /// Recreate the exact two-file state of a crash after snapshot rename but
1545    /// before journal reset/unlink: keep the newly published snapshot and put
1546    /// back the old journal bytes. No clocks, processes, config, or real data.
1547    fn replacement_crash_case(mode: SessionPersistence, durable: bool, shorten: bool) {
1548        let tmp = tempfile::TempDir::new().unwrap();
1549        let dir = tmp.path().canonicalize().unwrap();
1550        let mut s = with_messages(2);
1551        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1552        for _ in 0..4 {
1553            push_message(&mut s, "obsolete journal message");
1554        }
1555        s.title = "obsolete metadata".into();
1556        s.updated_at += chrono::Duration::hours(1);
1557        s.session_cost = 99.0;
1558        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1559        let old = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1560        let old_generation = disk_generation(&dir, &s.id);
1561
1562        // Even same-length edits with an unchanged final message must commit.
1563        s.api_messages[0] =
1564            std::sync::Arc::new(serde_json::json!({"role":"user","content":"new head"}));
1565        if shorten {
1566            s.api_messages.truncate(3);
1567        }
1568        s.title = "candidate".into();
1569        s.updated_at -= chrono::Duration::hours(2); // old metadata would win by timestamp alone
1570        s.session_cost = 1.0;
1571        let receipt = if durable {
1572            save_session_durable_in_dir(&dir, &s, mode).unwrap()
1573        } else {
1574            save_session_in_dir(&dir, &s, mode).unwrap()
1575        };
1576        assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1577        assert_ne!(disk_generation(&dir, &s.id), old_generation);
1578        std::fs::write(journal_path(&dir, &s.id), old).unwrap();
1579        assert_saved(&dir, &s);
1580        assert!(journal_meta_tail(&dir, &s.id).is_none());
1581
1582        // A subsequent normal save must not append into the stale generation.
1583        push_message(&mut s, "post-checkpoint");
1584        let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1585        assert_eq!(receipt.mode, SaveMode::FullSnapshot);
1586        assert_saved(&dir, &s);
1587        push_message(&mut s, "new generation delta");
1588        assert_eq!(
1589            save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1590                .unwrap()
1591                .mode,
1592            SaveMode::Append { messages: 1 }
1593        );
1594        assert_saved(&dir, &s);
1595    }
1596
1597    #[test]
1598    fn normal_replacements_ignore_stale_journal_after_snapshot_publication() {
1599        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1600            for shorten in [false, true] {
1601                replacement_crash_case(mode, false, shorten);
1602            }
1603        }
1604    }
1605
1606    #[cfg(unix)]
1607    #[test]
1608    fn durable_replacements_ignore_stale_journal_after_snapshot_publication() {
1609        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1610            for shorten in [false, true] {
1611                replacement_crash_case(mode, true, shorten);
1612            }
1613        }
1614    }
1615
1616    #[test]
1617    fn legacy_v1_reads_then_migrates_and_cannot_replay_over_replacement() {
1618        let tmp = tempfile::TempDir::new().unwrap();
1619        let dir = tmp.path().canonicalize().unwrap();
1620        let mut s = with_messages(1);
1621        std::fs::write(
1622            dir.join(format!("{}.json", s.id)),
1623            snapshot_json(&s, None).unwrap(),
1624        )
1625        .unwrap();
1626        let mut legacy = vec![JournalRecord::Open {
1627            v: 1,
1628            base: 1,
1629            generation: None,
1630            history_hash: None,
1631            turns_hash: None,
1632            times_hash: None,
1633        }];
1634        for i in 1..5 {
1635            push_message(&mut s, "legacy delta");
1636            legacy.push(JournalRecord::Msg {
1637                v: 1,
1638                i,
1639                m: s.api_messages[i].as_ref().clone(),
1640                t: None,
1641                at: None,
1642            });
1643        }
1644        s.title = "legacy title".into();
1645        s.updated_at += chrono::Duration::hours(1);
1646        legacy.push(JournalRecord::Meta {
1647            v: 1,
1648            meta: Box::new(SessionMeta::of(&s)),
1649            history_hash: None,
1650            turns_hash: None,
1651            times_hash: None,
1652        });
1653        let mut bytes = Vec::new();
1654        for record in legacy {
1655            serde_json::to_writer(&mut bytes, &record).unwrap();
1656            bytes.push(b'\n');
1657        }
1658        std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1659        assert_saved(&dir, &s);
1660        assert_eq!(
1661            journal_meta_tail(&dir, &s.id).unwrap().message_count,
1662            Some(5)
1663        );
1664        let migrated = load_session_in_dir(&dir, &s.id).unwrap();
1665        assert_eq!(
1666            save_session_in_dir(&dir, &migrated, SessionPersistence::Journal)
1667                .unwrap()
1668                .mode,
1669            SaveMode::FullSnapshot
1670        );
1671        assert_saved(&dir, &s);
1672        assert!(!disk_generation(&dir, &s.id).is_empty());
1673
1674        s.api_messages.truncate(2);
1675        s.title = "new window".into();
1676        s.updated_at -= chrono::Duration::hours(2);
1677        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1678        std::fs::write(journal_path(&dir, &s.id), &bytes).unwrap();
1679        assert_saved(&dir, &s);
1680        assert!(journal_meta_tail(&dir, &s.id).is_none());
1681    }
1682
1683    #[test]
1684    fn same_length_edits_in_snapshot_or_earlier_journal_message_resnapshot() {
1685        for appended in [false, true] {
1686            let tmp = tempfile::TempDir::new().unwrap();
1687            let dir = tmp.path().canonicalize().unwrap();
1688            let mut s = with_messages(3);
1689            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1690            let edited = if appended {
1691                push_message(&mut s, "editable");
1692                push_message(&mut s, "unchanged last message");
1693                save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1694                3
1695            } else {
1696                0
1697            };
1698            let old_generation = disk_generation(&dir, &s.id);
1699            std::sync::Arc::make_mut(&mut s.api_messages[edited])["content"] = "replaced".into();
1700            assert_eq!(
1701                save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1702                    .unwrap()
1703                    .mode,
1704                SaveMode::FullSnapshot
1705            );
1706            assert_ne!(disk_generation(&dir, &s.id), old_generation);
1707            assert_saved(&dir, &s);
1708        }
1709    }
1710
1711    #[test]
1712    fn v2_never_replays_without_binding_or_across_record_versions() {
1713        let tmp = tempfile::TempDir::new().unwrap();
1714        let dir = tmp.path().canonicalize().unwrap();
1715        let mut s = with_messages(1);
1716        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1717        let first = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1718        push_message(&mut s, "v2 delta");
1719        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1720        let snapshot: Session =
1721            serde_json::from_slice(&std::fs::read(dir.join(format!("{}.json", s.id))).unwrap())
1722                .unwrap();
1723        // A generic Session serde mirror drops only the storage metadata.
1724        std::fs::write(
1725            dir.join(format!("{}.json", s.id)),
1726            serde_json::to_vec(&snapshot).unwrap(),
1727        )
1728        .unwrap();
1729        assert_saved(&dir, &snapshot); // never apply an unbound v2 delta
1730        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1731        let bound_snapshot = s.clone();
1732        let mut journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1733        journal.extend_from_slice(
1734            b"{\"v\":1,\"k\":\"msg\",\"i\":2,\"m\":{\"content\":\"wrong version\"}}\n",
1735        );
1736        std::fs::write(journal_path(&dir, &s.id), &journal).unwrap();
1737        assert_saved(&dir, &bound_snapshot);
1738        assert_eq!(
1739            save_session_in_dir(&dir, &s, SessionPersistence::Journal)
1740                .unwrap()
1741                .mode,
1742            SaveMode::FullSnapshot
1743        );
1744        // Restoring an older *v2* open also invalidates the whole overlay.
1745        std::fs::write(journal_path(&dir, &s.id), first).unwrap();
1746        assert_saved(&dir, &s);
1747    }
1748
1749    #[test]
1750    fn json_saves_keep_binding_even_after_journal_deletion() {
1751        let tmp = tempfile::TempDir::new().unwrap();
1752        let dir = tmp.path().canonicalize().unwrap();
1753        let mut s = with_messages(2);
1754        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1755        push_message(&mut s, "old delta");
1756        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1757        let journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1758        save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1759        let generation = disk_generation(&dir, &s.id);
1760        s.api_messages.truncate(2);
1761        save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
1762        assert_ne!(generation, disk_generation(&dir, &s.id));
1763        std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
1764        assert_saved(&dir, &s);
1765    }
1766
1767    #[cfg(unix)]
1768    #[test]
1769    fn durable_error_after_rename_keeps_candidate_and_retry_is_safe() {
1770        let tmp = tempfile::TempDir::new().unwrap();
1771        let dir = tmp.path().canonicalize().unwrap();
1772        let mut s = with_messages(2);
1773        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1774        push_message(&mut s, "old delta");
1775        push_message(&mut s, "more old history");
1776        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1777        let old_journal = std::fs::read(journal_path(&dir, &s.id)).unwrap();
1778        let old_generation = disk_generation(&dir, &s.id);
1779        s.api_messages.truncate(2);
1780        s.title = "durable candidate".into();
1781        // Snapshot fsync/rename/dir fsync succeeds; journal temp cleanup fails.
1782        let blocker = dir.join(format!("{}.journal.tmp", s.id));
1783        std::fs::create_dir(&blocker).unwrap();
1784        let error = save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap_err();
1785        assert_ne!(error.kind(), std::io::ErrorKind::NotFound);
1786        assert_ne!(old_generation, disk_generation(&dir, &s.id));
1787        assert_eq!(
1788            std::fs::read(journal_path(&dir, &s.id)).unwrap(),
1789            old_journal
1790        );
1791        assert_saved(&dir, &s); // Err did NOT roll back the published head
1792        std::fs::remove_dir(blocker).unwrap();
1793        save_session_durable_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1794        assert_saved(&dir, &s);
1795    }
1796
1797    /// Private modes (spec §5.4): journal-mode saves keep the 0700 dir and
1798    /// 0600 files under a permissive umask, exactly like the legacy path.
1799    #[cfg(unix)]
1800    mod private_modes {
1801        use super::*;
1802        use crate::core::private_fs::test_support::UmaskGuard;
1803        use serial_test::serial;
1804        use std::os::unix::fs::PermissionsExt;
1805
1806        fn mode_of(path: &Path) -> u32 {
1807            std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1808        }
1809
1810        #[test]
1811        #[serial(umask)]
1812        fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
1813            let _umask = UmaskGuard::set(0);
1814            let tmp = tempfile::TempDir::new().unwrap();
1815            let dir = tmp.path().join("sessions");
1816            let mut s = Session::new("m", "medium", None);
1817            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1818            s.api_messages.push(std::sync::Arc::new(
1819                serde_json::json!({"role":"user","content":"x"}),
1820            ));
1821            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
1822
1823            assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
1824            assert_eq!(
1825                mode_of(&dir.join(format!("{}.json", s.id))),
1826                0o600,
1827                "snapshot must be 0600"
1828            );
1829            assert_eq!(
1830                mode_of(&journal_path(&dir, &s.id)),
1831                0o600,
1832                "journal must be 0600"
1833            );
1834        }
1835
1836        #[test]
1837        #[serial(umask)]
1838        fn durable_files_are_private_in_both_modes_and_repair_leaf_modes() {
1839            let _umask = UmaskGuard::set(0);
1840            for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1841                let tmp = tempfile::TempDir::new().unwrap();
1842                let dir = tmp
1843                    .path()
1844                    .canonicalize()
1845                    .unwrap()
1846                    .join("new/profile/sessions");
1847                let s = with_messages(2);
1848                save_session_durable_in_dir(&dir, &s, mode).unwrap();
1849                assert_eq!(mode_of(dir.parent().unwrap()), 0o700);
1850                std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o777)).unwrap();
1851                let snapshot = dir.join(format!("{}.json", s.id));
1852                std::fs::set_permissions(&snapshot, std::fs::Permissions::from_mode(0o666))
1853                    .unwrap();
1854                save_session_durable_in_dir(&dir, &s, mode).unwrap();
1855                assert_eq!(mode_of(&dir), 0o700);
1856                assert_eq!(mode_of(&snapshot), 0o600);
1857                if mode == SessionPersistence::Journal {
1858                    assert_eq!(mode_of(&journal_path(&dir, &s.id)), 0o600);
1859                }
1860                assert_saved(&dir, &s);
1861            }
1862        }
1863
1864        #[test]
1865        fn durable_saves_refuse_artifact_and_ancestor_symlinks_in_both_modes() {
1866            for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1867                for extension in ["json", "journal"] {
1868                    let tmp = tempfile::TempDir::new().unwrap();
1869                    let root = tmp.path().canonicalize().unwrap();
1870                    let dir = root.join("sessions");
1871                    std::fs::create_dir(&dir).unwrap();
1872                    let victim = root.join("victim");
1873                    std::fs::write(&victim, "original").unwrap();
1874                    let s = with_messages(1);
1875                    std::os::unix::fs::symlink(
1876                        &victim,
1877                        dir.join(format!("{}.{}", s.id, extension)),
1878                    )
1879                    .unwrap();
1880                    assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1881                    assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1882                }
1883                let tmp = tempfile::TempDir::new().unwrap();
1884                let root = tmp.path().canonicalize().unwrap();
1885                let victim_dir = root.join("victim-dir");
1886                std::fs::create_dir(&victim_dir).unwrap();
1887                let link = root.join("link");
1888                std::os::unix::fs::symlink(&victim_dir, &link).unwrap();
1889                let s = with_messages(1);
1890                for dir in [link.clone(), link.join("profile/sessions")] {
1891                    assert!(save_session_durable_in_dir(&dir, &s, mode).is_err());
1892                }
1893                assert_eq!(std::fs::read_dir(&victim_dir).unwrap().count(), 0);
1894            }
1895        }
1896
1897        #[test]
1898        fn journal_append_refuses_symlink_target() {
1899            let tmp = tempfile::TempDir::new().unwrap();
1900            let dir = tmp.path().join("sessions");
1901            std::fs::create_dir_all(&dir).unwrap();
1902            let mut s = Session::new("m", "medium", None);
1903            let victim = tmp.path().join("victim");
1904            std::fs::write(&victim, "original").unwrap();
1905            std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
1906            // Preflight refuses the journal before publishing a snapshot.
1907            s.api_messages.push(std::sync::Arc::new(
1908                serde_json::json!({"role":"user","content":"x"}),
1909            ));
1910            let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
1911            assert!(res.is_err(), "journal write onto a symlink must fail");
1912            assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
1913        }
1914    }
1915}
1916
1917/// What one session save costs ON A REAL DISK — the number the per-round
1918/// save (`agent_engine` round checkpoints) pays. Not run by default: set
1919/// `SYNAPS_SAVE_BENCH_DIR` to a directory on the filesystem to measure (NOT
1920/// tmpfs, whose fsync is free), then
1921/// `cargo test -p synaps-core --release --lib save_cost_bench -- --ignored --nocapture`.
1922#[cfg(test)]
1923mod save_cost_bench {
1924    use super::*;
1925    use std::sync::Arc;
1926    use std::time::{Duration, Instant};
1927
1928    fn session(bytes: usize) -> Session {
1929        let mut s = Session::new("claude-sonnet-4-5", "low", None);
1930        // ~4 KiB messages, alternating roles: a realistic mix of short
1931        // turns and tool output.
1932        let chunk = "x".repeat(4000);
1933        let n = bytes / 4096;
1934        s.api_messages = (0..n)
1935            .map(|i| {
1936                let role = if i % 2 == 0 { "user" } else { "assistant" };
1937                Arc::new(serde_json::json!({"role": role, "content": format!("{i} {chunk}")}))
1938            })
1939            .collect();
1940        s
1941    }
1942
1943    fn pct(sorted: &[Duration], p: f64) -> Duration {
1944        sorted[((sorted.len() - 1) as f64 * p).round() as usize]
1945    }
1946
1947    #[test]
1948    #[ignore = "benchmark: needs SYNAPS_SAVE_BENCH_DIR on a real disk"]
1949    fn save_cost_bench() {
1950        let Ok(root) = std::env::var("SYNAPS_SAVE_BENCH_DIR") else {
1951            eprintln!("SYNAPS_SAVE_BENCH_DIR not set; skipping");
1952            return;
1953        };
1954        let dir = tempfile::tempdir_in(root).unwrap();
1955        for (label, bytes) in [("200 KB", 200_000), ("2.3 MB", 2_300_000), ("10 MB", 10_000_000)] {
1956            for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
1957                let mut s = session(bytes);
1958                save_session_in_dir(dir.path(), &s, mode).unwrap(); // warm up
1959                let mut samples = Vec::new();
1960                for round in 0..40 {
1961                    // Each round appends an assistant reply + tool result.
1962                    for role in ["assistant", "user"] {
1963                        s.api_messages.push(Arc::new(
1964                            serde_json::json!({"role": role, "content": format!("round {round}")}),
1965                        ));
1966                    }
1967                    let t = Instant::now();
1968                    save_session_in_dir(dir.path(), &s, mode).unwrap();
1969                    samples.push(t.elapsed());
1970                }
1971                samples.sort();
1972                eprintln!(
1973                    "{label:>7} {mode:?}: p50 {:?}  p95 {:?}  max {:?}",
1974                    pct(&samples, 0.5),
1975                    pct(&samples, 0.95),
1976                    samples[samples.len() - 1]
1977                );
1978                let _ = delete_session_files_in_dir(dir.path(), &s.id);
1979            }
1980        }
1981    }
1982}
1983
1984/// Task #444, C-1: keep-warm labels are snapshot-only, and the journal stays
1985/// readable by 0.10.1.
1986#[cfg(test)]
1987mod snapshot_only_fields {
1988    use super::tests::{push_message, with_messages};
1989    use super::*;
1990
1991    /// Task #444: keep-warm spend persists through the snapshot in both
1992    /// modes (journal mode publishes a snapshot on the first save).
1993    #[test]
1994    fn keepwarm_cost_survives_save_and_load() {
1995        let tmp = tempfile::TempDir::new().unwrap();
1996        let dir = tmp.path().join("sessions");
1997        for persistence in [SessionPersistence::Json, SessionPersistence::Journal] {
1998            let mut s = Session::new("m", "medium", None);
1999            s.api_messages.push(std::sync::Arc::new(
2000                serde_json::json!({"role": "user", "content": "q"}),
2001            ));
2002            s.session_cost = 1.5;
2003            s.total_input_tokens = 100;
2004            s.total_output_tokens = 10;
2005            s.keepwarm_cost = 0.25;
2006            s.keepwarm_tokens = KeepwarmTokens {
2007                input: 4,
2008                output: 2,
2009                cache_read: 900,
2010                cache_creation: 0,
2011            };
2012            save_session_in_dir(&dir, &s, persistence).unwrap();
2013            let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2014            assert_eq!(loaded.keepwarm_cost, 0.25, "{persistence:?}");
2015            assert_eq!(loaded.keepwarm_tokens, s.keepwarm_tokens, "{persistence:?}");
2016            assert_eq!(loaded.session_cost, 1.5, "{persistence:?}");
2017        }
2018    }
2019
2020    /// The exact `SessionMeta` field set shipped in 0.10.1 (5c86d2ca), with
2021    /// its `deny_unknown_fields`. Kept frozen on purpose: it stands in for
2022    /// the older reader, which must keep parsing every meta record this
2023    /// build writes (C-1, task #444).
2024    #[derive(Deserialize)]
2025    #[serde(deny_unknown_fields)]
2026    #[allow(dead_code)]
2027    struct SessionMetaV0_10_1 {
2028        id: String,
2029        title: String,
2030        #[serde(default)]
2031        name: Option<String>,
2032        model: String,
2033        thinking_level: String,
2034        system_prompt: Option<String>,
2035        created_at: DateTime<Utc>,
2036        updated_at: DateTime<Utc>,
2037        total_input_tokens: u64,
2038        total_output_tokens: u64,
2039        session_cost: f64,
2040        #[serde(default)]
2041        message_count: Option<usize>,
2042        #[serde(default)]
2043        abort_context: Option<String>,
2044        #[serde(default)]
2045        parent_session: Option<String>,
2046        #[serde(default)]
2047        compacted_into: Option<String>,
2048        #[serde(default)]
2049        env: Option<Vec<(String, String)>>,
2050        #[serde(default)]
2051        env_stripped: Vec<String>,
2052        #[serde(default)]
2053        prompt_provenance: Option<serde_json::Value>,
2054        #[serde(default)]
2055        compaction: Option<serde_json::Value>,
2056    }
2057
2058    /// 0.10.1's journal reader, reduced to the part that matters: every
2059    /// record must parse (an `Err` there is a torn tail that truncates the
2060    /// session and is then rewritten by `full_snapshot_reset`).
2061    #[derive(Deserialize)]
2062    #[serde(tag = "k")]
2063    #[allow(dead_code)]
2064    enum JournalRecordV0_10_1 {
2065        #[serde(rename = "open")]
2066        Open {
2067            v: u8,
2068            base: usize,
2069            #[serde(default)]
2070            generation: Option<String>,
2071            #[serde(default)]
2072            history_hash: Option<String>,
2073        },
2074        #[serde(rename = "msg")]
2075        Msg {
2076            v: u8,
2077            i: usize,
2078            m: serde_json::Value,
2079        },
2080        #[serde(rename = "meta")]
2081        Meta {
2082            v: u8,
2083            meta: Box<SessionMetaV0_10_1>,
2084            #[serde(default)]
2085            history_hash: Option<String>,
2086        },
2087    }
2088
2089    #[test]
2090    fn journal_written_by_this_build_parses_with_the_0_10_1_reader() {
2091        let tmp = tempfile::TempDir::new().unwrap();
2092        let dir = tmp.path().join("sessions");
2093        let mut s = Session::new("m", "medium", Some("sys"));
2094        s.name = Some("n".into());
2095        s.parent_session = Some("p".into());
2096        s.env = Some(vec![("PATH".into(), "/bin".into())]);
2097        s.api_messages.push(std::sync::Arc::new(
2098            serde_json::json!({"role": "user", "content": "q"}),
2099        ));
2100        s.session_cost = 1.0;
2101        s.total_input_tokens = 50;
2102        s.keepwarm_cost = 0.5;
2103        s.keepwarm_tokens.input = 5;
2104        s.keepwarm_tokens.cache_read = 1_000;
2105        s.total_cache_read_tokens = 2_000;
2106        s.total_cache_creation_tokens = 300;
2107        // First save publishes the snapshot; the next two append deltas
2108        // (messages + meta records carrying every non-zero field).
2109        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2110        for turn in 0..2 {
2111            s.api_messages.push(std::sync::Arc::new(
2112                serde_json::json!({"role": "assistant", "content": format!("a{turn}")}),
2113            ));
2114            s.session_cost += 0.25;
2115            s.keepwarm_cost += 0.125;
2116            s.total_cache_read_tokens += 1_000;
2117            let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2118            assert!(matches!(receipt.mode, SaveMode::Append { .. }));
2119        }
2120        let raw = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
2121        let mut metas = 0;
2122        for line in raw.lines() {
2123            let record: JournalRecordV0_10_1 = serde_json::from_str(line)
2124                .unwrap_or_else(|e| panic!("0.10.1 cannot parse {line}: {e}"));
2125            if matches!(record, JournalRecordV0_10_1::Meta { .. }) {
2126                metas += 1;
2127            }
2128            assert!(
2129                !line.contains("keepwarm"),
2130                "journaled a snapshot-only field: {line}"
2131            );
2132            assert!(
2133                !line.contains("total_cache_"),
2134                "journaled a snapshot-only field: {line}"
2135            );
2136        }
2137        assert_eq!(metas, 2);
2138        // And this build still reads back the full history, with the
2139        // keep-warm label as of the snapshot (stale until the next one).
2140        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2141        assert_eq!(loaded.api_messages.len(), 3);
2142        assert_eq!(loaded.session_cost, 1.5);
2143        assert_eq!(loaded.keepwarm_cost, 0.5);
2144        assert_eq!(loaded.total_cache_read_tokens, 2_000);
2145        assert_eq!(loaded.total_cache_creation_tokens, 300);
2146    }
2147
2148    /// The cache read/write totals persist with the session, so a daemon
2149    /// reload or a resume keeps the footer's session cache hit rate instead
2150    /// of restarting it at the first (usually missed) request. Both modes;
2151    /// journal mode publishes a snapshot on the first save.
2152    #[test]
2153    fn cache_totals_survive_save_and_load() {
2154        let tmp = tempfile::TempDir::new().unwrap();
2155        let dir = tmp.path().join("sessions");
2156        for persistence in [SessionPersistence::Json, SessionPersistence::Journal] {
2157            let mut s = Session::new("m", "medium", None);
2158            s.api_messages.push(std::sync::Arc::new(
2159                serde_json::json!({"role": "user", "content": "q"}),
2160            ));
2161            s.total_input_tokens = 100;
2162            s.total_output_tokens = 10;
2163            s.total_cache_read_tokens = 85_679;
2164            s.total_cache_creation_tokens = 6_250;
2165            save_session_in_dir(&dir, &s, persistence).unwrap();
2166            let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2167            assert_eq!(loaded.total_cache_read_tokens, 85_679, "{persistence:?}");
2168            assert_eq!(loaded.total_cache_creation_tokens, 6_250, "{persistence:?}");
2169            assert_eq!(loaded.total_input_tokens, 100, "{persistence:?}");
2170        }
2171    }
2172
2173    /// Zero cache totals are not written, and a snapshot saved before the
2174    /// fields existed loads them as 0.
2175    #[test]
2176    fn cache_totals_are_optional_in_the_snapshot() {
2177        let s = Session::new("m", "medium", None);
2178        let value = serde_json::to_value(&s).unwrap();
2179        assert!(value.get("total_cache_read_tokens").is_none());
2180        assert!(value.get("total_cache_creation_tokens").is_none());
2181        let legacy: Session = serde_json::from_value(value).unwrap();
2182        assert_eq!(legacy.total_cache_read_tokens, 0);
2183        assert_eq!(legacy.total_cache_creation_tokens, 0);
2184    }
2185
2186    #[test]
2187    fn keepwarm_labels_clamp_under_the_totals_on_load() {
2188        let tmp = tempfile::TempDir::new().unwrap();
2189        let dir = tmp.path().join("sessions");
2190        let mut s = Session::new("m", "medium", None);
2191        s.api_messages.push(std::sync::Arc::new(
2192            serde_json::json!({"role": "user", "content": "q"}),
2193        ));
2194        s.session_cost = 0.25;
2195        s.total_input_tokens = 3;
2196        s.total_output_tokens = 1;
2197        s.keepwarm_cost = 0.75; // e.g. a stale snapshot label
2198        s.keepwarm_tokens.input = 10;
2199        s.keepwarm_tokens.output = 10;
2200        save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
2201        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2202        assert_eq!(loaded.keepwarm_cost, 0.25);
2203        assert_eq!(loaded.keepwarm_tokens.input, 3);
2204        assert_eq!(loaded.keepwarm_tokens.output, 1);
2205    }
2206
2207    // ── turn ids stay out of persisted message objects (03 C-2) ──────────
2208
2209    fn tagged(text: &str, id: &str) -> SharedMessage {
2210        std::sync::Arc::new(serde_json::json!({
2211            "role": "user", "content": text, MESSAGE_TURN_KEY: {"id": id}
2212        }))
2213    }
2214
2215    fn assert_no_host_keys(m: &serde_json::Value, ctx: &str) {
2216        let keys: Vec<_> = m
2217            .as_object()
2218            .map(|o| o.keys().filter(|k| k.starts_with("_synaps_")).cloned().collect())
2219            .unwrap_or_default();
2220        assert!(keys.is_empty(), "{ctx}: persisted message carries {keys:?}: {m}");
2221    }
2222
2223    /// What 0.10.2 (0862f255) loads: `Session` from the snapshot (it ignores
2224    /// unknown top-level fields) plus every journal `m`, unchanged. Its
2225    /// `session_journal`/`session` are byte-identical to this build's
2226    /// pre-fix code, so the frozen reader types above stand in for it.
2227    fn old_reader_view(dir: &Path, id: &str) -> (Vec<serde_json::Value>, Option<String>) {
2228        let snap: Session =
2229            serde_json::from_slice(&std::fs::read(dir.join(format!("{id}.json"))).unwrap())
2230                .unwrap();
2231        let mut messages: Vec<_> = snap.api_messages.iter().map(|m| (**m).clone()).collect();
2232        let mut hash = None;
2233        if let Ok(raw) = std::fs::read_to_string(journal_path(dir, id)) {
2234            for line in raw.lines() {
2235                match serde_json::from_str::<JournalRecordV0_10_1>(line)
2236                    .unwrap_or_else(|e| panic!("0.10.2 cannot parse {line}: {e}"))
2237                {
2238                    JournalRecordV0_10_1::Msg { i, m, .. } if i == messages.len() => {
2239                        messages.push(m)
2240                    }
2241                    JournalRecordV0_10_1::Open { history_hash, .. }
2242                    | JournalRecordV0_10_1::Meta { history_hash, .. } => hash = history_hash,
2243                    _ => {}
2244                }
2245            }
2246        }
2247        (messages, hash)
2248    }
2249
2250    #[test]
2251    fn turn_ids_are_persisted_beside_messages_not_inside_them() {
2252        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
2253            let tmp = tempfile::TempDir::new().unwrap();
2254            let dir = tmp.path().join("sessions");
2255            let mut s = Session::new("m", "medium", None);
2256            s.api_messages.push(tagged("first", "t-1"));
2257            push_message(&mut s, "reply");
2258            save_session_in_dir(&dir, &s, mode).unwrap();
2259            s.api_messages.push(tagged("second", "t-2"));
2260            let receipt = save_session_in_dir(&dir, &s, mode).unwrap();
2261            if mode == SessionPersistence::Journal {
2262                assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }));
2263            }
2264
2265            let (old, hash) = old_reader_view(&dir, &s.id);
2266            assert_eq!(old.len(), 3, "{mode:?}");
2267            for m in &old {
2268                assert_no_host_keys(m, &format!("{mode:?}"));
2269            }
2270            if mode == SessionPersistence::Journal {
2271                // 0.10.2 hashes the messages it loaded; equal hashes let it
2272                // keep appending instead of treating the journal as edited.
2273                let old: Vec<SharedMessage> =
2274                    old.into_iter().map(std::sync::Arc::new).collect();
2275                assert_eq!(hash, Some(history_hash(&old).unwrap()), "{mode:?}");
2276            }
2277
2278            // This build gets its turn ids back.
2279            let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2280            assert_eq!(
2281                serde_json::to_value(&loaded.api_messages).unwrap(),
2282                serde_json::to_value(&s.api_messages).unwrap(),
2283                "{mode:?}"
2284            );
2285        }
2286    }
2287
2288    /// The pre-fix `history_hash`: over the messages with the tag inline.
2289    fn dev_build_hash(messages: &[SharedMessage]) -> String {
2290        use sha2::{Digest, Sha256};
2291        format!("{:x}", Sha256::digest(serde_json::to_vec(messages).unwrap()))
2292    }
2293
2294    #[test]
2295    fn inline_turn_ids_from_earlier_dev_builds_still_load_and_migrate() {
2296        let tmp = tempfile::TempDir::new().unwrap();
2297        let dir = tmp.path().join("sessions");
2298        let mut s = Session::new("m", "medium", None);
2299        s.api_messages.push(tagged("first", "t-1"));
2300        // The pre-fix layout: the tag inline in the snapshot and the journal.
2301        let id = s.id.clone();
2302        let generation = "g-1";
2303        let mut snap = serde_json::to_value(&s).unwrap();
2304        snap["_journal_generation"] = generation.into();
2305        snap["message_count"] = 1.into();
2306        write_json_snapshot(&dir, &id, snap.to_string().as_bytes()).unwrap();
2307        let inline_hash = dev_build_hash(&s.api_messages);
2308        s.api_messages.push(tagged("second", "t-2"));
2309        let mut journal = String::new();
2310        for record in [
2311            serde_json::json!({"k":"open","v":2,"base":1,"generation":generation,"history_hash":inline_hash}),
2312            serde_json::json!({"k":"msg","v":2,"i":1,"m":&*s.api_messages[1]}),
2313            serde_json::json!({"k":"meta","v":2,"meta":SessionMeta::of(&s),
2314                "history_hash":dev_build_hash(&s.api_messages)}),
2315        ] {
2316            journal.push_str(&record.to_string());
2317            journal.push('\n');
2318        }
2319        std::fs::write(journal_path(&dir, &id), journal).unwrap();
2320
2321        let loaded = load_session_in_dir(&dir, &id).unwrap();
2322        assert_eq!(
2323            serde_json::to_value(&loaded.api_messages).unwrap(),
2324            serde_json::to_value(&s.api_messages).unwrap()
2325        );
2326        // The next save rewrites it in the sibling layout.
2327        let receipt = save_session_in_dir(&dir, &loaded, SessionPersistence::Journal).unwrap();
2328        assert!(matches!(receipt.mode, SaveMode::FullSnapshot));
2329        for m in old_reader_view(&dir, &id).0 {
2330            assert_no_host_keys(&m, "migrated");
2331        }
2332        let again = load_session_in_dir(&dir, &id).unwrap();
2333        assert_eq!(
2334            serde_json::to_value(&again.api_messages).unwrap(),
2335            serde_json::to_value(&s.api_messages).unwrap()
2336        );
2337    }
2338
2339    #[test]
2340    fn tagging_an_already_saved_message_forces_a_snapshot() {
2341        let tmp = tempfile::TempDir::new().unwrap();
2342        let dir = tmp.path().join("sessions");
2343        let mut s = with_messages(2);
2344        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2345        s.api_messages[1] = tagged("old 1", "late");
2346        let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2347        assert!(matches!(receipt.mode, SaveMode::FullSnapshot));
2348        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2349        assert_eq!(loaded.api_messages[1][MESSAGE_TURN_KEY]["id"], "late");
2350    }
2351
2352    /// The rollback this layout exists for: this build saves, 0.10.2 opens
2353    /// the session and appends a turn, then this build reads it back.
2354    #[test]
2355    fn rollback_round_trip_through_a_0_10_2_append() {
2356        let tmp = tempfile::TempDir::new().unwrap();
2357        let dir = tmp.path().join("sessions");
2358        let mut s = Session::new("m", "medium", None);
2359        s.api_messages.push(tagged("first", "t-1"));
2360        push_message(&mut s, "reply");
2361        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2362        s.api_messages.push(tagged("second", "t-2"));
2363        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2364
2365        // 0.10.2: loads the stored form, checks its prefix hash (equal, so
2366        // it appends rather than rewriting), and appends one turn with no
2367        // `t` and no `turns_hash`.
2368        let (old, hash) = old_reader_view(&dir, &s.id);
2369        let mut old: Vec<SharedMessage> = old.into_iter().map(std::sync::Arc::new).collect();
2370        assert_eq!(hash, Some(history_hash(&old).unwrap()));
2371        old.push(std::sync::Arc::new(
2372            serde_json::json!({"role": "user", "content": "asked on 0.10.2"}),
2373        ));
2374        let mut old_session = s.clone();
2375        old_session.api_messages = old.clone();
2376        old_session.updated_at += chrono::Duration::minutes(1);
2377        let mut journal = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
2378        for record in [
2379            serde_json::json!({"k":"msg","v":2,"i":3,"m":&*old[3]}),
2380            serde_json::json!({"k":"meta","v":2,"meta":SessionMeta::of(&old_session),
2381                "history_hash":history_hash(&old).unwrap()}),
2382        ] {
2383            journal.push_str(&record.to_string());
2384            journal.push('\n');
2385        }
2386        std::fs::write(journal_path(&dir, &s.id), journal).unwrap();
2387
2388        // This build: ids back on the same indices, the 0.10.2 turn untagged.
2389        let mut loaded = load_session_in_dir(&dir, &s.id).unwrap();
2390        assert_eq!(loaded.api_messages.len(), 4);
2391        let ids: Vec<_> = loaded.api_messages.iter().map(|m| message_turn_id(m)).collect();
2392        assert_eq!(ids, vec![Some("t-1"), None, Some("t-2"), None]);
2393        assert_eq!(loaded.api_messages[3]["content"], "asked on 0.10.2");
2394        // One full snapshot (the old meta has no turns hash), then appends.
2395        let receipt = save_session_in_dir(&dir, &loaded, SessionPersistence::Journal).unwrap();
2396        assert!(matches!(receipt.mode, SaveMode::FullSnapshot), "{:?}", receipt.mode);
2397        loaded.api_messages.push(tagged("third", "t-3"));
2398        let receipt = save_session_in_dir(&dir, &loaded, SessionPersistence::Journal).unwrap();
2399        assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }), "{:?}", receipt.mode);
2400        let again = load_session_in_dir(&dir, &s.id).unwrap();
2401        assert_eq!(
2402            serde_json::to_value(&again.api_messages).unwrap(),
2403            serde_json::to_value(&loaded.api_messages).unwrap()
2404        );
2405    }
2406
2407    #[test]
2408    fn a_malformed_journal_turn_id_does_not_end_replay() {
2409        let tmp = tempfile::TempDir::new().unwrap();
2410        let dir = tmp.path().join("sessions");
2411        let mut s = with_messages(1);
2412        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2413        s.api_messages.push(tagged("second", "t-2"));
2414        push_message(&mut s, "third");
2415        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2416        let path = journal_path(&dir, &s.id);
2417        let raw = std::fs::read_to_string(&path).unwrap();
2418        assert!(raw.contains(r#""t":"t-2""#));
2419        std::fs::write(&path, raw.replace(r#""t":"t-2""#, r#""t":{"bad":1}"#)).unwrap();
2420        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2421        assert_eq!(loaded.api_messages.len(), 3, "replay continued past the bad id");
2422        assert_eq!(message_turn_id(&loaded.api_messages[1]), None);
2423    }
2424
2425    /// `_turn_ids` is best-effort metadata: a malformed value drops the ids,
2426    /// never the session.
2427    #[test]
2428    fn malformed_turn_ids_are_dropped_not_fatal() {
2429        for bad in [
2430            serde_json::json!("garbage"),
2431            serde_json::json!([[0, "t-1"], ["x", 1], [99, "past-the-end"], 7]),
2432        ] {
2433            let tmp = tempfile::TempDir::new().unwrap();
2434            let dir = tmp.path().join("sessions");
2435            let mut s = Session::new("m", "medium", None);
2436            s.api_messages.push(tagged("first", "t-1"));
2437            push_message(&mut s, "reply");
2438            save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
2439            let path = dir.join(format!("{}.json", s.id));
2440            let mut snap: serde_json::Value =
2441                serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
2442            snap["_turn_ids"] = bad.clone();
2443            std::fs::write(&path, snap.to_string()).unwrap();
2444            let loaded = load_session_in_dir(&dir, &s.id)
2445                .unwrap_or_else(|e| panic!("{bad}: session not loadable: {e}"));
2446            assert_eq!(loaded.api_messages.len(), 2, "{bad}");
2447            let well_formed = bad.as_array().is_some();
2448            assert_eq!(
2449                message_turn_id(&loaded.api_messages[0]),
2450                well_formed.then_some("t-1"),
2451                "{bad}: valid entries survive"
2452            );
2453        }
2454    }
2455
2456    // ── message times stay out of persisted message objects ───────────────
2457
2458    fn timed(text: &str, ts: &str) -> SharedMessage {
2459        std::sync::Arc::new(serde_json::json!({
2460            "role": "user", "content": text, MESSAGE_AT_KEY: ts
2461        }))
2462    }
2463
2464    const T1: &str = "2026-10-25T05:47:00Z";
2465    const T2: &str = "2026-10-25T05:48:30Z";
2466
2467    #[test]
2468    fn message_times_round_trip_beside_messages_not_inside_them() {
2469        for mode in [SessionPersistence::Json, SessionPersistence::Journal] {
2470            let tmp = tempfile::TempDir::new().unwrap();
2471            let dir = tmp.path().join("sessions");
2472            let mut s = Session::new("m", "medium", None);
2473            let mut first = (*timed("first", T1)).clone();
2474            first[MESSAGE_TURN_KEY] = serde_json::json!({"id": "t-1"});
2475            s.api_messages.push(std::sync::Arc::new(first));
2476            push_message(&mut s, "legacy reply, no time");
2477            save_session_in_dir(&dir, &s, mode).unwrap();
2478            s.api_messages.push(timed("second", T2));
2479            let receipt = save_session_in_dir(&dir, &s, mode).unwrap();
2480            if mode == SessionPersistence::Journal {
2481                assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }));
2482                let raw = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
2483                assert!(raw.contains(&format!(r#""at":"{T2}""#)), "{raw}");
2484            } else {
2485                let raw = std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
2486                assert!(raw.contains(&format!(r#""_message_times":[[0,"{T1}"],[2,"{T2}"]]"#)), "{raw}");
2487            }
2488            let (old, hash) = old_reader_view(&dir, &s.id);
2489            assert_eq!(old.len(), 3, "{mode:?}");
2490            for m in &old {
2491                assert_no_host_keys(m, &format!("{mode:?}"));
2492            }
2493            if mode == SessionPersistence::Journal {
2494                let old: Vec<SharedMessage> = old.into_iter().map(std::sync::Arc::new).collect();
2495                assert_eq!(hash, Some(history_hash(&old).unwrap()), "{mode:?}");
2496            }
2497            let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2498            assert_eq!(
2499                serde_json::to_value(&loaded.api_messages).unwrap(),
2500                serde_json::to_value(&s.api_messages).unwrap(),
2501                "{mode:?}"
2502            );
2503            let times: Vec<_> = loaded.api_messages.iter().map(|m| message_time(m)).collect();
2504            assert_eq!(times, vec![Some(T1), None, Some(T2)], "{mode:?}");
2505        }
2506    }
2507
2508    #[test]
2509    fn stamping_an_already_saved_message_forces_a_snapshot() {
2510        let tmp = tempfile::TempDir::new().unwrap();
2511        let dir = tmp.path().join("sessions");
2512        let mut s = with_messages(2);
2513        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2514        let mut late = (*s.api_messages[1]).clone();
2515        late[MESSAGE_AT_KEY] = T1.into();
2516        s.api_messages[1] = std::sync::Arc::new(late);
2517        let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2518        assert!(matches!(receipt.mode, SaveMode::FullSnapshot), "{:?}", receipt.mode);
2519        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2520        assert_eq!(message_time(&loaded.api_messages[1]), Some(T1));
2521        // Unchanged times keep appending.
2522        let mut s = loaded;
2523        s.api_messages.push(timed("next", T2));
2524        let receipt = save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2525        assert!(matches!(receipt.mode, SaveMode::Append { messages: 1 }), "{:?}", receipt.mode);
2526    }
2527
2528    #[test]
2529    fn a_session_saved_without_times_loads_unchanged() {
2530        let tmp = tempfile::TempDir::new().unwrap();
2531        let dir = tmp.path().join("sessions");
2532        let mut s = with_messages(2);
2533        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2534        push_message(&mut s, "delta");
2535        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2536        let snap = std::fs::read_to_string(dir.join(format!("{}.json", s.id))).unwrap();
2537        let journal = std::fs::read_to_string(journal_path(&dir, &s.id)).unwrap();
2538        assert!(!snap.contains("_message_times") && !journal.contains("times_hash"));
2539        assert!(!journal.contains(r#""at":"#));
2540        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2541        assert_eq!(
2542            serde_json::to_value(&loaded.api_messages).unwrap(),
2543            serde_json::to_value(&s.api_messages).unwrap()
2544        );
2545        assert!(loaded.api_messages.iter().all(|m| message_time(m).is_none()));
2546    }
2547
2548    #[test]
2549    fn malformed_message_times_are_dropped_not_fatal() {
2550        for bad in [
2551            serde_json::json!("garbage"),
2552            serde_json::json!([[0, T1], [1, "not a time"], ["x", T2], [99, T2], 7]),
2553        ] {
2554            let tmp = tempfile::TempDir::new().unwrap();
2555            let dir = tmp.path().join("sessions");
2556            let mut s = Session::new("m", "medium", None);
2557            s.api_messages.push(timed("first", T1));
2558            push_message(&mut s, "reply");
2559            save_session_in_dir(&dir, &s, SessionPersistence::Json).unwrap();
2560            let path = dir.join(format!("{}.json", s.id));
2561            let mut snap: serde_json::Value =
2562                serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
2563            snap["_message_times"] = bad.clone();
2564            std::fs::write(&path, snap.to_string()).unwrap();
2565            let loaded = load_session_in_dir(&dir, &s.id)
2566                .unwrap_or_else(|e| panic!("{bad}: session not loadable: {e}"));
2567            assert_eq!(loaded.api_messages.len(), 2, "{bad}");
2568            let well_formed = bad.as_array().is_some();
2569            assert_eq!(message_time(&loaded.api_messages[0]), well_formed.then_some(T1), "{bad}");
2570            assert_eq!(message_time(&loaded.api_messages[1]), None, "{bad}");
2571        }
2572        // Journal: a bad `at` drops the time, replay continues.
2573        let tmp = tempfile::TempDir::new().unwrap();
2574        let dir = tmp.path().join("sessions");
2575        let mut s = with_messages(1);
2576        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2577        s.api_messages.push(timed("second", T2));
2578        push_message(&mut s, "third");
2579        save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
2580        let path = journal_path(&dir, &s.id);
2581        let raw = std::fs::read_to_string(&path).unwrap();
2582        std::fs::write(&path, raw.replace(&format!(r#""at":"{T2}""#), r#""at":{"bad":1}"#)).unwrap();
2583        let loaded = load_session_in_dir(&dir, &s.id).unwrap();
2584        assert_eq!(loaded.api_messages.len(), 3, "replay continued past the bad time");
2585        assert_eq!(message_time(&loaded.api_messages[1]), None);
2586    }
2587}