pub mod manager;
pub use manager::Session;
pub mod summarization;
use crate::global_store;
use crate::turso::{self, Connection, TxGuard, Value, params};
use crate::{ChatMessage, Reasoning, ToolCall as ProviderToolCall};
use anyhow::Result;
use chrono::{DateTime, Utc};
use std::path::Path;
global_store! {
pub static SESSIONS: SessionStorage,
constructor = SessionStorage::new_global,
expect = "SESSIONS not initialized",
}
const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_key TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_key_id ON sessions(session_key, id);
CREATE TABLE IF NOT EXISTS session_metadata (
session_key TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
last_activity TEXT NOT NULL
);";
pub(crate) const TRANSIENT_SESSION_PREFIXES: &[&str] =
&["ticket_", "ask_", "maintainer_", "discovery_"];
#[derive(Debug, Clone)]
pub struct SessionMetadata {
pub key: String,
pub created_at: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
pub message_count: usize,
}
#[derive(Clone, Debug)]
pub struct SessionStorage {
pub(crate) conn: Connection,
}
impl SessionStorage {
pub async fn new_global(sessions_root: &Path) -> Result<Self> {
let db_path = sessions_root.join("db/sessions.db");
let conn = turso::open_with_schema(&db_path, SCHEMA).await?;
Ok(Self { conn })
}
}
#[must_use]
fn parse_ts_or_now(s: &str, label: &str) -> DateTime<Utc> {
turso::parse_utc_timestamp(s).unwrap_or_else(|e| {
tracing::warn!(
field = %label,
value = %s,
error = %e,
"Failed to parse session {}, falling back to Utc::now()",
label,
);
Utc::now()
})
}
fn session_metadata_from_row(
key: &str,
created_str: &str,
activity_str: &str,
count: i64,
) -> SessionMetadata {
SessionMetadata {
key: key.to_string(),
created_at: parse_ts_or_now(created_str, "created_at"),
last_activity: parse_ts_or_now(activity_str, "last_activity"),
message_count: usize::try_from(count).unwrap_or(0),
}
}
async fn insert_messages_in_transaction(
tx: &TxGuard<'_>,
session_key: &str,
messages: &[ChatMessage],
) -> Result<()> {
let now = turso::now();
for msg in messages {
tx.execute(
"INSERT INTO sessions (session_key, role, content, created_at) VALUES (?1, ?2, ?3, ?4)",
params![
session_key,
msg.role.clone(),
msg.content.clone(),
now.clone()
],
)
.await?;
}
tx.execute(
"INSERT INTO session_metadata (session_key, created_at, last_activity) \
VALUES (?1, ?2, ?3) \
ON CONFLICT(session_key) DO UPDATE SET \
last_activity = excluded.last_activity",
params![session_key, now.clone(), now],
)
.await?;
Ok(())
}
impl SessionStorage {
pub(crate) async fn load(&self, session_key: &str) -> Vec<ChatMessage> {
let rows = match self
.conn
.query_map(
"SELECT role, content FROM sessions WHERE session_key = ?1 ORDER BY id ASC",
params![session_key],
|row| {
Ok::<_, anyhow::Error>(ChatMessage {
role: row.get(0)?,
content: row.get(1)?,
})
},
)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = %e, session_key, "Failed to load session history, treating as new session");
return Vec::new();
}
};
rows.into_iter()
.filter_map(|r| match r {
Ok(msg) => Some(msg),
Err(e) => {
tracing::warn!(error = %e, session_key, "Failed to decode session row, skipping");
None
}
})
.collect()
}
pub(crate) async fn append(&self, session_key: &str, message: &ChatMessage) -> Result<()> {
self.batch_append(session_key, std::slice::from_ref(message))
.await
}
pub(crate) async fn batch_append(
&self,
session_key: &str,
messages: &[ChatMessage],
) -> Result<()> {
let tx = self.conn.begin_tx().await?;
insert_messages_in_transaction(&tx, session_key, messages).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn replace_messages(
&self,
session_key: &str,
messages: &[ChatMessage],
) -> Result<()> {
let tx = self.conn.begin_tx().await?;
tx.execute(
"DELETE FROM sessions WHERE session_key = ?1",
params![session_key],
)
.await?;
insert_messages_in_transaction(&tx, session_key, messages).await?;
tx.commit().await?;
Ok(())
}
pub(crate) async fn delete(&self, session_key: &str) -> Result<bool> {
let tx = self.conn.begin_tx().await?;
let deleted = tx
.execute(
"DELETE FROM sessions WHERE session_key = ?1",
params![session_key],
)
.await?;
tx.execute(
"DELETE FROM session_metadata WHERE session_key = ?1",
params![session_key],
)
.await?;
tx.commit().await?;
Ok(deleted > 0)
}
pub(crate) async fn list_sessions_with_metadata(&self) -> Vec<SessionMetadata> {
let rows = match self
.conn
.query_map(
"SELECT sm.session_key, sm.created_at, sm.last_activity, COUNT(s.id) \
FROM session_metadata sm \
LEFT JOIN sessions s ON s.session_key = sm.session_key \
GROUP BY sm.session_key \
ORDER BY sm.last_activity DESC",
(),
|row| {
Ok::<_, anyhow::Error>(session_metadata_from_row(
&row.get::<String>(0)?,
&row.get::<String>(1)?,
&row.get::<String>(2)?,
row.get::<i64>(3)?,
))
},
)
.await
{
Ok(rows) => rows,
Err(e) => {
tracing::warn!(error = %e, "Failed to list sessions with metadata, returning empty vec");
return Vec::new();
}
};
rows.into_iter()
.filter_map(|r| match r {
Ok(meta) => Some(meta),
Err(e) => {
tracing::warn!(error = %e, "Failed to decode session metadata row, skipping");
None
}
})
.collect()
}
}
pub async fn cleanup_old_transient_sessions(cutoff: &str) -> Result<u64> {
let session_store = store();
let tx = session_store.conn.begin_tx().await?;
let likes = TRANSIENT_SESSION_PREFIXES
.iter()
.map(|_| "session_key LIKE ?")
.collect::<Vec<_>>()
.join(" OR ");
let prefix_patterns = format!("({likes})");
let build_params = {
let mut p = vec![Value::Text(cutoff.to_string())];
p.extend(
TRANSIENT_SESSION_PREFIXES
.iter()
.map(|prefix| Value::Text(format!("{prefix}%"))),
);
p
};
tx.execute(
&format!(
"DELETE FROM sessions WHERE session_key IN ( \
SELECT session_key FROM session_metadata \
WHERE last_activity < ? AND {prefix_patterns})"
),
build_params.clone(),
)
.await?;
let deleted = tx
.execute(
&format!("DELETE FROM session_metadata WHERE last_activity < ? AND {prefix_patterns}"),
build_params.clone(),
)
.await?;
tx.commit().await?;
Ok(deleted)
}
#[must_use]
pub fn direct_session_key(channel: &str, user_name: &str, role: &str, ws_name: &str) -> String {
format!("{channel}_{user_name}_{role}_{ws_name}")
}
#[must_use]
pub fn ticket_session_key(ticket_id: &str, role: &str) -> String {
format!("ticket_{ticket_id}_{role}")
}
#[must_use]
pub fn manager_session_key(ws_name: &str) -> String {
format!("manager_{ws_name}")
}
#[must_use]
pub fn maintainer_session_key(ws_name: &str) -> String {
format!("maintainer_{}_{}", ws_name, crate::generate_suffix())
}
#[must_use]
pub fn ask_session_key(ws_name: &str, role: &str) -> String {
format!("ask_{}_{}_{}", ws_name, role, crate::generate_suffix())
}
#[must_use]
pub fn discovery_session_key(ws_name: &str, role: &str) -> String {
format!(
"discovery_{}_{}_{}",
ws_name,
role,
crate::generate_suffix()
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static TEST_ID: AtomicU32 = AtomicU32::new(0);
fn unique_key() -> String {
format!("s{}", TEST_ID.fetch_add(1, Ordering::Relaxed))
}
#[tokio::test]
async fn session_store_create_and_load() {
crate::util::test::init_test_stores().await;
let k = unique_key();
store()
.append(&k, &ChatMessage::user("hello"))
.await
.unwrap();
let msgs = store().load(&k).await;
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "hello");
}
#[tokio::test]
async fn session_store_replace_messages() {
crate::util::test::init_test_stores().await;
let k = unique_key();
store().append(&k, &ChatMessage::user("old")).await.unwrap();
store()
.replace_messages(&k, &[ChatMessage::user("new")])
.await
.unwrap();
let msgs = store().load(&k).await;
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].content, "new");
}
#[tokio::test]
async fn session_store_delete() {
crate::util::test::init_test_stores().await;
let k = unique_key();
store().append(&k, &ChatMessage::user("a")).await.unwrap();
assert!(store().delete(&k).await.unwrap());
assert!(!store().delete(&k).await.unwrap());
}
}
#[cfg(test)]
mod transient_prefix_tests {
use super::*;
const SAFE_CHANNELS: &[&str] = &["telegram", "gui"];
#[test]
fn forward_no_collision_with_user_facing_sessions() {
for prefix in TRANSIENT_SESSION_PREFIXES {
let manager_key = manager_session_key("test-ws");
assert!(
!manager_key.starts_with(prefix),
"MANAGER SESSION KEY COLLISION: \
prefix='{prefix}' matches key='{manager_key}'. \
Fix: remove '{prefix}' from TRANSIENT_SESSION_PREFIXES \
or change the manager_session_key pattern.",
);
for channel in SAFE_CHANNELS {
let key = direct_session_key(channel, "testuser", "analyst", "test-ws");
assert!(
!key.starts_with(prefix),
"DIRECT SESSION KEY COLLISION: prefix='{prefix}' \
matches key='{key}' (channel='{channel}'). \
Fix: remove '{prefix}' from TRANSIENT_SESSION_PREFIXES \
or change the session key pattern.",
);
}
}
}
fn assert_transient_key(key: &str, expected_prefix: &str, builder_expr: &str) {
assert!(
key.starts_with(expected_prefix),
"{builder_expr} = '{key}' does not start with '{expected_prefix}'.\n\
Fix: update {builder_expr} to produce keys starting with '{expected_prefix}'.",
);
assert!(
TRANSIENT_SESSION_PREFIXES.contains(&expected_prefix),
"TRANSIENT_SESSION_PREFIXES is missing '{expected_prefix}' — \
{builder_expr} sessions will never be cleaned up.\n\
Fix: add \"{expected_prefix}\" to TRANSIENT_SESSION_PREFIXES.",
);
}
#[test]
fn reverse_transient_builders_use_registered_prefixes() {
assert_transient_key(
&ticket_session_key("abc123", "analyst"),
"ticket_",
"ticket_session_key('abc123', 'analyst')",
);
assert_transient_key(
&ask_session_key("ws", "coder"),
"ask_",
"ask_session_key('ws', 'coder')",
);
assert_transient_key(
&maintainer_session_key("ws"),
"maintainer_",
"maintainer_session_key('ws')",
);
assert_transient_key(
&discovery_session_key("ws", "analyst"),
"discovery_",
"discovery_session_key('ws', 'analyst')",
);
}
}
#[test]
fn parse_ts_or_now_invalid_fallback() {
let before = Utc::now();
let ts = parse_ts_or_now("garbage-input", "test_invalid");
let after = Utc::now();
assert!(
ts >= before - chrono::Duration::seconds(1),
"fallback ts {ts} should not be before {before}",
);
assert!(
ts <= after + chrono::Duration::seconds(1),
"fallback ts {ts} should not be after {after}",
);
}
#[derive(Debug)]
pub(crate) enum DecodedNativeHistoryMessage {
AssistantToolCalls {
content: Option<String>,
tool_calls: Vec<ProviderToolCall>,
reasoning: Option<Reasoning>,
},
AssistantReasoning {
content: Option<String>,
reasoning: Option<Reasoning>,
},
ToolResult {
tool_call_id: Option<String>,
content: String,
},
}
#[derive(Debug)]
pub(crate) struct NativeMessageParts {
pub role: String,
pub content: Option<String>,
pub tool_call_id: Option<String>,
pub tool_calls: Option<Vec<ProviderToolCall>>,
pub reasoning: Option<Reasoning>,
}
impl DecodedNativeHistoryMessage {
pub(crate) fn into_parts(self) -> NativeMessageParts {
match self {
DecodedNativeHistoryMessage::AssistantToolCalls {
content,
tool_calls,
reasoning,
} => NativeMessageParts {
role: "assistant".to_string(),
content,
tool_call_id: None,
tool_calls: Some(tool_calls),
reasoning,
},
DecodedNativeHistoryMessage::AssistantReasoning { content, reasoning } => {
NativeMessageParts {
role: "assistant".to_string(),
content,
tool_call_id: None,
tool_calls: None,
reasoning,
}
}
DecodedNativeHistoryMessage::ToolResult {
tool_call_id,
content,
} => NativeMessageParts {
role: "tool".to_string(),
content: Some(content),
tool_call_id,
tool_calls: None,
reasoning: None,
},
}
}
}
pub(crate) fn decode_native_history_message(
message: &ChatMessage,
) -> Option<DecodedNativeHistoryMessage> {
let parsed = serde_json::from_str::<serde_json::Value>(&message.content).ok();
if message.role == "assistant"
&& let Some(value) = parsed.as_ref()
{
let content = value
.get("content")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string);
let (r, rc, rd) =
crate::providers::reasoning_roundtrip::json_lossless_assistant_reasoning_fields(value);
let reasoning = Reasoning::from_optional_parts(r, rc, rd);
if let Some(tool_calls_value) = value.get("tool_calls")
&& let Ok(mut parsed_calls) =
serde_json::from_value::<Vec<ProviderToolCall>>(tool_calls_value.clone())
{
for call in &mut parsed_calls {
if let Some(s) = call.arguments.as_str()
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(s)
{
call.arguments = v;
}
}
return Some(DecodedNativeHistoryMessage::AssistantToolCalls {
content,
tool_calls: parsed_calls,
reasoning,
});
}
if reasoning.is_some() {
return Some(DecodedNativeHistoryMessage::AssistantReasoning { content, reasoning });
}
}
if message.role == "tool"
&& let Some(value) = parsed.as_ref()
{
return Some(DecodedNativeHistoryMessage::ToolResult {
tool_call_id: value
.get("tool_call_id")
.and_then(serde_json::Value::as_str)
.map(ToString::to_string),
content: value
.get("content")
.and_then(serde_json::Value::as_str)
.map_or_else(|| message.content.clone(), ToString::to_string),
});
}
None
}