Skip to main content

zeph_memory/store/
agent_sessions.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! CRUD for the `agent_sessions` table used by the fleet dashboard (#3884).
5
6use serde::{Deserialize, Serialize};
7use zeph_db::ActiveDialect;
8#[allow(unused_imports)]
9use zeph_db::sql;
10
11use super::SqliteStore;
12use crate::error::MemoryError;
13
14/// Discriminant for the type of agent session.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17#[non_exhaustive]
18pub enum SessionKind {
19    /// An interactive conversation session (CLI or TUI).
20    Interactive,
21    /// An autonomous goal-directed session.
22    Autonomous,
23    /// An ACP (agent-to-agent communication protocol) session.
24    Acp,
25}
26
27impl SessionKind {
28    fn as_str(self) -> &'static str {
29        match self {
30            Self::Interactive => "interactive",
31            Self::Autonomous => "autonomous",
32            Self::Acp => "acp",
33        }
34    }
35}
36
37impl std::fmt::Display for SessionKind {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.write_str(self.as_str())
40    }
41}
42
43impl std::str::FromStr for SessionKind {
44    type Err = String;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        match s {
48            "interactive" => Ok(Self::Interactive),
49            "autonomous" => Ok(Self::Autonomous),
50            "acp" => Ok(Self::Acp),
51            other => Err(format!("unknown session kind: {other}")),
52        }
53    }
54}
55
56/// Lifecycle status of an agent session.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59#[non_exhaustive]
60pub enum SessionStatus {
61    /// Session is currently running.
62    Active,
63    /// Session ended normally.
64    Completed,
65    /// Session ended due to an unrecoverable error.
66    Failed,
67    /// Session was cancelled by the user.
68    Cancelled,
69    /// Session was active at a previous startup and presumed crashed (no clean shutdown recorded).
70    Unknown,
71}
72
73impl SessionStatus {
74    fn as_str(self) -> &'static str {
75        match self {
76            Self::Active => "active",
77            Self::Completed => "completed",
78            Self::Failed => "failed",
79            Self::Cancelled => "cancelled",
80            Self::Unknown => "unknown",
81        }
82    }
83}
84
85impl std::fmt::Display for SessionStatus {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(self.as_str())
88    }
89}
90
91impl std::str::FromStr for SessionStatus {
92    type Err = String;
93
94    fn from_str(s: &str) -> Result<Self, Self::Err> {
95        match s {
96            "active" => Ok(Self::Active),
97            "completed" => Ok(Self::Completed),
98            "failed" => Ok(Self::Failed),
99            "cancelled" => Ok(Self::Cancelled),
100            "unknown" => Ok(Self::Unknown),
101            other => Err(format!("unknown session status: {other}")),
102        }
103    }
104}
105
106/// Channel over which the session was initiated.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "lowercase")]
109#[non_exhaustive]
110pub enum SessionChannel {
111    Cli,
112    Tui,
113    Telegram,
114    Discord,
115    Slack,
116    Acp,
117}
118
119impl SessionChannel {
120    fn as_str(self) -> &'static str {
121        match self {
122            Self::Cli => "cli",
123            Self::Tui => "tui",
124            Self::Telegram => "telegram",
125            Self::Discord => "discord",
126            Self::Slack => "slack",
127            Self::Acp => "acp",
128        }
129    }
130}
131
132impl std::fmt::Display for SessionChannel {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.write_str(self.as_str())
135    }
136}
137
138impl std::str::FromStr for SessionChannel {
139    type Err = String;
140
141    fn from_str(s: &str) -> Result<Self, Self::Err> {
142        match s {
143            "cli" => Ok(Self::Cli),
144            "tui" => Ok(Self::Tui),
145            "telegram" => Ok(Self::Telegram),
146            "discord" => Ok(Self::Discord),
147            "slack" => Ok(Self::Slack),
148            "acp" => Ok(Self::Acp),
149            other => Err(format!("unknown session channel: {other}")),
150        }
151    }
152}
153
154/// A row from the `agent_sessions` table.
155///
156/// Each field maps directly to a column. Token fields are informational; `reasoning_tokens`
157/// is a subset of `completion_tokens` and must **not** be added separately to cost.
158#[derive(Debug, Clone)]
159pub struct AgentSessionRow {
160    /// Stable session identifier (UUID).
161    pub id: String,
162    /// Session type discriminant.
163    pub kind: SessionKind,
164    /// Current lifecycle state.
165    pub status: SessionStatus,
166    /// Channel over which the session runs.
167    pub channel: SessionChannel,
168    /// LLM model name (e.g. `claude-sonnet-4-6`).
169    pub model: String,
170    /// ISO-8601 creation timestamp (UTC).
171    pub created_at: String,
172    /// ISO-8601 timestamp of the most recent agent turn (UTC).
173    pub last_active_at: String,
174    /// Number of completed agent turns.
175    pub turns: u32,
176    /// Prompt (input) tokens consumed across all turns.
177    pub prompt_tokens: u64,
178    /// Completion (output) tokens generated across all turns.
179    pub completion_tokens: u64,
180    /// Reasoning tokens (subset of `completion_tokens`; `OpenAI` only, others are 0).
181    pub reasoning_tokens: u64,
182    /// Estimated session cost in US cents.
183    pub cost_cents: f64,
184    /// Goal description for autonomous sessions; `None` for interactive sessions.
185    pub goal_text: Option<String>,
186}
187
188impl SqliteStore {
189    /// Insert or update an agent session row.
190    ///
191    /// On conflict on `id` the row is updated with the latest field values.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the database write fails.
196    #[tracing::instrument(name = "memory.fleet.upsert_session", skip_all, level = "debug", err)]
197    pub async fn upsert_agent_session(&self, s: &AgentSessionRow) -> Result<(), MemoryError> {
198        // `created_at`/`last_active_at` are Rust-formatted timestamp strings bound into a
199        // `TIMESTAMPTZ` column on Postgres (`TEXT` on SQLite). Postgres has no implicit
200        // text->timestamptz cast, so an explicit `::timestamptz` suffix is required on those
201        // two placeholders (PgDatabaseError 42804 otherwise); `excluded.last_active_at` in the
202        // conflict clause already carries the column's native type, no cast needed there.
203        let ts_cast = <ActiveDialect as zeph_db::dialect::Dialect>::TIMESTAMPTZ_CAST;
204        let raw = format!(
205            "INSERT INTO agent_sessions \
206             (id, kind, status, channel, model, created_at, last_active_at, \
207              turns, prompt_tokens, completion_tokens, reasoning_tokens, cost_cents, goal_text) \
208             VALUES (?, ?, ?, ?, ?, ?{ts_cast}, ?{ts_cast}, ?, ?, ?, ?, ?, ?) \
209             ON CONFLICT(id) DO UPDATE SET \
210               kind = excluded.kind, \
211               status = excluded.status, \
212               channel = excluded.channel, \
213               model = excluded.model, \
214               last_active_at = excluded.last_active_at, \
215               turns = excluded.turns, \
216               prompt_tokens = excluded.prompt_tokens, \
217               completion_tokens = excluded.completion_tokens, \
218               reasoning_tokens = excluded.reasoning_tokens, \
219               cost_cents = excluded.cost_cents, \
220               goal_text = excluded.goal_text"
221        );
222        let query_sql = zeph_db::rewrite_placeholders(&raw);
223        zeph_db::query(sqlx::AssertSqlSafe(query_sql))
224            .bind(&s.id)
225            .bind(s.kind.as_str())
226            .bind(s.status.as_str())
227            .bind(s.channel.as_str())
228            .bind(&s.model)
229            .bind(&s.created_at)
230            .bind(&s.last_active_at)
231            // `turns` maps to an `INTEGER` column in both dialects; bind it as `i32` so the query
232            // stays backend-agnostic. A bare `u32` only implements `Type<Sqlite>` (Postgres has no
233            // unsigned integer types), which pins the query to the SQLite driver and breaks the
234            // Postgres backend (#4964).
235            .bind(s.turns.cast_signed())
236            .bind(s.prompt_tokens.cast_signed())
237            .bind(s.completion_tokens.cast_signed())
238            .bind(s.reasoning_tokens.cast_signed())
239            .bind(s.cost_cents)
240            .bind(&s.goal_text)
241            .execute(&self.pool)
242            .await?;
243        Ok(())
244    }
245
246    /// Update the status of a session by its ID.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the database write fails.
251    #[tracing::instrument(
252        name = "memory.fleet.update_agent_session_status",
253        skip_all,
254        level = "debug",
255        err
256    )]
257    pub async fn update_agent_session_status(
258        &self,
259        id: &str,
260        status: SessionStatus,
261    ) -> Result<(), MemoryError> {
262        zeph_db::query(sql!(
263            "UPDATE agent_sessions SET status = ?, last_active_at = CURRENT_TIMESTAMP WHERE id = ?"
264        ))
265        .bind(status.as_str())
266        .bind(id)
267        .execute(&self.pool)
268        .await?;
269        Ok(())
270    }
271
272    /// Mark all sessions that are still `active` as `unknown` (crashed), except
273    /// for the current session.
274    ///
275    /// Called on startup to reconcile stale sessions from a previous unclean shutdown.
276    ///
277    /// # Errors
278    ///
279    /// Returns an error if the database write fails.
280    #[tracing::instrument(
281        name = "memory.fleet.reconcile_stale_sessions",
282        skip_all,
283        level = "debug",
284        err
285    )]
286    pub async fn reconcile_stale_sessions(
287        &self,
288        current_session_id: &str,
289    ) -> Result<u64, MemoryError> {
290        let result = zeph_db::query(sql!(
291            "UPDATE agent_sessions SET status = 'unknown' \
292             WHERE status = 'active' AND id != ?"
293        ))
294        .bind(current_session_id)
295        .execute(&self.pool)
296        .await?;
297        Ok(result.rows_affected())
298    }
299
300    /// List agent sessions ordered by `last_active_at` descending.
301    ///
302    /// Pass `status_filter = Some(status)` to restrict results to that lifecycle state.
303    /// Pass `limit = 0` for unlimited results.
304    ///
305    /// # Errors
306    ///
307    /// Returns an error if the database query fails.
308    #[tracing::instrument(name = "memory.fleet.list_sessions", skip_all, level = "debug", err)]
309    pub async fn list_agent_sessions(
310        &self,
311        limit: u32,
312        status_filter: Option<SessionStatus>,
313    ) -> Result<Vec<AgentSessionRow>, MemoryError> {
314        #[allow(clippy::cast_possible_wrap)]
315        let sql_limit: i64 = if limit == 0 { -1 } else { i64::from(limit) };
316
317        type SessionRow = (
318            String,
319            String,
320            String,
321            String,
322            String,
323            String,
324            String,
325            // `turns` is an `INTEGER` column (INT4 on Postgres); decode it as `i32` so the
326            // Postgres driver does not reject it as an `i64`/BIGINT mismatch (#4964).
327            i32,
328            i64,
329            i64,
330            i64,
331            f64,
332            Option<String>,
333        );
334        // `created_at`/`last_active_at` are `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite);
335        // decoding straight into `String` fails on Postgres without an explicit `::text`
336        // projection, mirroring `Dialect::select_as_text`'s use elsewhere for the same
337        // native-column-type-vs-`String`-decode mismatch.
338        let created_at_sel =
339            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
340        let last_active_at_sel =
341            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("last_active_at");
342        let rows: Vec<SessionRow> = if let Some(sf) = status_filter {
343            let raw = format!(
344                "SELECT id, kind, status, channel, model, {created_at_sel}, {last_active_at_sel}, \
345                 turns, prompt_tokens, completion_tokens, reasoning_tokens, cost_cents, goal_text \
346                 FROM agent_sessions WHERE status = ? \
347                 ORDER BY last_active_at DESC LIMIT ?"
348            );
349            let query_sql = zeph_db::rewrite_placeholders(&raw);
350            zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
351                .bind(sf.as_str())
352                .bind(sql_limit)
353                .fetch_all(&self.pool)
354                .await?
355        } else {
356            let raw = format!(
357                "SELECT id, kind, status, channel, model, {created_at_sel}, {last_active_at_sel}, \
358                 turns, prompt_tokens, completion_tokens, reasoning_tokens, cost_cents, goal_text \
359                 FROM agent_sessions \
360                 ORDER BY last_active_at DESC LIMIT ?"
361            );
362            let query_sql = zeph_db::rewrite_placeholders(&raw);
363            zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
364                .bind(sql_limit)
365                .fetch_all(&self.pool)
366                .await?
367        };
368
369        Ok(rows
370            .into_iter()
371            .map(
372                |(
373                    id,
374                    kind_s,
375                    status_s,
376                    channel_s,
377                    model,
378                    created_at,
379                    last_active_at,
380                    turns,
381                    prompt_tokens,
382                    completion_tokens,
383                    reasoning_tokens,
384                    cost_cents,
385                    goal_text,
386                )| {
387                    AgentSessionRow {
388                        id,
389                        kind: kind_s.parse().unwrap_or(SessionKind::Interactive),
390                        status: status_s.parse().unwrap_or(SessionStatus::Unknown),
391                        channel: channel_s.parse().unwrap_or(SessionChannel::Cli),
392                        model,
393                        created_at,
394                        last_active_at,
395                        turns: u32::try_from(turns).unwrap_or(0),
396                        prompt_tokens: u64::try_from(prompt_tokens).unwrap_or(0),
397                        completion_tokens: u64::try_from(completion_tokens).unwrap_or(0),
398                        reasoning_tokens: u64::try_from(reasoning_tokens).unwrap_or(0),
399                        cost_cents,
400                        goal_text,
401                    }
402                },
403            )
404            .collect())
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    async fn make_store() -> SqliteStore {
413        SqliteStore::new(":memory:")
414            .await
415            .expect("SqliteStore::new")
416    }
417
418    fn sample(id: &str) -> AgentSessionRow {
419        AgentSessionRow {
420            id: id.to_owned(),
421            kind: SessionKind::Interactive,
422            status: SessionStatus::Active,
423            channel: SessionChannel::Cli,
424            model: "claude-sonnet-4-6".to_owned(),
425            created_at: "2026-01-01T00:00:00".to_owned(),
426            last_active_at: "2026-01-01T00:00:00".to_owned(),
427            turns: 0,
428            prompt_tokens: 0,
429            completion_tokens: 0,
430            reasoning_tokens: 0,
431            cost_cents: 0.0,
432            goal_text: None,
433        }
434    }
435
436    #[tokio::test]
437    async fn upsert_and_list() {
438        let store = make_store().await;
439        store.upsert_agent_session(&sample("s1")).await.unwrap();
440        let rows = store.list_agent_sessions(10, None).await.unwrap();
441        assert_eq!(rows.len(), 1);
442        assert_eq!(rows[0].id, "s1");
443        assert_eq!(rows[0].kind, SessionKind::Interactive);
444        assert_eq!(rows[0].status, SessionStatus::Active);
445    }
446
447    #[tokio::test]
448    async fn upsert_updates_existing() {
449        let store = make_store().await;
450        store.upsert_agent_session(&sample("s1")).await.unwrap();
451        let mut updated = sample("s1");
452        updated.turns = 5;
453        updated.status = SessionStatus::Completed;
454        store.upsert_agent_session(&updated).await.unwrap();
455
456        let rows = store.list_agent_sessions(10, None).await.unwrap();
457        assert_eq!(rows.len(), 1);
458        assert_eq!(rows[0].turns, 5);
459        assert_eq!(rows[0].status, SessionStatus::Completed);
460    }
461
462    #[tokio::test]
463    async fn update_status() {
464        let store = make_store().await;
465        store.upsert_agent_session(&sample("s1")).await.unwrap();
466        store
467            .update_agent_session_status("s1", SessionStatus::Failed)
468            .await
469            .unwrap();
470        let rows = store.list_agent_sessions(10, None).await.unwrap();
471        assert_eq!(rows[0].status, SessionStatus::Failed);
472    }
473
474    #[tokio::test]
475    async fn reconcile_stale_sessions() {
476        let store = make_store().await;
477        store.upsert_agent_session(&sample("s1")).await.unwrap();
478        store.upsert_agent_session(&sample("s2")).await.unwrap();
479        let affected = store.reconcile_stale_sessions("s2").await.unwrap();
480        assert_eq!(affected, 1);
481
482        let rows = store.list_agent_sessions(10, None).await.unwrap();
483        let s1 = rows.iter().find(|r| r.id == "s1").unwrap();
484        let s2 = rows.iter().find(|r| r.id == "s2").unwrap();
485        assert_eq!(s1.status, SessionStatus::Unknown);
486        assert_eq!(s2.status, SessionStatus::Active);
487    }
488
489    #[tokio::test]
490    async fn list_with_status_filter() {
491        let store = make_store().await;
492        store.upsert_agent_session(&sample("s1")).await.unwrap();
493        let mut s2 = sample("s2");
494        s2.status = SessionStatus::Completed;
495        store.upsert_agent_session(&s2).await.unwrap();
496
497        let active = store
498            .list_agent_sessions(10, Some(SessionStatus::Active))
499            .await
500            .unwrap();
501        assert_eq!(active.len(), 1);
502        assert_eq!(active[0].id, "s1");
503    }
504
505    #[tokio::test]
506    async fn list_respects_limit() {
507        let store = make_store().await;
508        for i in 0..5u8 {
509            store
510                .upsert_agent_session(&sample(&format!("s{i}")))
511                .await
512                .unwrap();
513        }
514        let rows = store.list_agent_sessions(3, None).await.unwrap();
515        assert_eq!(rows.len(), 3);
516    }
517
518    #[tokio::test]
519    async fn session_kind_roundtrip() {
520        use std::str::FromStr as _;
521        assert_eq!(
522            SessionKind::from_str("autonomous").unwrap(),
523            SessionKind::Autonomous
524        );
525        assert!(SessionKind::from_str("bad").is_err());
526    }
527
528    #[tokio::test]
529    async fn session_status_unknown_roundtrip() {
530        use std::str::FromStr as _;
531        assert_eq!(
532            SessionStatus::from_str("unknown").unwrap(),
533            SessionStatus::Unknown
534        );
535    }
536}