Skip to main content

zeph_session/
lib.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Conversation-session persistence, event-log replay, and fork engine for Zeph.
5//!
6//! `zeph-session` implements spec-068: an append-only JSONL event log (the source of truth for
7//! a conversation), a metadata index over the existing `acp_sessions` table, a deterministic
8//! [`replay::ReplayEngine`], and the [`condenser::Condenser`] contract for durable context
9//! condensation. It is consumed by `zeph-core` (agent-loop `SessionSink` wiring, `zeph serve`
10//! per-session actors) and `zeph-acp` (session load/list/fork/resume handlers thinned to
11//! delegate here).
12//!
13//! # Architectural placement
14//!
15//! `zeph-session` mirrors the append-only journal design of `zeph-durable`
16//! (sequential event ordering, a single-writer actor model) but is a **separate** crate: the two
17//! operate at different abstraction levels (task/step effect-idempotency vs. conversation
18//! semantics) and use different storage formats (`SQLite`-backed opaque payloads vs. JSONL
19//! domain-typed events). `zeph-session` MUST NOT depend on `zeph-durable`, and vice versa
20//! (spec-068 §3, §15 NEVER; INV-1 in spec-064).
21//!
22//! # Module map
23//!
24//! - [`error`] — the crate-wide [`error::SessionError`].
25//! - [`event`] — the [`event::SessionEvent`] tagged enum and its [`event::SessionEventEnvelope`]
26//!   on-disk wrapper. Reuses `zeph_llm::provider::MessagePart` and
27//!   `zeph_common::memory::AnchoredSummary` rather than redefining them.
28//! - [`log`] — [`log::SessionEventLog`]: the append-only JSONL writer/reader, including the
29//!   INV-SP-2 torn-append truncation logic.
30//! - [`store`] — [`store::SessionStore`]: CRUD over the `acp_sessions` metadata index (spec §5).
31//! - [`replay`] — [`replay::ReplayEngine`]: deterministic fold of an event log into agent-ready
32//!   messages. Never calls the LLM or a tool executor.
33//! - [`condenser`] — the [`condenser::Condenser`] trait contract and the INV-SP-4 non-overlap
34//!   guard.
35//! - [`llm_condenser`] — [`llm_condenser::LlmCondenser`]: the default `Condenser` implementation,
36//!   reusing `zeph_context::summarization::summarize_structured`.
37//! - [`fork`] — [`fork::ForkEngine`]: eager-copy session forking (spec §7).
38//!
39//! The `zeph-core` `SessionActor` integration (`zeph serve`, spec §9) lands in a later phase of
40//! the implementation plan (`specs/068-session-persistence/plan.md`).
41
42pub mod condenser;
43pub mod error;
44pub mod event;
45pub mod fork;
46pub mod llm_condenser;
47pub mod log;
48pub mod replay;
49pub mod store;
50
51pub use condenser::{CondensationResult, Condenser};
52pub use error::SessionError;
53pub use event::{CompactionTier, SessionEvent, SessionEventEnvelope};
54pub use fork::{ForkEngine, ForkResult};
55pub use llm_condenser::LlmCondenser;
56pub use log::SessionEventLog;
57pub use replay::{ReconstructedState, ReplayEngine};
58pub use store::{SessionFilter, SessionMetadata, SessionStatus, SessionStore};
59
60/// The on-disk directory for one session's event log and blobs, per spec §4.1:
61/// `<data_dir>/<session_id>/`.
62///
63/// `data_dir` (`[session] data_dir`, default `.zeph/sessions`) already names the sessions
64/// root — callers must not append an extra `sessions` segment (#5981).
65///
66/// # Examples
67///
68/// ```
69/// use std::path::Path;
70///
71/// let dir = zeph_session::session_dir(Path::new(".zeph/sessions"), "abc-123");
72/// assert_eq!(dir, Path::new(".zeph/sessions/abc-123"));
73/// ```
74#[must_use]
75pub fn session_dir(data_dir: &std::path::Path, session_id: &str) -> std::path::PathBuf {
76    data_dir.join(session_id)
77}
78
79/// The one-time startup migration report returned by [`migrate_legacy_session_layout`].
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
81pub struct MigrationReport {
82    /// Number of legacy session directories moved up one level to the fixed (#5981) layout.
83    pub migrated: usize,
84    /// Number of legacy session directories left in place because a directory already existed
85    /// at the destination (not clobbered — it may already have been recreated at the new path).
86    pub skipped: usize,
87}
88
89/// Moves any session directories still sitting at the pre-#5981 on-disk layout
90/// (`<data_dir>/sessions/<session_id>/`) up one level to the fixed layout
91/// (`<data_dir>/<session_id>/`), which is what [`session_dir`] now resolves to.
92///
93/// Before #5981, [`session_dir`] appended a redundant `sessions` segment, so any session created
94/// before the fix physically has its `events.jsonl`/`blobs/` one directory level deeper than
95/// where the crate now looks. Left unmigrated, [`log::SessionEventLog::open`] silently
96/// `create_dir_all`s and creates a blank log at the new (empty) path — the user's real history
97/// becomes unreachable with zero error or warning. This function is meant to be called once at
98/// process startup, before any session is opened, to make that transition transparent.
99///
100/// A destination that already exists is left untouched (skipped, with a `tracing::warn!`)
101/// rather than clobbered. Idempotent: once every legacy subdirectory has been moved (or
102/// skipped), a subsequent run finds an empty (or absent) `<data_dir>/sessions/` and returns
103/// cheaply; a missing `<data_dir>/sessions/` (a brand-new install, or one already migrated) is
104/// not an error.
105///
106/// # Errors
107///
108/// Returns [`SessionError::Io`] if `<data_dir>/sessions/` exists but cannot be listed, or if a
109/// rename or an existence check fails for a reason other than the destination not existing.
110///
111/// # Examples
112///
113/// ```
114/// use std::path::Path;
115///
116/// # #[tokio::main]
117/// # async fn main() {
118/// let dir = tempfile::tempdir().unwrap();
119/// // Brand-new install: no `sessions/` subdirectory yet — a cheap no-op, not an error.
120/// let report = zeph_session::migrate_legacy_session_layout(dir.path()).await.unwrap();
121/// assert_eq!(report, zeph_session::MigrationReport::default());
122/// # }
123/// ```
124pub async fn migrate_legacy_session_layout(
125    data_dir: &std::path::Path,
126) -> Result<MigrationReport, SessionError> {
127    let legacy_root = data_dir.join("sessions");
128
129    let mut entries = match tokio::fs::read_dir(&legacy_root).await {
130        Ok(entries) => entries,
131        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
132            return Ok(MigrationReport::default());
133        }
134        Err(e) => return Err(e.into()),
135    };
136
137    let mut report = MigrationReport::default();
138    while let Some(entry) = entries.next_entry().await? {
139        if !entry.file_type().await?.is_dir() {
140            continue;
141        }
142
143        let src = entry.path();
144        let dest = data_dir.join(entry.file_name());
145
146        if tokio::fs::try_exists(&dest).await? {
147            tracing::warn!(
148                src = %src.display(),
149                dest = %dest.display(),
150                "legacy session directory left in place: destination already exists (#5981)"
151            );
152            report.skipped += 1;
153            continue;
154        }
155
156        tokio::fs::rename(&src, &dest).await?;
157        report.migrated += 1;
158    }
159
160    if report.migrated > 0 || report.skipped > 0 {
161        tracing::info!(
162            migrated = report.migrated,
163            skipped = report.skipped,
164            "migrated legacy (#5981) session directory layout"
165        );
166    }
167
168    Ok(report)
169}
170
171#[cfg(test)]
172mod tests {
173    use std::path::Path;
174
175    use super::{MigrationReport, migrate_legacy_session_layout, session_dir};
176
177    /// Regression test for #5981: `session_dir` must not append a redundant `sessions` segment
178    /// when `data_dir` (e.g. the configured default `.zeph/sessions`) already names the
179    /// sessions root, or every on-disk path double-nests as `sessions/sessions/<id>`.
180    #[test]
181    fn session_dir_does_not_double_nest() {
182        let dir = session_dir(Path::new(".zeph/sessions"), "abc-123");
183        assert_eq!(dir, Path::new(".zeph/sessions/abc-123"));
184    }
185
186    #[test]
187    fn session_dir_joins_arbitrary_data_dir() {
188        let dir = session_dir(Path::new("/var/lib/zeph/data"), "s1");
189        assert_eq!(dir, Path::new("/var/lib/zeph/data/s1"));
190    }
191
192    /// (a) Migrates an existing pre-#5981 session directory up one level, and — run a second
193    /// time — confirms idempotency (nothing left to move, cheap no-op).
194    #[tokio::test]
195    async fn migrate_moves_legacy_session_dir_up_one_level() {
196        let data_dir = tempfile::tempdir().unwrap();
197        let legacy = data_dir.path().join("sessions").join("abc-123");
198        tokio::fs::create_dir_all(&legacy).await.unwrap();
199        tokio::fs::write(legacy.join("events.jsonl"), b"{}\n")
200            .await
201            .unwrap();
202
203        let report = migrate_legacy_session_layout(data_dir.path())
204            .await
205            .unwrap();
206        assert_eq!(
207            report,
208            MigrationReport {
209                migrated: 1,
210                skipped: 0
211            }
212        );
213        assert!(
214            data_dir
215                .path()
216                .join("abc-123")
217                .join("events.jsonl")
218                .exists()
219        );
220        assert!(!legacy.exists());
221
222        // Idempotency: running again finds nothing left under `sessions/`.
223        let second = migrate_legacy_session_layout(data_dir.path())
224            .await
225            .unwrap();
226        assert_eq!(second, MigrationReport::default());
227    }
228
229    /// (b) No-op when the legacy `sessions/` directory exists but is empty.
230    #[tokio::test]
231    async fn migrate_is_noop_when_legacy_dir_is_empty() {
232        let data_dir = tempfile::tempdir().unwrap();
233        tokio::fs::create_dir_all(data_dir.path().join("sessions"))
234            .await
235            .unwrap();
236
237        let report = migrate_legacy_session_layout(data_dir.path())
238            .await
239            .unwrap();
240        assert_eq!(report, MigrationReport::default());
241    }
242
243    /// (c) Skips (with a warning, not an error) when a directory already exists at the
244    /// destination — must not clobber a session that may have already been recreated there.
245    #[tokio::test]
246    async fn migrate_skips_when_destination_already_exists() {
247        let data_dir = tempfile::tempdir().unwrap();
248        let legacy = data_dir.path().join("sessions").join("abc-123");
249        tokio::fs::create_dir_all(&legacy).await.unwrap();
250        tokio::fs::write(legacy.join("events.jsonl"), b"old\n")
251            .await
252            .unwrap();
253
254        let dest = data_dir.path().join("abc-123");
255        tokio::fs::create_dir_all(&dest).await.unwrap();
256        tokio::fs::write(dest.join("events.jsonl"), b"new\n")
257            .await
258            .unwrap();
259
260        let report = migrate_legacy_session_layout(data_dir.path())
261            .await
262            .unwrap();
263        assert_eq!(
264            report,
265            MigrationReport {
266                migrated: 0,
267                skipped: 1
268            }
269        );
270        assert_eq!(
271            tokio::fs::read(dest.join("events.jsonl")).await.unwrap(),
272            b"new\n",
273            "destination must not be clobbered"
274        );
275        assert!(legacy.exists(), "legacy directory must be left in place");
276    }
277
278    /// (d) No-op, not an error, when `<data_dir>/sessions/` doesn't exist at all (brand-new
279    /// install, or a `data_dir` already fully migrated).
280    #[tokio::test]
281    async fn migrate_is_noop_when_legacy_root_missing() {
282        let data_dir = tempfile::tempdir().unwrap();
283        let report = migrate_legacy_session_layout(data_dir.path())
284            .await
285            .unwrap();
286        assert_eq!(report, MigrationReport::default());
287    }
288}