use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::io::AsyncWriteExt;
use tokio::sync::RwLock;
pub use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Run {
pub run_id: String,
pub parent_run_id: Option<String>,
pub session_id: String,
pub status: RunStatus,
pub input: String,
pub events: Vec<RunEvent>,
pub final_output: Option<String>,
pub error: Option<String>,
pub token_usage: TokenUsage,
pub timings: RunTimings,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RunStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RunEvent {
LlmCall {
messages: usize,
prompt_tokens: u32,
completion_tokens: u32,
duration_ms: u64,
},
ToolCall {
call_id: String,
name: String,
#[serde(default)]
args: Option<serde_json::Value>,
#[serde(default)]
risk: Option<String>,
duration_ms: u64,
},
ToolResult {
call_id: String,
name: String,
success: bool,
#[serde(default)]
output_preview: Option<String>,
output_truncated: bool,
#[serde(default)]
duration_ms: u64,
},
ToolError {
call_id: String,
name: String,
message: String,
},
#[allow(dead_code)]
Error {
message: String,
},
Checkpoint {
id: String,
},
PermissionDecision {
tool: String,
decision: String,
reason: String,
},
FileEdit {
tool: String,
path: String,
},
TestRun {
command: String,
passed: bool,
failure_count: usize,
},
PhaseTransition {
phase: String,
iteration: usize,
},
SubAgentRun {
agent_name: String,
task: String,
outcome: String,
},
}
impl RunEvent {
pub fn new_tool_call(
call_id: String,
name: String,
args: Option<serde_json::Value>,
risk: Option<String>,
duration_ms: u64,
) -> Self {
let safe_args = args.map(|v| {
let s = serde_json::to_string(&v).unwrap_or_default();
let redacted = crate::security::redact_secrets(&s);
serde_json::from_str(&redacted).unwrap_or(v)
});
Self::ToolCall {
call_id,
name,
args: safe_args,
risk,
duration_ms,
}
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct TokenUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
impl TokenUsage {
pub fn add(&mut self, prompt: u32, completion: u32) {
self.prompt_tokens = self.prompt_tokens.saturating_add(prompt);
self.completion_tokens = self.completion_tokens.saturating_add(completion);
self.total_tokens = self
.total_tokens
.saturating_add(prompt.saturating_add(completion));
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct RunTimings {
pub total_duration_ms: u64,
pub llm_duration_ms: u64,
pub tool_duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunSummary {
pub run_id: String,
pub session_id: String,
pub status: RunStatus,
pub input_preview: String,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub token_usage: TokenUsage,
pub total_duration_ms: u64,
}
#[async_trait::async_trait]
pub trait RunStore: Send + Sync {
async fn save(&self, run: Run) -> Result<()>;
async fn load(&self, run_id: &str) -> Result<Option<Run>>;
async fn list_by_session(&self, session_id: &str) -> Result<Vec<RunSummary>>;
async fn list_all(&self, limit: usize) -> Result<Vec<RunSummary>>;
async fn append_event(&self, run_id: &str, event: RunEvent) -> Result<()> {
if let Some(mut run) = self.load(run_id).await? {
run.events.push(event);
self.save(run).await?;
}
Ok(())
}
}
pub struct InMemoryRunStore {
runs: RwLock<HashMap<String, Run>>,
}
impl InMemoryRunStore {
pub fn new() -> Self {
Self {
runs: RwLock::new(HashMap::new()),
}
}
pub async fn len(&self) -> usize {
self.runs.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.runs.read().await.is_empty()
}
}
impl Default for InMemoryRunStore {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl RunStore for InMemoryRunStore {
async fn save(&self, run: Run) -> Result<()> {
self.runs.write().await.insert(run.run_id.clone(), run);
Ok(())
}
async fn load(&self, run_id: &str) -> Result<Option<Run>> {
Ok(self.runs.read().await.get(run_id).cloned())
}
async fn list_by_session(&self, session_id: &str) -> Result<Vec<RunSummary>> {
let runs = self.runs.read().await;
let mut summaries: Vec<RunSummary> = runs
.values()
.filter(|r| r.session_id == session_id)
.map(|r| RunSummary {
run_id: r.run_id.clone(),
session_id: r.session_id.clone(),
status: r.status,
input_preview: r.input.chars().take(80).collect(),
started_at: r.started_at,
finished_at: r.finished_at,
token_usage: r.token_usage,
total_duration_ms: r.timings.total_duration_ms,
})
.collect();
summaries.sort_by_key(|s| s.started_at);
summaries.reverse();
Ok(summaries)
}
async fn list_all(&self, limit: usize) -> Result<Vec<RunSummary>> {
let runs = self.runs.read().await;
let mut summaries: Vec<RunSummary> = runs
.values()
.map(|r| RunSummary {
run_id: r.run_id.clone(),
session_id: r.session_id.clone(),
status: r.status,
input_preview: r.input.chars().take(80).collect(),
started_at: r.started_at,
finished_at: r.finished_at,
token_usage: r.token_usage,
total_duration_ms: r.timings.total_duration_ms,
})
.collect();
summaries.sort_by_key(|s| s.started_at);
summaries.reverse();
summaries.truncate(limit);
Ok(summaries)
}
}
pub struct JsonlRunStore {
dir: PathBuf,
cache: RwLock<HashMap<String, Run>>,
}
impl JsonlRunStore {
pub fn new(dir: impl Into<PathBuf>) -> Result<Self> {
let dir = dir.into();
std::fs::create_dir_all(&dir)?;
let mut cache = HashMap::new();
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "jsonl")
&& let Some(run) = Self::load_last_line(&path)
{
cache.insert(run.run_id.clone(), run);
}
}
}
Ok(Self {
dir,
cache: RwLock::new(cache),
})
}
fn run_path(&self, run_id: &str) -> PathBuf {
self.dir.join(format!("{run_id}.jsonl"))
}
fn load_last_line(path: &Path) -> Option<Run> {
let data = std::fs::read_to_string(path).ok()?;
let last_line = data.lines().rfind(|l| !l.trim().is_empty())?;
serde_json::from_str::<Run>(last_line).ok()
}
async fn load_last_line_async(path: &Path) -> Option<Run> {
let data = tokio::fs::read_to_string(path).await.ok()?;
let last_line = data.lines().rfind(|l| !l.trim().is_empty())?;
serde_json::from_str::<Run>(last_line).ok()
}
}
#[async_trait::async_trait]
impl RunStore for JsonlRunStore {
async fn save(&self, run: Run) -> Result<()> {
let run_id = run.run_id.clone();
let path = self.run_path(&run_id);
let line = serde_json::to_string(&run)?;
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.write(true)
.open(&path)
.await?;
file.write_all(line.as_bytes()).await?;
file.write_all(b"\n").await?;
self.cache.write().await.insert(run_id, run);
Ok(())
}
async fn load(&self, run_id: &str) -> Result<Option<Run>> {
if let Some(run) = self.cache.read().await.get(run_id) {
return Ok(Some(run.clone()));
}
let path = self.run_path(run_id);
if tokio::fs::try_exists(&path).await.unwrap_or(false)
&& let Some(run) = Self::load_last_line_async(&path).await
{
self.cache
.write()
.await
.insert(run_id.to_string(), run.clone());
return Ok(Some(run));
}
Ok(None)
}
async fn list_by_session(&self, session_id: &str) -> Result<Vec<RunSummary>> {
let cache = self.cache.read().await;
let mut summaries: Vec<RunSummary> = cache
.values()
.filter(|r| r.session_id == session_id)
.map(|r| RunSummary {
run_id: r.run_id.clone(),
session_id: r.session_id.clone(),
status: r.status,
input_preview: r.input.chars().take(80).collect(),
started_at: r.started_at,
finished_at: r.finished_at,
token_usage: r.token_usage,
total_duration_ms: r.timings.total_duration_ms,
})
.collect();
summaries.sort_by_key(|s| s.started_at);
summaries.reverse();
Ok(summaries)
}
async fn list_all(&self, limit: usize) -> Result<Vec<RunSummary>> {
let cache = self.cache.read().await;
let mut summaries: Vec<RunSummary> = cache
.values()
.map(|r| RunSummary {
run_id: r.run_id.clone(),
session_id: r.session_id.clone(),
status: r.status,
input_preview: r.input.chars().take(80).collect(),
started_at: r.started_at,
finished_at: r.finished_at,
token_usage: r.token_usage,
total_duration_ms: r.timings.total_duration_ms,
})
.collect();
summaries.sort_by_key(|s| s.started_at);
summaries.reverse();
summaries.truncate(limit);
Ok(summaries)
}
async fn append_event(&self, run_id: &str, event: RunEvent) -> Result<()> {
let mut run = match self.load(run_id).await? {
Some(run) => run,
None => return Ok(()),
};
run.events.push(event);
self.save(run).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_run(id: &str, session: &str) -> Run {
Run {
run_id: id.to_string(),
parent_run_id: None,
session_id: session.to_string(),
status: RunStatus::Completed,
input: "test input".to_string(),
events: vec![],
final_output: Some("ok".to_string()),
error: None,
token_usage: TokenUsage::default(),
timings: RunTimings::default(),
started_at: Utc::now(),
finished_at: Some(Utc::now()),
}
}
fn temp_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!("echo_trace_test_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[tokio::test]
async fn test_in_memory_store_save_and_load() {
let store = InMemoryRunStore::new();
let run = make_run("r1", "s1");
store.save(run).await.unwrap();
let loaded = store.load("r1").await.unwrap().unwrap();
assert_eq!(loaded.run_id, "r1");
assert_eq!(loaded.session_id, "s1");
}
#[tokio::test]
async fn test_in_memory_store_list_by_session() {
let store = InMemoryRunStore::new();
store.save(make_run("r1", "s1")).await.unwrap();
store.save(make_run("r2", "s1")).await.unwrap();
store.save(make_run("r3", "s2")).await.unwrap();
let s1_runs = store.list_by_session("s1").await.unwrap();
assert_eq!(s1_runs.len(), 2);
let s2_runs = store.list_by_session("s2").await.unwrap();
assert_eq!(s2_runs.len(), 1);
}
#[test]
fn test_token_usage_add() {
let mut usage = TokenUsage::default();
usage.add(100, 50);
assert_eq!(usage.prompt_tokens, 100);
assert_eq!(usage.completion_tokens, 50);
assert_eq!(usage.total_tokens, 150);
usage.add(30, 20);
assert_eq!(usage.prompt_tokens, 130);
assert_eq!(usage.completion_tokens, 70);
assert_eq!(usage.total_tokens, 200);
}
#[tokio::test]
async fn test_jsonl_store_save_and_load() {
let dir = temp_dir();
let store = JsonlRunStore::new(&dir).unwrap();
let run = make_run("r1", "s1");
store.save(run.clone()).await.unwrap();
let loaded = store.load("r1").await.unwrap().unwrap();
assert_eq!(loaded.run_id, "r1");
assert_eq!(loaded.session_id, "s1");
}
#[tokio::test]
async fn test_jsonl_store_list_by_session() {
let dir = temp_dir();
let store = JsonlRunStore::new(&dir).unwrap();
store.save(make_run("r1", "s1")).await.unwrap();
store.save(make_run("r2", "s1")).await.unwrap();
store.save(make_run("r3", "s2")).await.unwrap();
let s1 = store.list_by_session("s1").await.unwrap();
assert_eq!(s1.len(), 2);
let s2 = store.list_by_session("s2").await.unwrap();
assert_eq!(s2.len(), 1);
}
#[tokio::test]
async fn test_jsonl_store_list_all() {
let dir = temp_dir();
let store = JsonlRunStore::new(&dir).unwrap();
store.save(make_run("r1", "s1")).await.unwrap();
store.save(make_run("r2", "s2")).await.unwrap();
store.save(make_run("r3", "s3")).await.unwrap();
let all = store.list_all(2).await.unwrap();
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn test_jsonl_store_append_event() {
let dir = temp_dir();
let store = JsonlRunStore::new(&dir).unwrap();
store.save(make_run("r1", "s1")).await.unwrap();
store
.append_event(
"r1",
RunEvent::ToolCall {
call_id: "c1".into(),
name: "read_file".into(),
args: None,
risk: None,
duration_ms: 100,
},
)
.await
.unwrap();
let loaded = store.load("r1").await.unwrap().unwrap();
assert_eq!(loaded.events.len(), 1);
}
#[tokio::test]
async fn test_jsonl_store_persistence_across_instances() {
let dir = temp_dir();
let store = JsonlRunStore::new(&dir).unwrap();
store.save(make_run("r1", "s1")).await.unwrap();
drop(store);
let store2 = JsonlRunStore::new(&dir).unwrap();
let loaded = store2.load("r1").await.unwrap();
assert!(loaded.is_some());
}
}