feat(ecc2): classify typed file activity

This commit is contained in:
Affaan Mustafa
2026-04-09 07:33:42 -07:00
parent a0f69cec92
commit edd027edd4
5 changed files with 282 additions and 39 deletions

View File

@@ -131,8 +131,19 @@ pub struct SessionMessage {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileActivityEntry {
pub session_id: String,
pub tool_name: String,
pub action: FileActivityAction,
pub path: String,
pub summary: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FileActivityAction {
Read,
Create,
Modify,
Move,
Delete,
Touch,
}

View File

@@ -11,7 +11,9 @@ use crate::config::Config;
use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage};
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
use super::{FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState};
use super::{
FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState,
};
pub struct StateStore {
conn: Connection,
@@ -146,7 +148,8 @@ impl StateStore {
duration_ms INTEGER,
risk_score REAL DEFAULT 0.0,
timestamp TEXT NOT NULL,
file_paths_json TEXT NOT NULL DEFAULT '[]'
file_paths_json TEXT NOT NULL DEFAULT '[]',
file_events_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS messages (
@@ -270,6 +273,15 @@ impl StateStore {
.context("Failed to add file_paths_json column to tool_log table")?;
}
if !self.has_column("tool_log", "file_events_json")? {
self.conn
.execute(
"ALTER TABLE tool_log ADD COLUMN file_events_json TEXT NOT NULL DEFAULT '[]'",
[],
)
.context("Failed to add file_events_json column to tool_log table")?;
}
if !self.has_column("daemon_activity", "last_dispatch_deferred")? {
self.conn
.execute(
@@ -738,9 +750,17 @@ impl StateStore {
#[serde(default)]
file_paths: Vec<String>,
#[serde(default)]
file_events: Vec<ToolActivityFileEvent>,
#[serde(default)]
timestamp: String,
}
#[derive(serde::Deserialize)]
struct ToolActivityFileEvent {
path: String,
action: String,
}
let file = File::open(metrics_path)
.with_context(|| format!("Failed to open {}", metrics_path.display()))?;
let reader = BufReader::new(file);
@@ -773,8 +793,35 @@ impl StateStore {
.map(|path| path.trim().to_string())
.filter(|path| !path.is_empty())
.collect();
let file_events: Vec<PersistedFileEvent> = if row.file_events.is_empty() {
file_paths
.iter()
.cloned()
.map(|path| PersistedFileEvent {
path,
action: infer_file_activity_action(&row.tool_name),
})
.collect()
} else {
row.file_events
.into_iter()
.filter_map(|event| {
let path = event.path.trim().to_string();
if path.is_empty() {
return None;
}
Some(PersistedFileEvent {
path,
action: parse_file_activity_action(&event.action)
.unwrap_or_else(|| infer_file_activity_action(&row.tool_name)),
})
})
.collect()
};
let file_paths_json =
serde_json::to_string(&file_paths).unwrap_or_else(|_| "[]".to_string());
let file_events_json =
serde_json::to_string(&file_events).unwrap_or_else(|_| "[]".to_string());
let timestamp = if row.timestamp.trim().is_empty() {
chrono::Utc::now().to_rfc3339()
} else {
@@ -798,9 +845,10 @@ impl StateStore {
duration_ms,
risk_score,
timestamp,
file_paths_json
file_paths_json,
file_events_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
row.id,
row.session_id,
@@ -811,6 +859,7 @@ impl StateStore {
risk_score,
timestamp,
file_paths_json,
file_events_json,
],
)?;
@@ -1487,11 +1536,13 @@ impl StateStore {
limit: usize,
) -> Result<Vec<FileActivityEntry>> {
let mut stmt = self.conn.prepare(
"SELECT session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json
"SELECT session_id, tool_name, input_summary, output_summary, timestamp, file_events_json, file_paths_json
FROM tool_log
WHERE session_id = ?1
AND file_paths_json IS NOT NULL
AND file_paths_json != '[]'
AND (
(file_events_json IS NOT NULL AND file_events_json != '[]')
OR (file_paths_json IS NOT NULL AND file_paths_json != '[]')
)
ORDER BY timestamp DESC, id DESC",
)?;
@@ -1505,17 +1556,23 @@ impl StateStore {
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?
.unwrap_or_else(|| "[]".to_string()),
row.get::<_, Option<String>>(6)?
.unwrap_or_else(|| "[]".to_string()),
))
})?
.collect::<Result<Vec<_>, _>>()?;
let mut events = Vec::new();
for (session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json) in
rows
for (
session_id,
tool_name,
input_summary,
output_summary,
timestamp,
file_events_json,
file_paths_json,
) in rows
{
let Ok(paths) = serde_json::from_str::<Vec<String>>(&file_paths_json) else {
continue;
};
let occurred_at = chrono::DateTime::parse_from_rfc3339(&timestamp)
.unwrap_or_default()
.with_timezone(&chrono::Utc);
@@ -1525,16 +1582,28 @@ impl StateStore {
output_summary
};
for path in paths {
let path = path.trim().to_string();
if path.is_empty() {
continue;
}
let persisted = parse_persisted_file_events(&file_events_json).unwrap_or_else(|| {
serde_json::from_str::<Vec<String>>(&file_paths_json)
.unwrap_or_default()
.into_iter()
.filter_map(|path| {
let path = path.trim().to_string();
if path.is_empty() {
return None;
}
Some(PersistedFileEvent {
path,
action: infer_file_activity_action(&tool_name),
})
})
.collect()
});
for event in persisted {
events.push(FileActivityEntry {
session_id: session_id.clone(),
tool_name: tool_name.clone(),
path,
action: event.action,
path: event.path,
summary: summary.clone(),
timestamp: occurred_at,
});
@@ -1548,6 +1617,62 @@ impl StateStore {
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct PersistedFileEvent {
path: String,
action: FileActivityAction,
}
fn parse_persisted_file_events(value: &str) -> Option<Vec<PersistedFileEvent>> {
let events = serde_json::from_str::<Vec<PersistedFileEvent>>(value).ok()?;
let events: Vec<PersistedFileEvent> = events
.into_iter()
.filter_map(|event| {
let path = event.path.trim().to_string();
if path.is_empty() {
return None;
}
Some(PersistedFileEvent {
path,
action: event.action,
})
})
.collect();
if events.is_empty() {
return None;
}
Some(events)
}
fn parse_file_activity_action(value: &str) -> Option<FileActivityAction> {
match value.trim().to_ascii_lowercase().as_str() {
"read" => Some(FileActivityAction::Read),
"create" => Some(FileActivityAction::Create),
"modify" | "edit" | "write" => Some(FileActivityAction::Modify),
"move" | "rename" => Some(FileActivityAction::Move),
"delete" | "remove" => Some(FileActivityAction::Delete),
"touch" => Some(FileActivityAction::Touch),
_ => None,
}
}
fn infer_file_activity_action(tool_name: &str) -> FileActivityAction {
let tool_name = tool_name.trim().to_ascii_lowercase();
if tool_name.contains("read") {
FileActivityAction::Read
} else if tool_name.contains("write") {
FileActivityAction::Create
} else if tool_name.contains("edit") {
FileActivityAction::Modify
} else if tool_name.contains("delete") || tool_name.contains("remove") {
FileActivityAction::Delete
} else if tool_name.contains("move") || tool_name.contains("rename") {
FileActivityAction::Move
} else {
FileActivityAction::Touch
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1803,10 +1928,11 @@ mod tests {
let activity = db.list_file_activity("session-1", 10)?;
assert_eq!(activity.len(), 3);
assert_eq!(activity[0].tool_name, "Write");
assert_eq!(activity[0].action, FileActivityAction::Create);
assert_eq!(activity[0].path, "README.md");
assert_eq!(activity[1].action, FileActivityAction::Create);
assert_eq!(activity[1].path, "src/lib.rs");
assert_eq!(activity[2].tool_name, "Read");
assert_eq!(activity[2].action, FileActivityAction::Read);
assert_eq!(activity[2].path, "src/lib.rs");
Ok(())

View File

@@ -5400,25 +5400,19 @@ fn session_state_color(state: &SessionState) -> Color {
fn file_activity_summary(entry: &FileActivityEntry) -> String {
format!(
"{} {}",
file_activity_verb(&entry.tool_name),
file_activity_verb(entry.action.clone()),
truncate_for_dashboard(&entry.path, 72)
)
}
fn file_activity_verb(tool_name: &str) -> &'static str {
let tool_name = tool_name.trim().to_ascii_lowercase();
if tool_name.contains("read") {
"read"
} else if tool_name.contains("write") {
"write"
} else if tool_name.contains("edit") {
"edit"
} else if tool_name.contains("delete") || tool_name.contains("remove") {
"delete"
} else if tool_name.contains("move") || tool_name.contains("rename") {
"move"
} else {
"touch"
fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str {
match action {
crate::session::FileActivityAction::Read => "read",
crate::session::FileActivityAction::Create => "create",
crate::session::FileActivityAction::Modify => "modify",
crate::session::FileActivityAction::Move => "move",
crate::session::FileActivityAction::Delete => "delete",
crate::session::FileActivityAction::Touch => "touch",
}
}
@@ -6100,12 +6094,12 @@ mod tests {
dashboard.toggle_timeline_mode();
let rendered = dashboard.rendered_output_text(180, 30);
assert!(rendered.contains("read src/lib.rs"));
assert!(rendered.contains("write README.md"));
assert!(rendered.contains("create README.md"));
assert!(!rendered.contains("files touched 2"));
let metrics_text = dashboard.selected_session_metrics_text();
assert!(metrics_text.contains("Recent file activity"));
assert!(metrics_text.contains("write README.md"));
assert!(metrics_text.contains("create README.md"));
assert!(metrics_text.contains("read src/lib.rs"));
let _ = fs::remove_dir_all(root);