use std::path::Path;
use chrono::{DateTime, Utc};
use rusqlite::params;
use super::context::StorageContext;
use super::ops::fts_snippet_bytes;
use super::store::with_transaction;
use crate::error::Result;
const LOG_PREFIX: &str = "[session_db:retention]";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RetentionReport {
pub sessions: usize,
pub messages: usize,
pub tool_calls: usize,
pub run_events: usize,
pub run_telemetry: usize,
}
impl RetentionReport {
pub fn total(&self) -> usize {
self.sessions + self.messages + self.tool_calls + self.run_events + self.run_telemetry
}
}
pub fn prune_sessions_before(workspace_dir: &Path, older_than: DateTime<Utc>) -> Result<usize> {
let cutoff = older_than.to_rfc3339();
tracing::debug!("{LOG_PREFIX} prune_sessions_before.entry cutoff={cutoff}");
let removed = with_transaction(workspace_dir, |conn| {
let ids: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT id FROM sessions
WHERE status != 'running' AND ended_at IS NOT NULL AND ended_at < ?1",
)?;
let rows = stmt.query_map(params![cutoff], |row| row.get::<_, String>(0))?;
let mut ids = Vec::new();
for row in rows {
ids.push(row?);
}
ids
};
let mut removed = 0usize;
for id in &ids {
conn.execute(
"DELETE FROM sessions_fts WHERE session_id = ?1",
params![id],
)
.storage_context("delete session FTS rows")?;
removed += conn
.execute("DELETE FROM sessions WHERE id = ?1", params![id])
.storage_context("delete session")?;
}
Ok(removed)
})?;
tracing::debug!("{LOG_PREFIX} prune_sessions_before.exit removed={removed}");
Ok(removed)
}
pub fn trim_session_messages(
workspace_dir: &Path,
session_id: &str,
keep_last: usize,
) -> Result<usize> {
tracing::debug!(
"{LOG_PREFIX} trim_session_messages.entry session={session_id} keep_last={keep_last}"
);
let removed = with_transaction(workspace_dir, |conn| {
let removed = conn
.execute(
"DELETE FROM session_messages
WHERE session_id = ?1 AND id NOT IN (
SELECT id FROM session_messages WHERE session_id = ?1
ORDER BY id DESC LIMIT ?2
)",
params![session_id, keep_last as i64],
)
.storage_context("trim session messages")?;
Ok(removed)
})?;
tracing::debug!("{LOG_PREFIX} trim_session_messages.exit removed={removed}");
Ok(removed)
}
pub fn prune_tool_calls_before(workspace_dir: &Path, older_than: DateTime<Utc>) -> Result<usize> {
let cutoff = older_than.to_rfc3339();
tracing::debug!("{LOG_PREFIX} prune_tool_calls_before.entry cutoff={cutoff}");
let removed = with_transaction(workspace_dir, |conn| {
conn.execute(
"DELETE FROM session_tool_calls WHERE created_at < ?1",
params![cutoff],
)
.storage_context("prune tool calls")
})?;
tracing::debug!("{LOG_PREFIX} prune_tool_calls_before.exit removed={removed}");
Ok(removed)
}
pub fn prune_run_events_before(workspace_dir: &Path, older_than: DateTime<Utc>) -> Result<usize> {
let cutoff = older_than.to_rfc3339();
tracing::debug!("{LOG_PREFIX} prune_run_events_before.entry cutoff={cutoff}");
let removed = with_transaction(workspace_dir, |conn| {
conn.execute(
"DELETE FROM run_events WHERE timestamp < ?1",
params![cutoff],
)
.storage_context("prune run events")
})?;
tracing::debug!("{LOG_PREFIX} prune_run_events_before.exit removed={removed}");
Ok(removed)
}
pub fn prune_run_telemetry_before(
workspace_dir: &Path,
older_than: DateTime<Utc>,
) -> Result<usize> {
let cutoff = older_than.to_rfc3339();
tracing::debug!("{LOG_PREFIX} prune_run_telemetry_before.entry cutoff={cutoff}");
let removed = with_transaction(workspace_dir, |conn| {
conn.execute(
"DELETE FROM run_telemetry WHERE updated_at < ?1",
params![cutoff],
)
.storage_context("prune run telemetry")
})?;
tracing::debug!("{LOG_PREFIX} prune_run_telemetry_before.exit removed={removed}");
Ok(removed)
}
pub fn apply_retention(workspace_dir: &Path, older_than: DateTime<Utc>) -> Result<RetentionReport> {
tracing::debug!(
"{LOG_PREFIX} apply_retention.entry cutoff={}",
older_than.to_rfc3339()
);
let cutoff = older_than.to_rfc3339();
let report = with_transaction(workspace_dir, |conn| {
let ids: Vec<String> = {
let mut stmt = conn.prepare(
"SELECT id FROM sessions WHERE status != 'running' AND ended_at IS NOT NULL AND ended_at < ?1",
)?;
stmt.query_map(params![cutoff], |row| row.get(0))?
.collect::<std::result::Result<_, _>>()?
};
for id in &ids {
conn.execute(
"DELETE FROM sessions_fts WHERE session_id = ?1",
params![id],
)
.storage_context("delete session FTS rows")?;
conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])
.storage_context("delete session")?;
}
Ok(RetentionReport {
sessions: ids.len(),
messages: 0,
tool_calls: conn
.execute(
"DELETE FROM session_tool_calls WHERE created_at < ?1",
params![cutoff],
)
.storage_context("prune tool calls")?,
run_events: conn
.execute(
"DELETE FROM run_events WHERE timestamp < ?1",
params![cutoff],
)
.storage_context("prune run events")?,
run_telemetry: conn
.execute(
"DELETE FROM run_telemetry WHERE updated_at < ?1",
params![cutoff],
)
.storage_context("prune run telemetry")?,
})
})?;
tracing::info!(
"{LOG_PREFIX} apply_retention.exit removed total={} sessions={} tool_calls={} \
run_events={} run_telemetry={}",
report.total(),
report.sessions,
report.tool_calls,
report.run_events,
report.run_telemetry
);
Ok(report)
}
pub fn reindex_fts(workspace_dir: &Path) -> Result<usize> {
tracing::debug!("{LOG_PREFIX} reindex_fts.entry");
let limit = fts_snippet_bytes();
let written = with_transaction(workspace_dir, |conn| {
conn.execute("DELETE FROM sessions_fts", [])
.storage_context("clear sessions_fts")?;
let mut written = 0usize;
{
let mut stmt =
conn.prepare("SELECT id, agent_definition_name FROM sessions ORDER BY rowid ASC")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut insert = conn.prepare(
"INSERT INTO sessions_fts (session_id, agent_definition_name, content, tool_name)
VALUES (?1, ?2, '', '')",
)?;
for row in rows {
let (id, name) = row?;
insert.execute(params![id, name])?;
written += 1;
}
}
{
let mut stmt =
conn.prepare("SELECT session_id, content FROM session_messages ORDER BY id ASC")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut insert = conn.prepare(
"INSERT INTO sessions_fts (session_id, agent_definition_name, content, tool_name)
VALUES (?1, '', ?2, '')",
)?;
for row in rows {
let (session_id, content) = row?;
insert.execute(params![session_id, snippet(&content, limit)])?;
written += 1;
}
}
{
let mut stmt = conn
.prepare("SELECT session_id, tool_name FROM session_tool_calls ORDER BY id ASC")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut insert = conn.prepare(
"INSERT INTO sessions_fts (session_id, agent_definition_name, content, tool_name)
VALUES (?1, '', '', ?2)",
)?;
for row in rows {
let (session_id, tool_name) = row?;
insert.execute(params![session_id, tool_name])?;
written += 1;
}
}
Ok(written)
})?;
tracing::info!("{LOG_PREFIX} reindex_fts.exit rows={written}");
Ok(written)
}
fn snippet(content: &str, limit: usize) -> &str {
if content.len() <= limit {
return content;
}
let mut cutoff = limit;
while cutoff > 0 && !content.is_char_boundary(cutoff) {
cutoff -= 1;
}
&content[..cutoff]
}