mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 11:23:32 +08:00
feat(ecc2): surface per-file session activity
This commit is contained in:
@@ -127,3 +127,12 @@ pub struct SessionMessage {
|
|||||||
pub read: bool,
|
pub read: bool,
|
||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct FileActivityEntry {
|
||||||
|
pub session_id: String,
|
||||||
|
pub tool_name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use crate::config::Config;
|
|||||||
use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage};
|
use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage};
|
||||||
|
|
||||||
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
|
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
|
||||||
use super::{Session, SessionMessage, SessionMetrics, SessionState};
|
use super::{FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState};
|
||||||
|
|
||||||
pub struct StateStore {
|
pub struct StateStore {
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
@@ -1480,6 +1480,72 @@ impl StateStore {
|
|||||||
total,
|
total,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_file_activity(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<FileActivityEntry>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json
|
||||||
|
FROM tool_log
|
||||||
|
WHERE session_id = ?1
|
||||||
|
AND file_paths_json IS NOT NULL
|
||||||
|
AND file_paths_json != '[]'
|
||||||
|
ORDER BY timestamp DESC, id DESC",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(rusqlite::params![session_id], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?.unwrap_or_default(),
|
||||||
|
row.get::<_, Option<String>>(3)?.unwrap_or_default(),
|
||||||
|
row.get::<_, String>(4)?,
|
||||||
|
row.get::<_, Option<String>>(5)?
|
||||||
|
.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
|
||||||
|
{
|
||||||
|
let Ok(paths) = serde_json::from_str::<Vec<String>>(&file_paths_json) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let occurred_at = chrono::DateTime::parse_from_rfc3339(×tamp)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.with_timezone(&chrono::Utc);
|
||||||
|
let summary = if output_summary.trim().is_empty() {
|
||||||
|
input_summary
|
||||||
|
} else {
|
||||||
|
output_summary
|
||||||
|
};
|
||||||
|
|
||||||
|
for path in paths {
|
||||||
|
let path = path.trim().to_string();
|
||||||
|
if path.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
events.push(FileActivityEntry {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
tool_name: tool_name.clone(),
|
||||||
|
path,
|
||||||
|
summary: summary.clone(),
|
||||||
|
timestamp: occurred_at,
|
||||||
|
});
|
||||||
|
if events.len() >= limit {
|
||||||
|
return Ok(events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(events)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1702,6 +1768,50 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_file_activity_expands_logged_file_paths() -> Result<()> {
|
||||||
|
let tempdir = TestDir::new("store-file-activity")?;
|
||||||
|
let db = StateStore::open(&tempdir.path().join("state.db"))?;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "session-1".to_string(),
|
||||||
|
task: "sync tools".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: PathBuf::from("/tmp"),
|
||||||
|
state: SessionState::Running,
|
||||||
|
pid: None,
|
||||||
|
worktree: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_heartbeat_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let metrics_dir = tempdir.path().join("metrics");
|
||||||
|
fs::create_dir_all(&metrics_dir)?;
|
||||||
|
let metrics_path = metrics_dir.join("tool-usage.jsonl");
|
||||||
|
fs::write(
|
||||||
|
&metrics_path,
|
||||||
|
concat!(
|
||||||
|
"{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
||||||
|
"{\"id\":\"evt-2\",\"session_id\":\"session-1\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\",\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n"
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
db.sync_tool_activity_metrics(&metrics_path)?;
|
||||||
|
|
||||||
|
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].path, "README.md");
|
||||||
|
assert_eq!(activity[1].path, "src/lib.rs");
|
||||||
|
assert_eq!(activity[2].tool_name, "Read");
|
||||||
|
assert_eq!(activity[2].path, "src/lib.rs");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> {
|
fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> {
|
||||||
let tempdir = TestDir::new("store-duration-metrics")?;
|
let tempdir = TestDir::new("store-duration-metrics")?;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use crate::session::output::{
|
|||||||
OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT,
|
OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT,
|
||||||
};
|
};
|
||||||
use crate::session::store::{DaemonActivity, StateStore};
|
use crate::session::store::{DaemonActivity, StateStore};
|
||||||
use crate::session::{Session, SessionMessage, SessionState};
|
use crate::session::{FileActivityEntry, Session, SessionMessage, SessionState};
|
||||||
use crate::worktree;
|
use crate::worktree;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -3482,13 +3482,24 @@ impl Dashboard {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.metrics.files_changed > 0 {
|
let file_activity = self
|
||||||
|
.db
|
||||||
|
.list_file_activity(&session.id, 64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if file_activity.is_empty() && session.metrics.files_changed > 0 {
|
||||||
events.push(TimelineEvent {
|
events.push(TimelineEvent {
|
||||||
occurred_at: session.updated_at,
|
occurred_at: session.updated_at,
|
||||||
session_id: session.id.clone(),
|
session_id: session.id.clone(),
|
||||||
event_type: TimelineEventType::FileChange,
|
event_type: TimelineEventType::FileChange,
|
||||||
summary: format!("files touched {}", session.metrics.files_changed),
|
summary: format!("files touched {}", session.metrics.files_changed),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
events.extend(file_activity.into_iter().map(|entry| TimelineEvent {
|
||||||
|
occurred_at: entry.timestamp,
|
||||||
|
session_id: session.id.clone(),
|
||||||
|
event_type: TimelineEventType::FileChange,
|
||||||
|
summary: file_activity_summary(&entry),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let messages = self
|
let messages = self
|
||||||
@@ -4125,6 +4136,20 @@ impl Dashboard {
|
|||||||
"Tools {} | Files {}",
|
"Tools {} | Files {}",
|
||||||
metrics.tool_calls, metrics.files_changed,
|
metrics.tool_calls, metrics.files_changed,
|
||||||
));
|
));
|
||||||
|
let recent_file_activity = self
|
||||||
|
.db
|
||||||
|
.list_file_activity(&session.id, 5)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !recent_file_activity.is_empty() {
|
||||||
|
lines.push("Recent file activity".to_string());
|
||||||
|
for entry in recent_file_activity {
|
||||||
|
lines.push(format!(
|
||||||
|
"- {} {}",
|
||||||
|
self.short_timestamp(&entry.timestamp.to_rfc3339()),
|
||||||
|
file_activity_summary(&entry)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
lines.push(format!(
|
lines.push(format!(
|
||||||
"Cost ${:.4} | Duration {}s",
|
"Cost ${:.4} | Duration {}s",
|
||||||
metrics.cost_usd, metrics.duration_secs
|
metrics.cost_usd, metrics.duration_secs
|
||||||
@@ -5372,6 +5397,31 @@ fn session_state_color(state: &SessionState) -> Color {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn file_activity_summary(entry: &FileActivityEntry) -> String {
|
||||||
|
format!(
|
||||||
|
"{} {}",
|
||||||
|
file_activity_verb(&entry.tool_name),
|
||||||
|
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 heartbeat_enforcement_note(outcome: &manager::HeartbeatEnforcementOutcome) -> String {
|
fn heartbeat_enforcement_note(outcome: &manager::HeartbeatEnforcementOutcome) -> String {
|
||||||
if !outcome.auto_terminated_sessions.is_empty() {
|
if !outcome.auto_terminated_sessions.is_empty() {
|
||||||
return format!(
|
return format!(
|
||||||
@@ -6017,6 +6067,51 @@ mod tests {
|
|||||||
assert!(!rendered.contains("files touched 1"));
|
assert!(!rendered.contains("files touched 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn timeline_and_metrics_render_recent_file_activity_details() -> Result<()> {
|
||||||
|
let root = std::env::temp_dir().join(format!("ecc2-file-activity-{}", Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&root)?;
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut session = sample_session(
|
||||||
|
"focus-12345678",
|
||||||
|
"planner",
|
||||||
|
SessionState::Running,
|
||||||
|
Some("ecc/focus"),
|
||||||
|
512,
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
session.created_at = now - chrono::Duration::hours(2);
|
||||||
|
session.updated_at = now - chrono::Duration::minutes(5);
|
||||||
|
|
||||||
|
let mut dashboard = test_dashboard(vec![session.clone()], 0);
|
||||||
|
dashboard.db.insert_session(&session)?;
|
||||||
|
|
||||||
|
let metrics_path = root.join("tool-usage.jsonl");
|
||||||
|
fs::write(
|
||||||
|
&metrics_path,
|
||||||
|
concat!(
|
||||||
|
"{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
||||||
|
"{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n"
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
dashboard.db.sync_tool_activity_metrics(&metrics_path)?;
|
||||||
|
dashboard.sync_from_store();
|
||||||
|
|
||||||
|
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("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("read src/lib.rs"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn timeline_time_filter_hides_old_events() {
|
fn timeline_time_filter_hides_old_events() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|||||||
Reference in New Issue
Block a user