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}