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