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.pad(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.pad(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.pad(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 let (limit_clause, limit_bind) = zeph_db::limit_clause(u64::from(limit));
315
316 type SessionRow = (
317 String,
318 String,
319 String,
320 String,
321 String,
322 String,
323 String,
324 i32,
327 i64,
328 i64,
329 i64,
330 f64,
331 Option<String>,
332 );
333 let created_at_sel =
338 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
339 let last_active_at_sel =
340 <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("last_active_at");
341 let rows: Vec<SessionRow> = if let Some(sf) = status_filter {
342 let raw = format!(
343 "SELECT id, kind, status, channel, model, {created_at_sel}, {last_active_at_sel}, \
344 turns, prompt_tokens, completion_tokens, reasoning_tokens, cost_cents, goal_text \
345 FROM agent_sessions WHERE status = ? \
346 ORDER BY last_active_at DESC{limit_clause}"
347 );
348 let query_sql = zeph_db::rewrite_placeholders(&raw);
349 let mut query = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql)).bind(sf.as_str());
350 if let Some(lim) = limit_bind {
351 query = query.bind(lim);
352 }
353 query.fetch_all(&self.pool).await?
354 } else {
355 let raw = format!(
356 "SELECT id, kind, status, channel, model, {created_at_sel}, {last_active_at_sel}, \
357 turns, prompt_tokens, completion_tokens, reasoning_tokens, cost_cents, goal_text \
358 FROM agent_sessions \
359 ORDER BY last_active_at DESC{limit_clause}"
360 );
361 let query_sql = zeph_db::rewrite_placeholders(&raw);
362 let mut query = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql));
363 if let Some(lim) = limit_bind {
364 query = query.bind(lim);
365 }
366 query.fetch_all(&self.pool).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-5".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]
523 async fn list_unlimited_when_zero() {
524 let store = make_store().await;
525 for i in 0..5u8 {
526 store
527 .upsert_agent_session(&sample(&format!("s{i}")))
528 .await
529 .unwrap();
530 }
531 let rows = store.list_agent_sessions(0, None).await.unwrap();
532 assert_eq!(rows.len(), 5);
533
534 let filtered = store
535 .list_agent_sessions(0, Some(SessionStatus::Active))
536 .await
537 .unwrap();
538 assert_eq!(filtered.len(), 5);
539 }
540
541 #[tokio::test]
542 async fn session_kind_roundtrip() {
543 use std::str::FromStr as _;
544 assert_eq!(
545 SessionKind::from_str("autonomous").unwrap(),
546 SessionKind::Autonomous
547 );
548 assert!(SessionKind::from_str("bad").is_err());
549 }
550
551 #[tokio::test]
552 async fn session_status_unknown_roundtrip() {
553 use std::str::FromStr as _;
554 assert_eq!(
555 SessionStatus::from_str("unknown").unwrap(),
556 SessionStatus::Unknown
557 );
558 }
559
560 #[test]
565 fn session_kind_display_respects_width() {
566 assert_eq!(
567 format!("{:<12}", SessionKind::Interactive),
568 format!("{:<12}", "interactive")
569 );
570 assert_eq!(
571 format!("{:<12}", SessionKind::Acp),
572 format!("{:<12}", "acp")
573 );
574 }
575
576 #[test]
577 fn session_status_display_respects_width() {
578 assert_eq!(
579 format!("{:<11}", SessionStatus::Active),
580 format!("{:<11}", "active")
581 );
582 assert_eq!(
583 format!("{:<11}", SessionStatus::Cancelled),
584 format!("{:<11}", "cancelled")
585 );
586 }
587
588 #[test]
589 fn session_channel_display_respects_width() {
590 assert_eq!(
591 format!("{:<5}", SessionChannel::Cli),
592 format!("{:<5}", "cli")
593 );
594 assert_eq!(
595 format!("{:<5}", SessionChannel::Telegram),
596 format!("{:<5}", "telegram")
597 );
598 }
599}