Skip to main content

zeph_session/
store.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`SessionStore`]: the `acp_sessions` metadata index.
5//!
6//! Promotes the existing `acp_sessions` table (migration 013, `crates/zeph-memory`) to a
7//! channel-agnostic conversation-session index (spec-068 §2 Decision D1 — no new `sessions`
8//! table is introduced). `zeph-session` talks to the table directly via [`zeph_db::DbPool`]
9//! rather than depending on `zeph-memory`, keeping the crate boundary intact.
10//!
11//! The event log ([`crate::log::SessionEventLog`]) is the source of truth for conversation
12//! content; this store only tracks lightweight, queryable metadata (`last_seq`, `status`,
13//! fork provenance) used to reconcile the projection on open (INV-SP-3) and to answer
14//! `sessions list` without replaying every log.
15
16use zeph_db::{ActiveDialect, DbPool, dialect::Dialect, sql};
17
18use crate::error::SessionError;
19
20/// Lifecycle status of a conversation-session, mirroring the `acp_sessions.status` CHECK
21/// constraint added in migration 106.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
23#[serde(rename_all = "snake_case")]
24pub enum SessionStatus {
25    /// Actively attached to a live agent/actor.
26    Active,
27    /// Persisted but not currently attached.
28    Idle,
29    /// Explicitly archived; excluded from default `list` results.
30    Archived,
31}
32
33impl SessionStatus {
34    /// The `TEXT` representation stored in the `status` column.
35    #[must_use]
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Self::Active => "active",
39            Self::Idle => "idle",
40            Self::Archived => "archived",
41        }
42    }
43}
44
45impl std::str::FromStr for SessionStatus {
46    type Err = SessionError;
47
48    fn from_str(s: &str) -> Result<Self, Self::Err> {
49        match s {
50            "active" => Ok(Self::Active),
51            "idle" => Ok(Self::Idle),
52            "archived" => Ok(Self::Archived),
53            other => Err(SessionError::NotFound(format!(
54                "unknown session status: {other}"
55            ))),
56        }
57    }
58}
59
60/// A conversation-session's metadata row, as tracked in `acp_sessions`.
61#[derive(Debug, Clone, serde::Serialize)]
62pub struct SessionMetadata {
63    pub session_id: String,
64    pub title: Option<String>,
65    pub created_at: String,
66    pub updated_at: String,
67    pub conversation_id: Option<i64>,
68    pub last_seq: u64,
69    pub event_count: u64,
70    pub forked_from: Option<String>,
71    pub forked_at_seq: Option<u64>,
72    pub status: SessionStatus,
73    pub last_condensed_seq: u64,
74}
75
76/// Filter parameters for [`SessionStore::list`].
77#[derive(Debug, Clone, Default)]
78pub struct SessionFilter {
79    /// Restrict to a single status; `None` returns all statuses.
80    pub status: Option<SessionStatus>,
81    /// Maximum rows returned; `0` means unlimited.
82    pub limit: usize,
83}
84
85/// CRUD access to the `acp_sessions` metadata index.
86pub struct SessionStore {
87    pool: DbPool,
88}
89
90impl SessionStore {
91    /// Wrap an existing [`DbPool`]. `zeph-session` does not own a dedicated database file —
92    /// it shares the pool that already owns `acp_sessions` (migration 013).
93    #[must_use]
94    pub fn new(pool: DbPool) -> Self {
95        Self { pool }
96    }
97
98    /// Insert a new session row with `status = 'active'`, ignoring the call if the row already
99    /// exists (idempotent, mirrors the existing `create_acp_session` pattern).
100    ///
101    /// # Errors
102    ///
103    /// Returns [`SessionError::Db`] if the write fails.
104    #[tracing::instrument(name = "session.store.create", skip_all, level = "debug")]
105    pub async fn create(&self, session_id: &str) -> Result<(), SessionError> {
106        let stmt = zeph_db::rewrite_placeholders(&format!(
107            "{} INTO acp_sessions (id, status) VALUES (?, 'active'){}",
108            <ActiveDialect as Dialect>::INSERT_IGNORE,
109            <ActiveDialect as Dialect>::CONFLICT_NOTHING,
110        ));
111        zeph_db::query(sqlx::AssertSqlSafe(stmt))
112            .bind(session_id)
113            .execute(&self.pool)
114            .await?;
115        Ok(())
116    }
117
118    /// Update `last_seq`, `event_count`, and `updated_at` after a turn's events are flushed to
119    /// the log (INV-SP-1: called only after the log append is durable).
120    ///
121    /// Explicitly bumps `updated_at` here because the pre-cutover `AFTER INSERT ON
122    /// acp_session_events` trigger (migration 017) that used to drive it never fires for
123    /// post-cutover sessions (spec-068 §12.3 / D-2: `acp_session_events` is a write target only
124    /// for legacy pre-cutover sessions) — without this, `list_acp_sessions`' "ordered by last
125    /// activity descending" would silently degrade to "ordered by creation time" for every
126    /// session created after the cutover.
127    ///
128    /// # Errors
129    ///
130    /// Returns [`SessionError::Db`] if the write fails.
131    #[allow(clippy::cast_possible_wrap)]
132    #[tracing::instrument(name = "session.store.update_seq", skip_all, level = "debug")]
133    pub async fn update_seq(
134        &self,
135        session_id: &str,
136        last_seq: u64,
137        event_count: u64,
138    ) -> Result<(), SessionError> {
139        let stmt = zeph_db::rewrite_placeholders(&format!(
140            "UPDATE acp_sessions SET last_seq = ?, event_count = ?, updated_at = {} WHERE id = ?",
141            <ActiveDialect as Dialect>::NOW,
142        ));
143        zeph_db::query(sqlx::AssertSqlSafe(stmt))
144            .bind(last_seq as i64)
145            .bind(event_count as i64)
146            .bind(session_id)
147            .execute(&self.pool)
148            .await?;
149        Ok(())
150    }
151
152    /// Update the session's lifecycle status.
153    ///
154    /// # Errors
155    ///
156    /// Returns [`SessionError::Db`] if the write fails.
157    #[tracing::instrument(name = "session.store.set_status", skip_all, level = "debug")]
158    pub async fn set_status(
159        &self,
160        session_id: &str,
161        status: SessionStatus,
162    ) -> Result<(), SessionError> {
163        zeph_db::query(sql!("UPDATE acp_sessions SET status = ? WHERE id = ?"))
164            .bind(status.as_str())
165            .bind(session_id)
166            .execute(&self.pool)
167            .await?;
168        Ok(())
169    }
170
171    /// Update the high-water condensation mark (INV-SP-4 non-overlap tracking).
172    ///
173    /// # Errors
174    ///
175    /// Returns [`SessionError::Db`] if the write fails.
176    #[allow(clippy::cast_possible_wrap)]
177    #[tracing::instrument(name = "session.store.set_condensed_seq", skip_all, level = "debug")]
178    pub async fn set_condensed_seq(
179        &self,
180        session_id: &str,
181        last_condensed_seq: u64,
182    ) -> Result<(), SessionError> {
183        zeph_db::query(sql!(
184            "UPDATE acp_sessions SET last_condensed_seq = ? WHERE id = ?"
185        ))
186        .bind(last_condensed_seq as i64)
187        .bind(session_id)
188        .execute(&self.pool)
189        .await?;
190        Ok(())
191    }
192
193    /// Fetch a single session's metadata.
194    ///
195    /// # Errors
196    ///
197    /// Returns [`SessionError::Db`] if the query fails.
198    #[tracing::instrument(name = "session.store.get", skip_all, level = "debug")]
199    pub async fn get(&self, session_id: &str) -> Result<Option<SessionMetadata>, SessionError> {
200        // `created_at`/`updated_at` are `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project
201        // both through `Dialect::select_as_text` so they decode into `SessionRow`'s `String`
202        // fields, mirroring `zeph-memory`'s `list_acp_sessions`/`list_agent_sessions` fix for
203        // the same mismatch.
204        let created_at_sel = <ActiveDialect as Dialect>::select_as_text("created_at");
205        let updated_at_sel = <ActiveDialect as Dialect>::select_as_text("updated_at");
206        let raw = format!(
207            "SELECT id, title, {created_at_sel}, {updated_at_sel}, conversation_id, last_seq, \
208             event_count, forked_from, forked_at_seq, status, last_condensed_seq \
209             FROM acp_sessions WHERE id = ?"
210        );
211        let query_sql = zeph_db::rewrite_placeholders(&raw);
212        let row = zeph_db::query_as::<_, SessionRow>(sqlx::AssertSqlSafe(query_sql))
213            .bind(session_id)
214            .fetch_optional(&self.pool)
215            .await?;
216        row.map(TryInto::try_into).transpose()
217    }
218
219    /// List sessions, most recently updated first.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`SessionError::Db`] if the query fails.
224    #[tracing::instrument(name = "session.store.list", skip_all, level = "debug")]
225    pub async fn list(&self, filter: &SessionFilter) -> Result<Vec<SessionMetadata>, SessionError> {
226        let status_filter = filter.status.map(SessionStatus::as_str);
227        let (limit_clause, limit_bind) = zeph_db::limit_clause(filter.limit as u64);
228        // `created_at`/`updated_at` are `TIMESTAMPTZ` on Postgres — see `Self::get`.
229        let created_at_sel = <ActiveDialect as Dialect>::select_as_text("created_at");
230        let updated_at_sel = <ActiveDialect as Dialect>::select_as_text("updated_at");
231
232        let raw = format!(
233            "SELECT id, title, {created_at_sel}, {updated_at_sel}, conversation_id, last_seq, \
234             event_count, forked_from, forked_at_seq, status, last_condensed_seq \
235             FROM acp_sessions \
236             WHERE (? IS NULL OR status = ?) \
237             ORDER BY updated_at DESC{limit_clause}"
238        );
239        let query_sql = zeph_db::rewrite_placeholders(&raw);
240        let mut query = zeph_db::query_as::<_, SessionRow>(sqlx::AssertSqlSafe(query_sql))
241            .bind(status_filter)
242            .bind(status_filter);
243        if let Some(lim) = limit_bind {
244            query = query.bind(lim);
245        }
246        let rows = query.fetch_all(&self.pool).await?;
247
248        rows.into_iter().map(TryInto::try_into).collect()
249    }
250
251    /// Link this session to a `ConversationId` (raw `i64` — `zeph-session` does not depend on
252    /// `zeph-memory`'s newtype), enforcing the `SessionId`<->`ConversationId` bijection (spec
253    /// §5.2) via the unique partial index added in migration 106.
254    ///
255    /// # Errors
256    ///
257    /// Returns [`SessionError::Db`] if the write fails (including a unique-constraint violation
258    /// when `conversation_id` is already linked to a different session).
259    #[tracing::instrument(name = "session.store.link_conversation", skip_all, level = "debug")]
260    pub async fn link_conversation(
261        &self,
262        session_id: &str,
263        conversation_id: i64,
264    ) -> Result<(), SessionError> {
265        zeph_db::query(sql!(
266            "UPDATE acp_sessions SET conversation_id = ? WHERE id = ?"
267        ))
268        .bind(conversation_id)
269        .bind(session_id)
270        .execute(&self.pool)
271        .await?;
272        Ok(())
273    }
274
275    /// Look up the session already linked to a `ConversationId`, if any.
276    ///
277    /// Used at non-ACP channel startup (CLI/TUI/Telegram) to resume the same conversation's
278    /// existing session (and its event log) across process restarts, rather than minting a new
279    /// `SessionId` every launch (spec §12.2).
280    ///
281    /// # Errors
282    ///
283    /// Returns [`SessionError::Db`] if the query fails.
284    #[tracing::instrument(
285        name = "session.store.get_by_conversation_id",
286        skip_all,
287        level = "debug"
288    )]
289    pub async fn get_by_conversation_id(
290        &self,
291        conversation_id: i64,
292    ) -> Result<Option<SessionMetadata>, SessionError> {
293        // `created_at`/`updated_at` are `TIMESTAMPTZ` on Postgres — see `Self::get`.
294        let created_at_sel = <ActiveDialect as Dialect>::select_as_text("created_at");
295        let updated_at_sel = <ActiveDialect as Dialect>::select_as_text("updated_at");
296        let raw = format!(
297            "SELECT id, title, {created_at_sel}, {updated_at_sel}, conversation_id, last_seq, \
298             event_count, forked_from, forked_at_seq, status, last_condensed_seq \
299             FROM acp_sessions WHERE conversation_id = ?"
300        );
301        let query_sql = zeph_db::rewrite_placeholders(&raw);
302        let row = zeph_db::query_as::<_, SessionRow>(sqlx::AssertSqlSafe(query_sql))
303            .bind(conversation_id)
304            .fetch_optional(&self.pool)
305            .await?;
306        row.map(TryInto::try_into).transpose()
307    }
308
309    /// Record a fork: sets `forked_from`/`forked_at_seq` on the child row.
310    ///
311    /// Does not touch the parent's log (the `ForkPoint` provenance event is appended by
312    /// [`crate::replay`]'s `ForkEngine`, which owns the parent's [`crate::log::SessionEventLog`]).
313    ///
314    /// `owner` stamps `owner_key` (#5868): ACP's `fork_conversation` passes its connection's
315    /// owner identity so the freshly forked child is immediately listable by its creator;
316    /// the CLI's `sessions fork` passes `None` (operator-scoped, unowned — consistent with
317    /// every other CLI-side session row).
318    ///
319    /// # Errors
320    ///
321    /// Returns [`SessionError::Db`] if either write fails.
322    #[allow(clippy::cast_possible_wrap)]
323    #[tracing::instrument(name = "session.store.record_fork", skip_all, level = "debug")]
324    pub async fn record_fork(
325        &self,
326        new_session_id: &str,
327        src_session_id: &str,
328        forked_at_seq: u64,
329        owner: Option<&str>,
330    ) -> Result<(), SessionError> {
331        let stmt = zeph_db::rewrite_placeholders(&format!(
332            "{} INTO acp_sessions (id, status, forked_from, forked_at_seq, owner_key) \
333             VALUES (?, 'active', ?, ?, ?){}",
334            <ActiveDialect as Dialect>::INSERT_IGNORE,
335            <ActiveDialect as Dialect>::CONFLICT_NOTHING,
336        ));
337        zeph_db::query(sqlx::AssertSqlSafe(stmt))
338            .bind(new_session_id)
339            .bind(src_session_id)
340            .bind(forked_at_seq as i64)
341            .bind(owner)
342            .execute(&self.pool)
343            .await?;
344        Ok(())
345    }
346
347    /// Delete a session's metadata row. Returns `true` if a row was deleted.
348    ///
349    /// Does not remove the on-disk event log directory or blobs — callers with access to
350    /// `[session] data_dir` are responsible for that (mirrors the separation of concerns between
351    /// [`SessionStore`] and [`crate::log::SessionEventLog`]).
352    ///
353    /// # Errors
354    ///
355    /// Returns [`SessionError::Db`] if the write fails.
356    #[tracing::instrument(name = "session.store.delete", skip_all, level = "debug")]
357    pub async fn delete(&self, session_id: &str) -> Result<bool, SessionError> {
358        let result = zeph_db::query(sql!("DELETE FROM acp_sessions WHERE id = ?"))
359            .bind(session_id)
360            .execute(&self.pool)
361            .await?;
362        Ok(result.rows_affected() > 0)
363    }
364}
365
366#[derive(sqlx::FromRow)]
367struct SessionRow {
368    id: String,
369    title: Option<String>,
370    created_at: String,
371    updated_at: String,
372    conversation_id: Option<i64>,
373    last_seq: i64,
374    event_count: i64,
375    forked_from: Option<String>,
376    forked_at_seq: Option<i64>,
377    status: String,
378    last_condensed_seq: i64,
379}
380
381impl TryFrom<SessionRow> for SessionMetadata {
382    type Error = SessionError;
383
384    fn try_from(row: SessionRow) -> Result<Self, Self::Error> {
385        Ok(Self {
386            session_id: row.id,
387            title: row.title,
388            created_at: row.created_at,
389            updated_at: row.updated_at,
390            conversation_id: row.conversation_id,
391            last_seq: u64::try_from(row.last_seq).unwrap_or(0),
392            event_count: u64::try_from(row.event_count).unwrap_or(0),
393            forked_from: row.forked_from,
394            forked_at_seq: row.forked_at_seq.map(|v| u64::try_from(v).unwrap_or(0)),
395            status: row.status.parse()?,
396            last_condensed_seq: u64::try_from(row.last_condensed_seq).unwrap_or(0),
397        })
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    async fn make_pool() -> DbPool {
406        let config = zeph_db::DbConfig {
407            url: ":memory:".to_owned(),
408            ..Default::default()
409        };
410        let pool = config
411            .connect()
412            .await
413            .expect("connect in-memory sqlite pool");
414        zeph_db::run_migrations(&pool)
415            .await
416            .expect("run migrations");
417        pool
418    }
419
420    #[tokio::test]
421    async fn test_migration_106_idempotent() {
422        let pool = make_pool().await;
423        zeph_db::run_migrations(&pool)
424            .await
425            .expect("second run is a no-op");
426    }
427
428    #[tokio::test]
429    async fn create_and_get_defaults() {
430        let store = SessionStore::new(make_pool().await);
431        store.create("s1").await.unwrap();
432        let meta = store.get("s1").await.unwrap().expect("row exists");
433        assert_eq!(meta.session_id, "s1");
434        assert_eq!(meta.last_seq, 0);
435        assert_eq!(meta.event_count, 0);
436        assert_eq!(meta.status, SessionStatus::Active);
437        assert!(meta.forked_from.is_none());
438    }
439
440    #[tokio::test]
441    async fn update_seq_persists() {
442        let store = SessionStore::new(make_pool().await);
443        store.create("s1").await.unwrap();
444        store.update_seq("s1", 41, 20).await.unwrap();
445        let meta = store.get("s1").await.unwrap().unwrap();
446        assert_eq!(meta.last_seq, 41);
447        assert_eq!(meta.event_count, 20);
448    }
449
450    #[tokio::test]
451    async fn set_status_persists() {
452        let store = SessionStore::new(make_pool().await);
453        store.create("s1").await.unwrap();
454        store.set_status("s1", SessionStatus::Idle).await.unwrap();
455        let meta = store.get("s1").await.unwrap().unwrap();
456        assert_eq!(meta.status, SessionStatus::Idle);
457    }
458
459    #[tokio::test]
460    async fn record_fork_sets_provenance() {
461        let store = SessionStore::new(make_pool().await);
462        store.create("parent").await.unwrap();
463        store
464            .record_fork("child", "parent", 12, None)
465            .await
466            .unwrap();
467        let meta = store.get("child").await.unwrap().unwrap();
468        assert_eq!(meta.forked_from.as_deref(), Some("parent"));
469        assert_eq!(meta.forked_at_seq, Some(12));
470    }
471
472    /// Regression test (#5868): `record_fork`'s own `INSERT` must stamp `owner_key` on the
473    /// child row, mirroring `create_acp_session`. Found mid-implementation: `record_fork`
474    /// (used by `ForkEngine::fork`, ACP's `fork_conversation` under `[session] enabled = true`)
475    /// has a separate INSERT statement that bypassed `create_acp_session` entirely — every fork
476    /// would have landed `owner_key = NULL` regardless of the `owner` argument, making it
477    /// invisible in the fork-creator's own scoped `list_sessions` immediately after forking.
478    #[tokio::test]
479    async fn record_fork_stamps_owner_key_on_child_row() {
480        let pool = make_pool().await;
481        let store = SessionStore::new(pool.clone());
482        store.create("parent").await.unwrap();
483        store
484            .record_fork("child", "parent", 12, Some("alice"))
485            .await
486            .unwrap();
487
488        let owner_key: Option<String> =
489            zeph_db::query_scalar(sql!("SELECT owner_key FROM acp_sessions WHERE id = ?"))
490                .bind("child")
491                .fetch_one(&pool)
492                .await
493                .unwrap();
494        assert_eq!(owner_key.as_deref(), Some("alice"));
495    }
496
497    /// `record_fork(owner: None)` (the CLI / non-ACP fork path) must leave the child row
498    /// unowned, matching every other non-ACP write path (spec-068 Decision D1).
499    #[tokio::test]
500    async fn record_fork_with_no_owner_leaves_child_row_unowned() {
501        let pool = make_pool().await;
502        let store = SessionStore::new(pool.clone());
503        store.create("parent").await.unwrap();
504        store
505            .record_fork("child", "parent", 12, None)
506            .await
507            .unwrap();
508
509        let owner_key: Option<String> =
510            zeph_db::query_scalar(sql!("SELECT owner_key FROM acp_sessions WHERE id = ?"))
511                .bind("child")
512                .fetch_one(&pool)
513                .await
514                .unwrap();
515        assert!(owner_key.is_none());
516    }
517
518    #[tokio::test]
519    async fn list_filters_by_status() {
520        let store = SessionStore::new(make_pool().await);
521        store.create("s1").await.unwrap();
522        store.create("s2").await.unwrap();
523        store
524            .set_status("s2", SessionStatus::Archived)
525            .await
526            .unwrap();
527
528        let active = store
529            .list(&SessionFilter {
530                status: Some(SessionStatus::Active),
531                limit: 0,
532            })
533            .await
534            .unwrap();
535        assert_eq!(active.len(), 1);
536        assert_eq!(active[0].session_id, "s1");
537
538        let all = store.list(&SessionFilter::default()).await.unwrap();
539        assert_eq!(all.len(), 2);
540    }
541
542    /// Regression test (#5980): `SessionStore::list` used to bind `LIMIT ?` with `-1` for the
543    /// `limit == 0` ("unlimited") sentinel — a `SQLite`-only convenience that `PostgreSQL`
544    /// rejects at execution time. This exercises the non-zero limit branch on `SQLite`; the
545    /// Postgres-specific `limit == 0` regression is covered by
546    /// `tests/postgres_integration.rs::list_unlimited_when_zero_postgres`.
547    #[tokio::test]
548    async fn list_respects_nonzero_limit() {
549        let store = SessionStore::new(make_pool().await);
550        for i in 0..5u8 {
551            store.create(&format!("s{i}")).await.unwrap();
552        }
553
554        let limited = store
555            .list(&SessionFilter {
556                status: None,
557                limit: 3,
558            })
559            .await
560            .unwrap();
561        assert_eq!(limited.len(), 3);
562    }
563
564    #[tokio::test]
565    async fn delete_removes_row() {
566        let store = SessionStore::new(make_pool().await);
567        store.create("s1").await.unwrap();
568        assert!(store.delete("s1").await.unwrap());
569        assert!(store.get("s1").await.unwrap().is_none());
570        assert!(!store.delete("s1").await.unwrap());
571    }
572
573    #[tokio::test]
574    async fn get_missing_returns_none() {
575        let store = SessionStore::new(make_pool().await);
576        assert!(store.get("no-such").await.unwrap().is_none());
577    }
578
579    #[tokio::test]
580    async fn link_conversation_and_lookup_round_trips() {
581        let pool = make_pool().await;
582        let store = SessionStore::new(pool.clone());
583        store.create("s1").await.unwrap();
584
585        // `conversation_id` carries an FK to `conversations(id)` (migration 001); insert a row
586        // directly since creating conversations is zeph-memory's domain, out of scope here.
587        let (cid,): (i64,) =
588            zeph_db::query_as("INSERT INTO conversations DEFAULT VALUES RETURNING id")
589                .fetch_one(&pool)
590                .await
591                .unwrap();
592
593        store.link_conversation("s1", cid).await.unwrap();
594
595        let meta = store.get("s1").await.unwrap().unwrap();
596        assert_eq!(meta.conversation_id, Some(cid));
597
598        let found = store.get_by_conversation_id(cid).await.unwrap().unwrap();
599        assert_eq!(found.session_id, "s1");
600    }
601
602    #[tokio::test]
603    async fn get_by_conversation_id_returns_none_when_unlinked() {
604        let store = SessionStore::new(make_pool().await);
605        store.create("s1").await.unwrap();
606        assert!(store.get_by_conversation_id(99).await.unwrap().is_none());
607    }
608}