From 8b34f72df3c771f20e5e8e492049c65bd2799b43 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 24 Mar 2026 03:39:53 -0700 Subject: [PATCH] feat(ecc2): add tool call logging and history --- ecc2/src/comms/mod.rs | 5 +- ecc2/src/main.rs | 15 ++-- ecc2/src/observability/mod.rs | 145 +++++++++++++++++++++++++++++++--- ecc2/src/session/manager.rs | 79 +++++++++++++++++- ecc2/src/session/store.rs | 103 ++++++++++++++++++++++-- ecc2/src/tui/dashboard.rs | 32 ++++++-- ecc2/src/worktree/mod.rs | 6 +- 7 files changed, 352 insertions(+), 33 deletions(-) diff --git a/ecc2/src/comms/mod.rs b/ecc2/src/comms/mod.rs index be176e96..8be89f2b 100644 --- a/ecc2/src/comms/mod.rs +++ b/ecc2/src/comms/mod.rs @@ -13,7 +13,10 @@ pub enum MessageType { /// Response to a query Response { answer: String }, /// Notification of completion - Completed { summary: String, files_changed: Vec }, + Completed { + summary: String, + files_changed: Vec, + }, /// Conflict detected (e.g., two agents editing the same file) Conflict { file: String, description: String }, } diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 850b7b49..afa50a2f 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1,9 +1,9 @@ +mod comms; mod config; +mod observability; mod session; mod tui; mod worktree; -mod observability; -mod comms; use anyhow::Result; use clap::Parser; @@ -63,10 +63,13 @@ async fn main() -> Result<()> { Some(Commands::Dashboard) | None => { tui::app::run(db, cfg).await?; } - Some(Commands::Start { task, agent, worktree: use_worktree }) => { - let session_id = session::manager::create_session( - &db, &cfg, &task, &agent, use_worktree, - ).await?; + Some(Commands::Start { + task, + agent, + worktree: use_worktree, + }) => { + let session_id = + session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?; println!("Session started: {session_id}"); } Some(Commands::Sessions) => { diff --git a/ecc2/src/observability/mod.rs b/ecc2/src/observability/mod.rs index 5f7a9645..363a1d8c 100644 --- a/ecc2/src/observability/mod.rs +++ b/ecc2/src/observability/mod.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; use crate::session::store::StateStore; @@ -14,6 +14,26 @@ pub struct ToolCallEvent { } impl ToolCallEvent { + pub fn new( + session_id: impl Into, + tool_name: impl Into, + input_summary: impl Into, + output_summary: impl Into, + duration_ms: u64, + ) -> Self { + let tool_name = tool_name.into(); + let input_summary = input_summary.into(); + + Self { + session_id: session_id.into(), + risk_score: Self::compute_risk(&tool_name, &input_summary), + tool_name, + input_summary, + output_summary: output_summary.into(), + duration_ms, + } + } + /// Compute risk score based on tool type and input patterns. pub fn compute_risk(tool_name: &str, input: &str) -> f64 { let mut score: f64 = 0.0; @@ -43,12 +63,119 @@ impl ToolCallEvent { } } -pub fn log_tool_call(db: &StateStore, event: &ToolCallEvent) -> Result<()> { - db.send_message( - &event.session_id, - "observability", - &serde_json::to_string(event)?, - "tool_call", - )?; - Ok(()) +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolLogEntry { + pub id: i64, + pub session_id: String, + pub tool_name: String, + pub input_summary: String, + pub output_summary: String, + pub duration_ms: u64, + pub risk_score: f64, + pub timestamp: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolLogPage { + pub entries: Vec, + pub page: u64, + pub page_size: u64, + pub total: u64, +} + +pub struct ToolLogger<'a> { + db: &'a StateStore, +} + +impl<'a> ToolLogger<'a> { + pub fn new(db: &'a StateStore) -> Self { + Self { db } + } + + pub fn log(&self, event: &ToolCallEvent) -> Result { + let timestamp = chrono::Utc::now().to_rfc3339(); + + self.db.insert_tool_log( + &event.session_id, + &event.tool_name, + &event.input_summary, + &event.output_summary, + event.duration_ms, + event.risk_score, + ×tamp, + ) + } + + pub fn query(&self, session_id: &str, page: u64, page_size: u64) -> Result { + if page_size == 0 { + bail!("page_size must be greater than 0"); + } + + self.db.query_tool_logs(session_id, page.max(1), page_size) + } +} + +pub fn log_tool_call(db: &StateStore, event: &ToolCallEvent) -> Result { + ToolLogger::new(db).log(event) +} + +#[cfg(test)] +mod tests { + use super::{ToolCallEvent, ToolLogger}; + use crate::session::store::StateStore; + use crate::session::{Session, SessionMetrics, SessionState}; + use std::path::PathBuf; + + fn test_db_path() -> PathBuf { + std::env::temp_dir().join(format!("ecc2-observability-{}.db", uuid::Uuid::new_v4())) + } + + fn test_session(id: &str) -> Session { + let now = chrono::Utc::now(); + + Session { + id: id.to_string(), + task: "test task".to_string(), + agent_type: "claude".to_string(), + state: SessionState::Pending, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + } + } + + #[test] + fn compute_risk_caps_high_risk_bash_commands() { + let score = ToolCallEvent::compute_risk("Bash", "sudo rm -rf /tmp --force"); + assert_eq!(score, 1.0); + } + + #[test] + fn logger_persists_entries_and_paginates() -> anyhow::Result<()> { + let db_path = test_db_path(); + let db = StateStore::open(&db_path)?; + db.insert_session(&test_session("sess-1"))?; + + let logger = ToolLogger::new(&db); + + logger.log(&ToolCallEvent::new("sess-1", "Read", "first", "ok", 5))?; + logger.log(&ToolCallEvent::new("sess-1", "Write", "second", "ok", 15))?; + logger.log(&ToolCallEvent::new("sess-1", "Bash", "third", "ok", 25))?; + + let first_page = logger.query("sess-1", 1, 2)?; + assert_eq!(first_page.total, 3); + assert_eq!(first_page.entries.len(), 2); + assert_eq!(first_page.entries[0].tool_name, "Bash"); + assert_eq!(first_page.entries[1].tool_name, "Write"); + + let second_page = logger.query("sess-1", 2, 2)?; + assert_eq!(second_page.total, 3); + assert_eq!(second_page.entries.len(), 1); + assert_eq!(second_page.entries[0].tool_name, "Read"); + + std::fs::remove_file(&db_path).ok(); + + Ok(()) + } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index c08c5f0d..71895c22 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1,9 +1,10 @@ use anyhow::Result; use std::fmt; -use super::{Session, SessionMetrics, SessionState}; use super::store::StateStore; +use super::{Session, SessionMetrics, SessionState}; use crate::config::Config; +use crate::observability::{log_tool_call, ToolCallEvent, ToolLogEntry, ToolLogPage, ToolLogger}; use crate::worktree; pub async fn create_session( @@ -53,6 +54,44 @@ pub async fn stop_session(db: &StateStore, id: &str) -> Result<()> { Ok(()) } +pub fn record_tool_call( + db: &StateStore, + session_id: &str, + tool_name: &str, + input_summary: &str, + output_summary: &str, + duration_ms: u64, +) -> Result { + let session = db + .get_session(session_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?; + + let event = ToolCallEvent::new( + session.id.clone(), + tool_name, + input_summary, + output_summary, + duration_ms, + ); + let entry = log_tool_call(db, &event)?; + db.increment_tool_calls(&session.id)?; + + Ok(entry) +} + +pub fn query_tool_calls( + db: &StateStore, + session_id: &str, + page: u64, + page_size: u64, +) -> Result { + let session = db + .get_session(session_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?; + + ToolLogger::new(db).query(&session.id, page, page_size) +} + pub struct SessionStatus(Session); impl fmt::Display for SessionStatus { @@ -74,3 +113,41 @@ impl fmt::Display for SessionStatus { write!(f, "Updated: {}", s.updated_at) } } + +#[cfg(test)] +mod tests { + use super::{create_session, query_tool_calls, record_tool_call}; + use crate::config::Config; + use crate::session::store::StateStore; + + #[tokio::test] + async fn record_tool_call_updates_session_metrics() -> anyhow::Result<()> { + let db_path = + std::env::temp_dir().join(format!("ecc2-session-manager-{}.db", uuid::Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + + let cfg = Config { + db_path: db_path.clone(), + ..Config::default() + }; + + let session_id = + create_session(&db, &cfg, "implement tool logging", "claude", false).await?; + + let entry = record_tool_call(&db, &session_id, "Bash", "git status", "clean worktree", 18)?; + + assert_eq!(entry.session_id, session_id); + assert_eq!(entry.tool_name, "Bash"); + + let session = db.get_session(&session_id)?.expect("session should exist"); + assert_eq!(session.metrics.tool_calls, 1); + + let page = query_tool_calls(&db, &session_id[..4], 1, 10)?; + assert_eq!(page.total, 1); + assert_eq!(page.entries[0].output_summary, "clean worktree"); + + std::fs::remove_file(&db_path).ok(); + + Ok(()) + } +} diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index b412f188..2a3eadff 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -3,6 +3,7 @@ use rusqlite::Connection; use std::path::Path; use super::{Session, SessionMetrics, SessionState}; +use crate::observability::{ToolLogEntry, ToolLogPage}; pub struct StateStore { conn: Connection, @@ -112,6 +113,14 @@ impl StateStore { Ok(()) } + pub fn increment_tool_calls(&self, session_id: &str) -> Result<()> { + self.conn.execute( + "UPDATE sessions SET tool_calls = tool_calls + 1, updated_at = ?1 WHERE id = ?2", + rusqlite::params![chrono::Utc::now().to_rfc3339(), session_id], + )?; + Ok(()) + } + pub fn list_sessions(&self) -> Result> { let mut stmt = self.conn.prepare( "SELECT id, task, agent_type, state, worktree_path, worktree_branch, worktree_base, @@ -170,16 +179,12 @@ impl StateStore { pub fn get_session(&self, id: &str) -> Result> { let sessions = self.list_sessions()?; - Ok(sessions.into_iter().find(|s| s.id == id || s.id.starts_with(id))) + Ok(sessions + .into_iter() + .find(|s| s.id == id || s.id.starts_with(id))) } - pub fn send_message( - &self, - from: &str, - to: &str, - content: &str, - msg_type: &str, - ) -> Result<()> { + pub fn send_message(&self, from: &str, to: &str, content: &str, msg_type: &str) -> Result<()> { self.conn.execute( "INSERT INTO messages (from_session, to_session, content, msg_type, timestamp) VALUES (?1, ?2, ?3, ?4, ?5)", @@ -187,4 +192,86 @@ impl StateStore { )?; Ok(()) } + + pub fn insert_tool_log( + &self, + session_id: &str, + tool_name: &str, + input_summary: &str, + output_summary: &str, + duration_ms: u64, + risk_score: f64, + timestamp: &str, + ) -> Result { + self.conn.execute( + "INSERT INTO tool_log (session_id, tool_name, input_summary, output_summary, duration_ms, risk_score, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + session_id, + tool_name, + input_summary, + output_summary, + duration_ms, + risk_score, + timestamp, + ], + )?; + + Ok(ToolLogEntry { + id: self.conn.last_insert_rowid(), + session_id: session_id.to_string(), + tool_name: tool_name.to_string(), + input_summary: input_summary.to_string(), + output_summary: output_summary.to_string(), + duration_ms, + risk_score, + timestamp: timestamp.to_string(), + }) + } + + pub fn query_tool_logs( + &self, + session_id: &str, + page: u64, + page_size: u64, + ) -> Result { + let page = page.max(1); + let offset = (page - 1) * page_size; + + let total: u64 = self.conn.query_row( + "SELECT COUNT(*) FROM tool_log WHERE session_id = ?1", + rusqlite::params![session_id], + |row| row.get(0), + )?; + + let mut stmt = self.conn.prepare( + "SELECT id, session_id, tool_name, input_summary, output_summary, duration_ms, risk_score, timestamp + FROM tool_log + WHERE session_id = ?1 + ORDER BY timestamp DESC, id DESC + LIMIT ?2 OFFSET ?3", + )?; + + let entries = stmt + .query_map(rusqlite::params![session_id, page_size, offset], |row| { + Ok(ToolLogEntry { + id: row.get(0)?, + session_id: row.get(1)?, + tool_name: row.get(2)?, + input_summary: row.get::<_, Option>(3)?.unwrap_or_default(), + output_summary: row.get::<_, Option>(4)?.unwrap_or_default(), + duration_ms: row.get::<_, Option>(5)?.unwrap_or_default(), + risk_score: row.get::<_, Option>(6)?.unwrap_or_default(), + timestamp: row.get(7)?, + }) + })? + .collect::, _>>()?; + + Ok(ToolLogPage { + entries, + page, + page_size, + total, + }) + } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index aca1e995..9c36a1ce 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -4,8 +4,8 @@ use ratatui::{ }; use crate::config::Config; -use crate::session::{Session, SessionState}; use crate::session::store::StateStore; +use crate::session::{Session, SessionState}; pub struct Dashboard { db: StateStore, @@ -42,7 +42,7 @@ impl Dashboard { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Header + Constraint::Length(3), // Header Constraint::Min(10), // Main content Constraint::Length(3), // Status bar ]) @@ -79,7 +79,11 @@ impl Dashboard { } fn render_header(&self, frame: &mut Frame, area: Rect) { - let running = self.sessions.iter().filter(|s| s.state == SessionState::Running).count(); + let running = self + .sessions + .iter() + .filter(|s| s.state == SessionState::Running) + .count(); let total = self.sessions.len(); let title = format!(" ECC 2.0 | {running} running / {total} total "); @@ -90,7 +94,11 @@ impl Dashboard { Pane::Output => 1, Pane::Metrics => 2, }) - .highlight_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)); + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); frame.render_widget(tabs, area); } @@ -110,11 +118,18 @@ impl Dashboard { SessionState::Pending => "◌", }; let style = if i == self.selected_session { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default() }; - let text = format!("{state_icon} {} [{}] {}", &s.id[..8.min(s.id.len())], s.agent_type, s.task); + let text = format!( + "{state_icon} {} [{}] {}", + &s.id[..8.min(s.id.len())], + s.agent_type, + s.task + ); ListItem::new(text).style(style) }) .collect(); @@ -136,7 +151,10 @@ impl Dashboard { fn render_output(&self, frame: &mut Frame, area: Rect) { let content = if let Some(session) = self.sessions.get(self.selected_session) { - format!("Agent output for session {}...\n\n(Live streaming coming soon)", session.id) + format!( + "Agent output for session {}...\n\n(Live streaming coming soon)", + session.id + ) } else { "No sessions. Press 'n' to start one.".to_string() }; diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 50306f2a..8ac1974b 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -28,7 +28,11 @@ pub fn create_for_session(session_id: &str, cfg: &Config) -> Result