Skip to main content

agent_core/core/
session_journal.rs

1//! Task 35 — opt-in session journal + periodic atomic snapshots (spec §9.8;
2//! decision record: docs/decisions/T35-session-journal-opt-in.md).
3//!
4//! The DEFAULT persistence (`SessionPersistence::Json`) is byte-for-byte the
5//! legacy path: every save atomically rewrites `sessions/<id>.json`. The
6//! opt-in `Journal` mode is purely ADDITIVE: the snapshot file keeps the
7//! unchanged legacy `Session` schema, and an append-only `sessions/<id>.journal`
8//! (JSONL, schema v1) carries the deltas since the snapshot, so a steady-state
9//! save costs O(delta) instead of O(total history).
10//!
11//! Replay is IDEMPOTENT by construction — `msg` records carry absolute
12//! history indices (`i == len` appends, `i < len` skips, `i > len` stops at
13//! the last consistent prefix) and `meta` records only apply when their
14//! `updated_at` is not older than the loaded state. Torn tails (kill during
15//! append), stale journals (kill between snapshot and journal reset), and
16//! manual journal deletion therefore all recover to a consistent session.
17//!
18//! Everything here writes through the T4 `private_fs` helpers: 0700 dirs,
19//! 0600 files, symlink-refusing, atomic snapshot replacement, synced appends.
20
21use crate::core::session::Session;
22use crate::core::stream_types::SharedMessage;
23use chrono::{DateTime, Utc};
24use serde::{Deserialize, Serialize};
25use std::io::Write;
26use std::path::{Path, PathBuf};
27
28/// Journal record schema version (line-level `"v"` field).
29pub const JOURNAL_SCHEMA_VERSION: u8 = 1;
30
31/// Journal size floor before a periodic snapshot is forced.
32pub const JOURNAL_SNAPSHOT_MIN_BYTES: u64 = 256 * 1024;
33
34/// A snapshot is due when the journal outgrows `snapshot / RATIO` (bounded
35/// write amplification: large sessions stretch the threshold proportionally).
36pub const JOURNAL_SNAPSHOT_RATIO: u64 = 4;
37
38/// Bounded tail window scanned for the freshest `meta` record.
39const META_TAIL_WINDOW: u64 = 64 * 1024;
40
41/// Hard cap on any single persisted-session artifact read (snapshot or
42/// journal). Reads are bounded on the OPENED handle; an artifact past the
43/// cap is refused rather than slurped.
44pub const MAX_PERSISTED_READ_BYTES: u64 = 1024 * 1024 * 1024; // 1 GiB
45
46// ─── confined resolution (fix1 I1 + fix2) ───────────────────────────────────
47//
48// STRICT TRUSTED-ROOT SEMANTICS (fix2): the sessions directory path is
49// resolved with EVERY component — ancestors AND the final one — opened
50// handle-relatively from `/` with symlinks refused
51// (`ConfinedDir::{open,create}_absolute_no_symlinks`; Linux uses one atomic
52// `openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS` from the root handle).
53// Nothing on the path is trusted; there is no check-then-open race — a
54// component swapped to a symlink at any moment fails the open itself.
55// Artifacts inside the directory are then opened/written/removed relative
56// to that ONE handle. Operators whose base dir legitimately sits behind
57// ancestor symlinks (e.g. `/home` → `var/home`) must point
58// `SYNAPS_BASE_DIR` at the canonical path.
59//
60// Non-unix keeps the crate's documented best-effort pathname fallback
61// (final-component symlink refusal only).
62
63#[cfg(unix)]
64type SessionsDirHandle = crate::core::private_fs::ConfinedDir;
65
66/// Open the sessions dir strictly. `Ok(None)` when a path component does
67/// not exist; symlinks anywhere are errors.
68#[cfg(unix)]
69fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
70    match crate::core::private_fs::ConfinedDir::open_absolute_no_symlinks(dir) {
71        Ok(handle) => Ok(Some(handle)),
72        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
73        Err(e) => Err(e),
74    }
75}
76
77/// Create-or-open the sessions dir strictly (0700 leaf).
78#[cfg(unix)]
79fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
80    crate::core::private_fs::ConfinedDir::create_absolute_no_symlinks(dir)
81}
82
83/// Open `<name>` inside an already-strictly-opened sessions dir.
84/// `Ok(None)` when the file does not exist; a symlinked artifact errors.
85#[cfg(unix)]
86fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
87    match handle.open_file(&[name.to_string()]) {
88        Ok(file) => Ok(Some(file)),
89        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
90        Err(e) => Err(e),
91    }
92}
93
94/// One-shot strict open of `<name>` inside `dir`.
95#[cfg(unix)]
96pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
97    let Some(handle) = open_sessions_dir(dir)? else {
98        return Ok(None);
99    };
100    open_artifact(&handle, name)
101}
102
103// ── Non-unix best-effort fallbacks (documented, matching `private_fs`) ──
104
105#[cfg(not(unix))]
106struct SessionsDirHandle {
107    dir: std::path::PathBuf,
108}
109
110#[cfg(not(unix))]
111fn open_sessions_dir(dir: &Path) -> std::io::Result<Option<SessionsDirHandle>> {
112    if !dir.exists() {
113        return Ok(None);
114    }
115    Ok(Some(SessionsDirHandle {
116        dir: dir.to_path_buf(),
117    }))
118}
119
120#[cfg(not(unix))]
121fn create_sessions_dir(dir: &Path) -> std::io::Result<SessionsDirHandle> {
122    crate::core::private_fs::ensure_private_dir(dir)?;
123    Ok(SessionsDirHandle {
124        dir: dir.to_path_buf(),
125    })
126}
127
128#[cfg(not(unix))]
129impl SessionsDirHandle {
130    fn write_atomic(&self, name: &str, data: &[u8]) -> std::io::Result<()> {
131        crate::core::private_fs::write_atomic_private(&self.dir.join(name), data)
132            .map_err(std::io::Error::other)
133    }
134    fn append_file(&self, name: &str) -> std::io::Result<std::fs::File> {
135        crate::core::private_fs::open_private_append(&self.dir.join(name))
136            .map_err(std::io::Error::other)
137    }
138    fn remove_file(&self, name: &str) -> std::io::Result<()> {
139        std::fs::remove_file(self.dir.join(name))
140    }
141}
142
143#[cfg(not(unix))]
144fn open_artifact(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<std::fs::File>> {
145    let path = handle.dir.join(name);
146    match std::fs::symlink_metadata(&path) {
147        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
148        Ok(meta) if meta.file_type().is_symlink() => {
149            return Err(std::io::Error::other(format!(
150                "refusing symlinked session artifact {name:?}"
151            )))
152        }
153        _ => {}
154    }
155    match std::fs::File::open(&path) {
156        Ok(f) => Ok(Some(f)),
157        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
158        Err(e) => Err(e),
159    }
160}
161
162#[cfg(not(unix))]
163pub(crate) fn confined_open(dir: &Path, name: &str) -> std::io::Result<Option<std::fs::File>> {
164    let Some(handle) = open_sessions_dir(dir)? else {
165        return Ok(None);
166    };
167    open_artifact(&handle, name)
168}
169
170/// Bounded read of a whole artifact from an already-confined handle.
171fn read_artifact_bytes(handle: &SessionsDirHandle, name: &str) -> std::io::Result<Option<Vec<u8>>> {
172    use std::io::Read;
173    let Some(file) = open_artifact(handle, name)? else {
174        return Ok(None);
175    };
176    let mut buf = Vec::new();
177    file.take(MAX_PERSISTED_READ_BYTES + 1)
178        .read_to_end(&mut buf)?;
179    if buf.len() as u64 > MAX_PERSISTED_READ_BYTES {
180        return Err(std::io::Error::other(format!(
181            "session artifact {name:?} exceeds the {MAX_PERSISTED_READ_BYTES}-byte read bound"
182        )));
183    }
184    Ok(Some(buf))
185}
186
187/// One strictly-resolved directory entry for session listings.
188#[derive(Debug, Clone)]
189pub struct SessionDirEntry {
190    pub name: String,
191    pub mtime: Option<std::time::SystemTime>,
192    /// Stored bytes for metadata-only accounting. Obtained from the directory
193    /// entry stat; reading it never opens session content.
194    pub byte_len: u64,
195}
196
197/// Enumerate the sessions dir through the SAME strict handle resolution as
198/// every other T35 operation (fix2): handle-relative `readdir` +
199/// `fstatat(AT_SYMLINK_NOFOLLOW)` — a symlinked ancestor refuses, a
200/// missing dir lists empty, and entry mtimes are of the entries
201/// themselves, never symlink targets.
202pub fn session_dir_entries(dir: &Path) -> std::io::Result<Vec<SessionDirEntry>> {
203    #[cfg(unix)]
204    {
205        let Some(handle) = open_sessions_dir(dir)? else {
206            return Ok(Vec::new());
207        };
208        Ok(handle
209            .entries()?
210            .into_iter()
211            .filter(|e| e.is_file)
212            .map(|e| SessionDirEntry {
213                name: e.name,
214                mtime: e.mtime_unix_ms.map(|ms| {
215                    std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_millis(ms)
216                }),
217                byte_len: e.byte_len,
218            })
219            .collect())
220    }
221    #[cfg(not(unix))]
222    {
223        if !dir.exists() {
224            return Ok(Vec::new());
225        }
226        let mut out = Vec::new();
227        for entry in std::fs::read_dir(dir)? {
228            let entry = entry?;
229            if !entry.path().is_file() {
230                continue;
231            }
232            let Ok(name) = entry.file_name().into_string() else {
233                continue;
234            };
235            let byte_len = entry
236                .metadata()
237                .map(|metadata| metadata.len())
238                .unwrap_or_default();
239            out.push(SessionDirEntry {
240                name,
241                mtime: entry.metadata().and_then(|m| m.modified()).ok(),
242                byte_len,
243            });
244        }
245        Ok(out)
246    }
247}
248
249/// Which on-disk persistence strategy `Session::save` uses.
250///
251/// `Json` (the default) is the unchanged legacy behavior. `Journal` is the
252/// spec §9.8 opt-in and is only ever selected explicitly via the
253/// `session_persistence = journal` config key.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
255pub enum SessionPersistence {
256    #[default]
257    Json,
258    Journal,
259}
260
261impl SessionPersistence {
262    /// Parse a config value; anything unrecognized yields `None` so the
263    /// caller keeps the safe default and surfaces a typed warning.
264    pub fn parse(val: &str) -> Option<Self> {
265        match val.trim() {
266            "json" => Some(Self::Json),
267            "journal" => Some(Self::Journal),
268            _ => None,
269        }
270    }
271}
272
273/// How a save landed on disk.
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
275pub enum SaveMode {
276    /// Full legacy-schema snapshot written (and, in journal mode, the
277    /// journal reset to a lone `open` record).
278    FullSnapshot,
279    /// Delta append: `messages` new history entries plus one meta record.
280    Append { messages: usize },
281}
282
283/// Machine-readable receipt for benchmarks and delta-proportionality tests.
284#[derive(Debug, Clone, Copy)]
285pub struct SaveReceipt {
286    pub mode: SaveMode,
287    pub bytes_written: u64,
288}
289
290/// Freshest journaled metadata, read from a bounded tail window — lets
291/// session listings stay accurate without parsing the full journal.
292#[derive(Debug, Clone)]
293pub struct JournalMetaTail {
294    pub updated_at: DateTime<Utc>,
295    pub session_cost: f64,
296    /// `None` for meta records written before the count was journaled.
297    pub message_count: Option<usize>,
298}
299
300/// `sessions/<id>.journal` — single-extension name so the session id stays
301/// the file stem (chain-head protection and retention pair on the stem).
302pub fn journal_path(dir: &Path, id: &str) -> PathBuf {
303    dir.join(format!("{id}.journal"))
304}
305
306/// Snapshot threshold: journal bytes ≥ max(256 KiB, snapshot / 4).
307pub fn snapshot_due(journal_bytes: u64, snapshot_bytes: u64) -> bool {
308    journal_bytes >= JOURNAL_SNAPSHOT_MIN_BYTES.max(snapshot_bytes / JOURNAL_SNAPSHOT_RATIO)
309}
310
311// ─── journal records ─────────────────────────────────────────────────────────
312
313#[derive(Serialize, Deserialize)]
314#[serde(tag = "k")]
315enum JournalRecord {
316    /// First line of every journal: the snapshot held `base` messages.
317    #[serde(rename = "open")]
318    Open { v: u8, base: usize },
319    /// Message at ABSOLUTE history index `i`.
320    #[serde(rename = "msg")]
321    Msg {
322        v: u8,
323        i: usize,
324        m: serde_json::Value,
325    },
326    /// Full session metadata (the `Session` object minus `api_messages`).
327    #[serde(rename = "meta")]
328    Meta { v: u8, meta: Box<SessionMeta> },
329}
330
331/// Mirror of [`Session`] WITHOUT `api_messages`, with identical serde
332/// attributes. `deny_unknown_fields` + the `session_meta_stays_in_sync_…`
333/// test make silent drift between the two types impossible.
334#[derive(Clone, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336struct SessionMeta {
337    id: String,
338    title: String,
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    name: Option<String>,
341    model: String,
342    thinking_level: String,
343    system_prompt: Option<String>,
344    created_at: DateTime<Utc>,
345    updated_at: DateTime<Utc>,
346    total_input_tokens: u64,
347    total_output_tokens: u64,
348    session_cost: f64,
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    message_count: Option<usize>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    abort_context: Option<String>,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    parent_session: Option<String>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    compacted_into: Option<String>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    prompt_provenance: Option<crate::prompt::PromptProvenance>,
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    compaction: Option<crate::core::compaction::CompactionRecord>,
361}
362
363impl SessionMeta {
364    fn of(s: &Session) -> Self {
365        Self {
366            id: s.id.clone(),
367            title: s.title.clone(),
368            name: s.name.clone(),
369            model: s.model.clone(),
370            thinking_level: s.thinking_level.clone(),
371            system_prompt: s.system_prompt.clone(),
372            created_at: s.created_at,
373            updated_at: s.updated_at,
374            total_input_tokens: s.total_input_tokens,
375            total_output_tokens: s.total_output_tokens,
376            session_cost: s.session_cost,
377            message_count: Some(s.api_messages.len()),
378            abort_context: s.abort_context.clone(),
379            parent_session: s.parent_session.clone(),
380            compacted_into: s.compacted_into.clone(),
381            prompt_provenance: s.prompt_provenance.clone(),
382            compaction: s.compaction.clone(),
383        }
384    }
385
386    /// Apply onto a loaded session. Only metadata moves — never history.
387    fn apply(self, s: &mut Session) {
388        s.title = self.title;
389        s.name = self.name;
390        s.model = self.model;
391        s.thinking_level = self.thinking_level;
392        s.system_prompt = self.system_prompt;
393        s.created_at = self.created_at;
394        s.updated_at = self.updated_at;
395        s.total_input_tokens = self.total_input_tokens;
396        s.total_output_tokens = self.total_output_tokens;
397        s.session_cost = self.session_cost;
398        s.abort_context = self.abort_context;
399        s.parent_session = self.parent_session;
400        s.compacted_into = self.compacted_into;
401        s.prompt_provenance = self.prompt_provenance;
402        s.compaction = self.compaction;
403    }
404}
405
406/// Parsed view of an on-disk journal: the durable message count implied by
407/// the contiguous valid prefix, the last durable message (tripwire), and
408/// whether every line parsed cleanly (a torn tail forces a resnapshot so
409/// later appends are never shadowed behind an unparseable line).
410struct JournalState {
411    durable_len: usize,
412    last_msg: Option<(usize, serde_json::Value)>,
413    bytes: u64,
414    clean: bool,
415}
416
417fn read_journal_state(
418    handle: &SessionsDirHandle,
419    id: &str,
420) -> std::io::Result<Option<JournalState>> {
421    let Some(raw) = read_artifact_bytes(handle, &format!("{id}.journal"))? else {
422        return Ok(None);
423    };
424    let bytes = raw.len() as u64;
425    let text = String::from_utf8_lossy(&raw);
426    let mut lines = text.lines();
427
428    let Some(first) = lines.next() else {
429        return Ok(Some(JournalState {
430            durable_len: 0,
431            last_msg: None,
432            bytes,
433            clean: false,
434        }));
435    };
436    let base = match serde_json::from_str::<JournalRecord>(first) {
437        // fix1 M1: only the CURRENT schema version is interpretable. An
438        // unsupported open version invalidates the whole journal — its
439        // record semantics are unknown, so nothing may replay or be
440        // appended behind it.
441        Ok(JournalRecord::Open { v, base }) if v == JOURNAL_SCHEMA_VERSION => base,
442        _ => {
443            // No/invalid/unsupported open record: the journal is unusable
444            // as an append target — resnapshot.
445            return Ok(Some(JournalState {
446                durable_len: 0,
447                last_msg: None,
448                bytes,
449                clean: false,
450            }));
451        }
452    };
453
454    let mut durable_len = base;
455    let mut last_msg = None;
456    let mut clean = true;
457    for line in lines {
458        match serde_json::from_str::<JournalRecord>(line) {
459            // fix1 M1: a record from an unknown schema version ends the
460            // valid prefix — the next save must resnapshot.
461            Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
462                if v != JOURNAL_SCHEMA_VERSION =>
463            {
464                clean = false;
465                break;
466            }
467            Ok(JournalRecord::Msg { i, m, .. }) => {
468                if i == durable_len {
469                    durable_len += 1;
470                    last_msg = Some((i, m));
471                } else if i > durable_len {
472                    clean = false; // gap — inconsistent suffix
473                    break;
474                }
475                // i < durable_len: stale duplicate, ignore.
476            }
477            Ok(JournalRecord::Meta { .. }) => {}
478            Ok(JournalRecord::Open { .. }) | Err(_) => {
479                clean = false; // torn tail or nested open
480                break;
481            }
482        }
483    }
484    Ok(Some(JournalState {
485        durable_len,
486        last_msg,
487        bytes,
488        clean,
489    }))
490}
491
492// ─── save ────────────────────────────────────────────────────────────────────
493
494/// Persist `session` into `dir` under the given persistence mode.
495///
496/// `Json` (default): the unchanged legacy path — one atomic private
497/// `<id>.json` — plus rollback folding: any journal left over from a
498/// previous opt-in is deleted, because the fresh snapshot supersedes it.
499///
500/// `Journal`: append the delta since the last durable state; write a fresh
501/// snapshot instead when the journal is missing/unusable, the history
502/// shrank or its durable tail was edited (append-only tripwires), or the
503/// journal outgrew [`snapshot_due`].
504pub fn save_session_in_dir(
505    dir: &Path,
506    session: &Session,
507    mode: SessionPersistence,
508) -> std::io::Result<SaveReceipt> {
509    // ONE strict resolution per save (fix2); every artifact operation below
510    // is relative to this handle.
511    let handle = create_sessions_dir(dir)?;
512    // The persisted listing hint (`message_count`) is refreshed at write
513    // time by `snapshot_json` / `SessionMeta::of` — no `Session` clone, so
514    // the journal delta path stays O(delta) rather than O(history).
515    match mode {
516        SessionPersistence::Json => {
517            let json = snapshot_json(session)?;
518            handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
519            // Rollback fold: the snapshot now holds everything; a stale
520            // journal must not shadow future legacy-only readers.
521            remove_artifact_if_exists(&handle, &format!("{}.journal", session.id))?;
522            Ok(SaveReceipt {
523                mode: SaveMode::FullSnapshot,
524                bytes_written: json.len() as u64,
525            })
526        }
527        SessionPersistence::Journal => save_journal_mode(&handle, session),
528    }
529}
530
531fn save_journal_mode(
532    handle: &SessionsDirHandle,
533    session: &Session,
534) -> std::io::Result<SaveReceipt> {
535    let journal_name = format!("{}.journal", session.id);
536
537    let snapshot = open_artifact(handle, &format!("{}.json", session.id))?;
538    let state = match read_journal_state(handle, &session.id)? {
539        Some(state) if snapshot.is_some() => state,
540        // First journal-mode save of a new or legacy session (or the
541        // snapshot vanished out-of-band): full snapshot + fresh journal.
542        _ => return full_snapshot_reset(handle, session),
543    };
544
545    // Append-only tripwires — anything the journal cannot express safely
546    // becomes a fresh atomic snapshot instead.
547    let rewrite_needed = !state.clean
548        || session.api_messages.len() < state.durable_len
549        || state.last_msg.as_ref().is_some_and(|(i, m)| {
550            session
551                .api_messages
552                .get(*i)
553                .map_or(true, |live| live.as_ref() != m)
554        });
555    if rewrite_needed {
556        return full_snapshot_reset(handle, session);
557    }
558
559    // Delta append: new messages (absolute indices) + one meta record.
560    let mut buf = Vec::new();
561    for (offset, msg) in session.api_messages[state.durable_len..].iter().enumerate() {
562        let record = JournalRecord::Msg {
563            v: JOURNAL_SCHEMA_VERSION,
564            i: state.durable_len + offset,
565            m: msg.as_ref().clone(),
566        };
567        serde_json::to_writer(&mut buf, &record).map_err(std::io::Error::other)?;
568        buf.push(b'\n');
569    }
570    let meta = JournalRecord::Meta {
571        v: JOURNAL_SCHEMA_VERSION,
572        meta: Box::new(SessionMeta::of(session)),
573    };
574    serde_json::to_writer(&mut buf, &meta).map_err(std::io::Error::other)?;
575    buf.push(b'\n');
576
577    let appended = session.api_messages.len() - state.durable_len;
578    let mut file = handle.append_file(&journal_name)?;
579    file.write_all(&buf)?;
580    file.sync_data()?;
581    drop(file);
582
583    // Periodic snapshot: fold an oversized journal back into the atomic
584    // snapshot. Crash between the two steps leaves a stale journal whose
585    // records replay idempotently (see module docs).
586    let journal_bytes = state.bytes + buf.len() as u64;
587    let snapshot_bytes = snapshot
588        .and_then(|f| f.metadata().ok())
589        .map(|m| m.len())
590        .unwrap_or(0);
591    if snapshot_due(journal_bytes, snapshot_bytes) {
592        let reset = full_snapshot_reset(handle, session)?;
593        return Ok(SaveReceipt {
594            mode: SaveMode::FullSnapshot,
595            bytes_written: buf.len() as u64 + reset.bytes_written,
596        });
597    }
598
599    Ok(SaveReceipt {
600        mode: SaveMode::Append { messages: appended },
601        bytes_written: buf.len() as u64,
602    })
603}
604
605/// Atomic full snapshot (unchanged legacy schema) followed by an atomic
606/// journal reset to a lone `open` record. Snapshot strictly first: a crash
607/// between the two leaves a stale-but-idempotent journal, never data loss.
608/// Borrowing mirror of [`Session`] used ONLY for snapshot serialization:
609/// identical field order and serde attributes, with `message_count`
610/// computed from `api_messages.len()` at write time instead of read from
611/// the (non-authoritative) in-memory field. Field order matters —
612/// `message_count` must precede `api_messages` for `read_session_header`.
613/// The `snapshot_json_matches_session_schema` test guards drift.
614#[derive(Serialize)]
615struct SessionSnapshotRef<'a> {
616    id: &'a str,
617    title: &'a str,
618    #[serde(skip_serializing_if = "Option::is_none")]
619    name: &'a Option<String>,
620    model: &'a str,
621    thinking_level: &'a str,
622    system_prompt: &'a Option<String>,
623    created_at: &'a DateTime<Utc>,
624    updated_at: &'a DateTime<Utc>,
625    total_input_tokens: u64,
626    total_output_tokens: u64,
627    session_cost: f64,
628    message_count: usize,
629    api_messages: &'a [SharedMessage],
630    #[serde(skip_serializing_if = "Option::is_none")]
631    abort_context: &'a Option<String>,
632    #[serde(skip_serializing_if = "Option::is_none")]
633    parent_session: &'a Option<String>,
634    #[serde(skip_serializing_if = "Option::is_none")]
635    compacted_into: &'a Option<String>,
636    #[serde(skip_serializing_if = "Option::is_none")]
637    prompt_provenance: &'a Option<crate::prompt::PromptProvenance>,
638    #[serde(skip_serializing_if = "Option::is_none")]
639    compaction: &'a Option<crate::core::compaction::CompactionRecord>,
640}
641
642impl<'a> SessionSnapshotRef<'a> {
643    fn of(s: &'a Session) -> Self {
644        Self {
645            id: &s.id,
646            title: &s.title,
647            name: &s.name,
648            model: &s.model,
649            thinking_level: &s.thinking_level,
650            system_prompt: &s.system_prompt,
651            created_at: &s.created_at,
652            updated_at: &s.updated_at,
653            total_input_tokens: s.total_input_tokens,
654            total_output_tokens: s.total_output_tokens,
655            session_cost: s.session_cost,
656            message_count: s.api_messages.len(),
657            api_messages: &s.api_messages,
658            abort_context: &s.abort_context,
659            parent_session: &s.parent_session,
660            compacted_into: &s.compacted_into,
661            prompt_provenance: &s.prompt_provenance,
662            compaction: &s.compaction,
663        }
664    }
665}
666
667/// Full-snapshot JSON with a fresh `message_count`, without cloning.
668fn snapshot_json(session: &Session) -> std::io::Result<String> {
669    serde_json::to_string(&SessionSnapshotRef::of(session)).map_err(std::io::Error::other)
670}
671
672fn full_snapshot_reset(
673    handle: &SessionsDirHandle,
674    session: &Session,
675) -> std::io::Result<SaveReceipt> {
676    let json = snapshot_json(session)?;
677    handle.write_atomic(&format!("{}.json", session.id), json.as_bytes())?;
678
679    let open = JournalRecord::Open {
680        v: JOURNAL_SCHEMA_VERSION,
681        base: session.api_messages.len(),
682    };
683    let mut line = serde_json::to_vec(&open).map_err(std::io::Error::other)?;
684    line.push(b'\n');
685    handle.write_atomic(&format!("{}.journal", session.id), &line)?;
686
687    Ok(SaveReceipt {
688        mode: SaveMode::FullSnapshot,
689        bytes_written: json.len() as u64 + line.len() as u64,
690    })
691}
692
693// ─── load ────────────────────────────────────────────────────────────────────
694
695/// Load `<id>.json` and, when a journal exists, replay it idempotently.
696/// Old-format sessions (no journal) load exactly as before; a session with
697/// a torn or stale journal recovers to its last consistent state. Both
698/// reads are confined nofollow bounded handle reads (fix1 I1).
699pub fn load_session_in_dir(dir: &Path, id: &str) -> std::io::Result<Session> {
700    // ONE strict resolution per load (fix2); both artifact reads below are
701    // relative to this handle.
702    let handle = open_sessions_dir(dir)?.ok_or_else(|| {
703        std::io::Error::new(
704            std::io::ErrorKind::NotFound,
705            format!("no sessions directory for '{id}'"),
706        )
707    })?;
708    let snapshot = read_artifact_bytes(&handle, &format!("{id}.json"))?.ok_or_else(|| {
709        std::io::Error::new(
710            std::io::ErrorKind::NotFound,
711            format!("no session snapshot for '{id}'"),
712        )
713    })?;
714    let mut session: Session = serde_json::from_slice(&snapshot).map_err(std::io::Error::other)?;
715
716    let Some(raw) = read_artifact_bytes(&handle, &format!("{id}.journal"))? else {
717        return Ok(session);
718    };
719    let text = String::from_utf8_lossy(&raw);
720    let mut lines = text.lines();
721    // The first line must be an open record OF THE SUPPORTED VERSION;
722    // otherwise the whole journal is untrusted and the snapshot alone is
723    // the consistent state (fix1 M1).
724    match lines.next().map(serde_json::from_str::<JournalRecord>) {
725        Some(Ok(JournalRecord::Open { v, .. })) if v == JOURNAL_SCHEMA_VERSION => {}
726        _ => return Ok(session),
727    }
728    for line in lines {
729        match serde_json::from_str::<JournalRecord>(line) {
730            // fix1 M1: an unknown-version record ends the valid prefix.
731            Ok(JournalRecord::Msg { v, .. }) | Ok(JournalRecord::Meta { v, .. })
732                if v != JOURNAL_SCHEMA_VERSION =>
733            {
734                break;
735            }
736            Ok(JournalRecord::Msg { i, m, .. }) => {
737                if i == session.api_messages.len() {
738                    session.api_messages.push(std::sync::Arc::new(m));
739                } else if i > session.api_messages.len() {
740                    break; // gap — stop at the consistent prefix
741                }
742                // i < len: already in the snapshot — idempotent skip.
743            }
744            Ok(JournalRecord::Meta { meta, .. }) => {
745                if meta.id == session.id && meta.updated_at >= session.updated_at {
746                    meta.apply(&mut session);
747                }
748            }
749            Ok(JournalRecord::Open { .. }) | Err(_) => break, // torn tail
750        }
751    }
752    Ok(session)
753}
754
755/// Freshest `meta` record from a bounded journal tail window, for listing
756/// freshness without a full journal read. `None` when no journal, no
757/// complete supported-version meta record, or a refused (non-confined)
758/// artifact — a symlinked journal discloses nothing.
759pub fn journal_meta_tail(dir: &Path, id: &str) -> Option<JournalMetaTail> {
760    use std::io::{Read, Seek, SeekFrom};
761    let mut file = confined_open(dir, &format!("{id}.journal")).ok()??;
762    let len = file.metadata().ok()?.len();
763    let start = len.saturating_sub(META_TAIL_WINDOW);
764    file.seek(SeekFrom::Start(start)).ok()?;
765    let mut tail = String::new();
766    file.take(META_TAIL_WINDOW).read_to_string(&mut tail).ok()?;
767
768    let mut freshest = None;
769    for line in tail.lines() {
770        if let Ok(JournalRecord::Meta { v, meta }) = serde_json::from_str::<JournalRecord>(line) {
771            if v == JOURNAL_SCHEMA_VERSION {
772                freshest = Some(JournalMetaTail {
773                    updated_at: meta.updated_at,
774                    session_cost: meta.session_cost,
775                    message_count: meta.message_count,
776                });
777            }
778        }
779    }
780    freshest
781}
782
783// ─── deletion ────────────────────────────────────────────────────────────────
784
785/// Remove a session's snapshot AND journal (compaction rollback, retention).
786/// Idempotent — missing files are not errors.
787pub fn delete_session_files_in_dir(dir: &Path, id: &str) -> std::io::Result<()> {
788    let Some(handle) = open_sessions_dir(dir)? else {
789        return Ok(()); // no directory — idempotently nothing to delete
790    };
791    remove_artifact_if_exists(&handle, &format!("{id}.json"))?;
792    remove_artifact_if_exists(&handle, &format!("{id}.journal"))
793}
794
795fn remove_artifact_if_exists(handle: &SessionsDirHandle, name: &str) -> std::io::Result<()> {
796    match handle.remove_file(name) {
797        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
798        other => other,
799    }
800}
801
802/// Strict-handle snapshot write shared with the legacy
803/// `session::save_json_in_dir` path (fix2): same root resolution and the
804/// same handle-relative atomic private write as every journal operation.
805#[cfg(test)]
806pub(crate) fn write_json_snapshot(dir: &Path, id: &str, json: &[u8]) -> std::io::Result<()> {
807    let handle = create_sessions_dir(dir)?;
808    handle.write_atomic(&format!("{id}.json"), json)
809}
810
811// ─── tests ───────────────────────────────────────────────────────────────────
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816
817    /// Drift guard: `SessionMeta` must mirror every non-history `Session`
818    /// field. Serializing a full `Session`, dropping `api_messages`, and
819    /// parsing the rest as `SessionMeta` (deny_unknown_fields) fails the
820    /// moment `Session` grows a field this module does not journal.
821    #[test]
822    fn snapshot_json_matches_session_schema() {
823        let mut s = Session::new("model-x", "medium", Some("prompt"));
824        s.name = Some("named".into());
825        s.abort_context = Some("ctx".into());
826        s.parent_session = Some("parent".into());
827        s.compacted_into = Some("child".into());
828        s.api_messages.push(std::sync::Arc::new(
829            serde_json::json!({"role": "user", "content": "hi"}),
830        ));
831        s.message_count = 0; // stale in memory — snapshot must not trust it
832                             // Expected = the old clone-and-refresh path, byte for byte.
833        let mut expected = s.clone();
834        expected.message_count = expected.api_messages.len();
835        let expected = serde_json::to_string(&expected).unwrap();
836        assert_eq!(snapshot_json(&s).unwrap(), expected);
837        let back: Session = serde_json::from_str(&snapshot_json(&s).unwrap()).unwrap();
838        assert_eq!(back.message_count, 1);
839        // Same check with every optional absent.
840        let s = Session::new("model-x", "medium", None);
841        let mut expected = s.clone();
842        expected.message_count = 0;
843        assert_eq!(
844            snapshot_json(&s).unwrap(),
845            serde_json::to_string(&expected).unwrap()
846        );
847    }
848
849    #[test]
850    fn session_meta_stays_in_sync_with_session_schema() {
851        let mut s = Session::new("model-x", "medium", Some("prompt"));
852        s.name = Some("named".into());
853        s.abort_context = Some("ctx".into());
854        s.parent_session = Some("parent".into());
855        s.compacted_into = Some("child".into());
856        let mut value = serde_json::to_value(&s).unwrap();
857        value.as_object_mut().unwrap().remove("api_messages");
858        let meta: SessionMeta = serde_json::from_value(value)
859            .expect("Session grew a field SessionMeta does not mirror — extend SessionMeta");
860        assert_eq!(meta.id, s.id);
861        assert_eq!(meta.name.as_deref(), Some("named"));
862    }
863
864    #[test]
865    fn meta_roundtrip_applies_every_field() {
866        let mut a = Session::new("model-a", "high", Some("sys"));
867        a.title = "t".into();
868        a.total_input_tokens = 7;
869        a.session_cost = 0.5;
870        a.updated_at = chrono::Utc::now();
871        let mut b = Session::new("model-b", "low", None);
872        b.id = a.id.clone();
873        SessionMeta::of(&a).apply(&mut b);
874        assert_eq!(b.model, "model-a");
875        assert_eq!(b.title, "t");
876        assert_eq!(b.total_input_tokens, 7);
877        assert_eq!(b.session_cost, 0.5);
878        assert_eq!(b.updated_at, a.updated_at);
879    }
880
881    /// Private modes (spec §5.4): journal-mode saves keep the 0700 dir and
882    /// 0600 files under a permissive umask, exactly like the legacy path.
883    #[cfg(unix)]
884    mod private_modes {
885        use super::*;
886        use crate::core::private_fs::test_support::UmaskGuard;
887        use serial_test::serial;
888        use std::os::unix::fs::PermissionsExt;
889
890        fn mode_of(path: &Path) -> u32 {
891            std::fs::metadata(path).unwrap().permissions().mode() & 0o777
892        }
893
894        #[test]
895        #[serial(umask)]
896        fn journal_files_are_0600_in_0700_dir_under_permissive_umask() {
897            let _umask = UmaskGuard::set(0);
898            let tmp = tempfile::TempDir::new().unwrap();
899            let dir = tmp.path().join("sessions");
900            let mut s = Session::new("m", "medium", None);
901            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
902            s.api_messages.push(std::sync::Arc::new(
903                serde_json::json!({"role":"user","content":"x"}),
904            ));
905            save_session_in_dir(&dir, &s, SessionPersistence::Journal).unwrap();
906
907            assert_eq!(mode_of(&dir), 0o700, "sessions dir must be 0700");
908            assert_eq!(
909                mode_of(&dir.join(format!("{}.json", s.id))),
910                0o600,
911                "snapshot must be 0600"
912            );
913            assert_eq!(
914                mode_of(&journal_path(&dir, &s.id)),
915                0o600,
916                "journal must be 0600"
917            );
918        }
919
920        #[test]
921        fn journal_append_refuses_symlink_target() {
922            let tmp = tempfile::TempDir::new().unwrap();
923            let dir = tmp.path().join("sessions");
924            std::fs::create_dir_all(&dir).unwrap();
925            let mut s = Session::new("m", "medium", None);
926            let victim = tmp.path().join("victim");
927            std::fs::write(&victim, "original").unwrap();
928            std::os::unix::fs::symlink(&victim, journal_path(&dir, &s.id)).unwrap();
929            // Snapshot write succeeds; the journal step must refuse.
930            s.api_messages.push(std::sync::Arc::new(
931                serde_json::json!({"role":"user","content":"x"}),
932            ));
933            let res = save_session_in_dir(&dir, &s, SessionPersistence::Journal);
934            assert!(res.is_err(), "journal write onto a symlink must fail");
935            assert_eq!(std::fs::read_to_string(&victim).unwrap(), "original");
936        }
937    }
938}