use crate::db::Pool;
use crate::db::database::interact_err;
use anyhow::{Context as _, Result};
use rusqlite::params;
#[derive(Debug, Clone)]
pub struct AuditRow {
pub session_id: String,
pub message_id: String,
pub tool_name: String,
pub status: String,
pub created_at: i64,
pub read_count: i64,
pub last_read: Option<String>,
pub outcome: Option<String>,
}
#[derive(Clone)]
pub struct TurnRetrievalRepository {
pool: Pool,
}
pub fn retrieval_kind(tool_name: &str) -> Option<&'static str> {
match tool_name {
"read_file" | "load_brain_file" | "analyze_image" | "analyze_video" => Some("read"),
"grep" | "glob" | "web_search" | "exa_search" | "memory_search" | "session_search" => {
Some("search")
}
"ls" => Some("list"),
_ => None,
}
}
pub fn audit_target(tool_name: &str, input: &serde_json::Value) -> Option<String> {
for key in ["path", "query", "pattern"] {
if let Some(s) = input.get(key).and_then(|v| v.as_str())
&& !s.trim().is_empty()
{
return Some(s.to_string());
}
}
tracing::debug!("[AUDIT] no target key for read-class tool {tool_name}");
None
}
pub fn turn_outcome_from_outputs(outputs: &[String]) -> (&'static str, Option<String>) {
let mut verified_line: Option<String> = None;
for out in outputs {
for line in out.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("test result: ") {
let failed = rest
.split(';')
.filter_map(|seg| {
let seg = seg.trim();
seg.strip_suffix(" failed")
.and_then(|n| n.trim().parse::<u64>().ok())
})
.sum::<u64>();
if failed > 0 {
return ("failed", Some(shorten(trimmed)));
}
if verified_line.is_none() {
verified_line = Some(shorten(trimmed));
}
} else if trimmed.starts_with("error[E") {
return ("failed", Some(shorten(trimmed)));
}
}
}
match verified_line {
Some(line) => ("verified", Some(line)),
None => ("unverified", None),
}
}
fn shorten(s: &str) -> String {
s.chars().take(200).collect()
}
impl TurnRetrievalRepository {
pub fn new(pool: Pool) -> Self {
Self { pool }
}
#[allow(clippy::too_many_arguments)] pub async fn record_retrieval(
&self,
id: &str,
session_id: &str,
message_id: &str,
tool_name: &str,
kind: &str,
target: &str,
content_hash: Option<&str>,
preview: Option<&str>,
) -> Result<()> {
let preview = preview.map(|p| p.chars().take(128).collect::<String>());
let id = id.to_string();
let session_id = session_id.to_string();
let message_id = message_id.to_string();
let tool_name = tool_name.to_string();
let kind = kind.to_string();
let target = target.to_string();
let content_hash = content_hash.map(|s| s.to_string());
self.pool
.get()
.await
.context("Failed to get connection")?
.interact(move |conn| {
conn.execute(
"INSERT INTO turn_retrievals \
(id, session_id, message_id, tool_name, kind, target, content_hash, preview) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
id,
session_id,
message_id,
tool_name,
kind,
target,
content_hash,
preview
],
)
})
.await
.map_err(interact_err)?
.context("Failed to record turn retrieval")?;
Ok(())
}
pub async fn record_outcome(
&self,
id: &str,
session_id: &str,
message_id: &str,
outcome: &str,
evidence: Option<&str>,
) -> Result<()> {
let id = id.to_string();
let session_id = session_id.to_string();
let message_id = message_id.to_string();
let outcome = outcome.to_string();
let evidence = evidence.map(|s| s.to_string());
self.pool
.get()
.await
.context("Failed to get connection")?
.interact(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO turn_outcomes \
(id, session_id, message_id, outcome, evidence) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, session_id, message_id, outcome, evidence],
)
})
.await
.map_err(interact_err)?
.context("Failed to record turn outcome")?;
Ok(())
}
pub async fn recent_audit_rows(&self, limit: u32) -> Result<Vec<AuditRow>> {
self.pool
.get()
.await
.context("Failed to get connection")?
.interact(move |conn| -> anyhow::Result<Vec<AuditRow>> {
let mut stmt = conn.prepare(
"SELECT t.session_id, t.message_id, t.tool_name, t.status, t.created_at, \
(SELECT COUNT(*) FROM turn_retrievals r WHERE r.message_id = t.message_id), \
(SELECT r.kind || ':' || r.target FROM turn_retrievals r \
WHERE r.message_id = t.message_id ORDER BY r.created_at DESC LIMIT 1), \
o.outcome \
FROM tool_executions t \
LEFT JOIN turn_outcomes o ON o.message_id = t.message_id \
ORDER BY t.created_at DESC, t.id DESC LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit], |row| {
Ok(AuditRow {
session_id: row.get(0)?,
message_id: row.get(1)?,
tool_name: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
read_count: row.get(5)?,
last_read: row.get(6)?,
outcome: row.get(7)?,
})
})?
.collect::<std::result::Result<Vec<AuditRow>, rusqlite::Error>>()?;
Ok(rows)
})
.await
.map_err(interact_err)?
.context("Failed to load audit rows")
}
}