1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17#[non_exhaustive]
18pub enum SessionKind {
19 Interactive,
21 Autonomous,
23 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59#[non_exhaustive]
60pub enum SessionStatus {
61 Active,
63 Completed,
65 Failed,
67 Cancelled,
69 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#[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#[derive(Debug, Clone)]
159pub struct AgentSessionRow {
160 pub id: String,
162 pub kind: SessionKind,
164 pub status: SessionStatus,
166 pub channel: SessionChannel,
168 pub model: String,
170 pub created_at: String,
172 pub last_active_at: String,
174 pub turns: u32,
176 pub prompt_tokens: u64,
178 pub completion_tokens: u64,
180 pub reasoning_tokens: u64,
182 pub cost_cents: f64,
184 pub goal_text: Option<String>,
186}
187
188impl SqliteStore {
189 #[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 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 .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 #[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 #[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 #[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 i32,
328 i64,
329 i64,
330 i64,
331 f64,
332 Option<String>,
333 );
334 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}