mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 17:59:43 +08:00
feat(ecc2): surface overlapping file activity
This commit is contained in:
parent
31f672275e
commit
f28f55c41e
@ -1,6 +1,7 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use rusqlite::{Connection, OptionalExtension};
|
use rusqlite::{Connection, OptionalExtension};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::cmp::Reverse;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
@ -19,6 +20,16 @@ pub struct StateStore {
|
|||||||
conn: Connection,
|
conn: Connection,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct FileActivityOverlap {
|
||||||
|
pub path: String,
|
||||||
|
pub current_action: FileActivityAction,
|
||||||
|
pub other_action: FileActivityAction,
|
||||||
|
pub other_session_id: String,
|
||||||
|
pub other_session_state: SessionState,
|
||||||
|
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize)]
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
pub struct DaemonActivity {
|
pub struct DaemonActivity {
|
||||||
pub last_dispatch_at: Option<chrono::DateTime<chrono::Utc>>,
|
pub last_dispatch_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
@ -1627,6 +1638,67 @@ impl StateStore {
|
|||||||
|
|
||||||
Ok(events)
|
Ok(events)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_file_overlaps(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<FileActivityOverlap>> {
|
||||||
|
if limit == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_activity = self.list_file_activity(session_id, 64)?;
|
||||||
|
if current_activity.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut current_by_path = HashMap::new();
|
||||||
|
for entry in current_activity {
|
||||||
|
current_by_path.entry(entry.path.clone()).or_insert(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut overlaps = Vec::new();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
|
for session in self.list_sessions()? {
|
||||||
|
if session.id == session_id || !session_state_supports_overlap(&session.state) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in self.list_file_activity(&session.id, 32)? {
|
||||||
|
let Some(current) = current_by_path.get(&entry.path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !file_overlap_is_relevant(current, &entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !seen.insert((session.id.clone(), entry.path.clone())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlaps.push(FileActivityOverlap {
|
||||||
|
path: entry.path.clone(),
|
||||||
|
current_action: current.action.clone(),
|
||||||
|
other_action: entry.action.clone(),
|
||||||
|
other_session_id: session.id.clone(),
|
||||||
|
other_session_state: session.state.clone(),
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlaps.sort_by_key(|entry| {
|
||||||
|
(
|
||||||
|
overlap_state_priority(&entry.other_session_state),
|
||||||
|
Reverse(entry.timestamp),
|
||||||
|
entry.other_session_id.clone(),
|
||||||
|
entry.path.clone(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
overlaps.truncate(limit);
|
||||||
|
Ok(overlaps)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
@ -1702,6 +1774,31 @@ fn infer_file_activity_action(tool_name: &str) -> FileActivityAction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_state_supports_overlap(state: &SessionState) -> bool {
|
||||||
|
matches!(
|
||||||
|
state,
|
||||||
|
SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_overlap_is_relevant(current: &FileActivityEntry, other: &FileActivityEntry) -> bool {
|
||||||
|
current.path == other.path
|
||||||
|
&& !(matches!(current.action, FileActivityAction::Read)
|
||||||
|
&& matches!(other.action, FileActivityAction::Read))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlap_state_priority(state: &SessionState) -> u8 {
|
||||||
|
match state {
|
||||||
|
SessionState::Running => 0,
|
||||||
|
SessionState::Idle => 1,
|
||||||
|
SessionState::Pending => 2,
|
||||||
|
SessionState::Stale => 3,
|
||||||
|
SessionState::Completed => 4,
|
||||||
|
SessionState::Failed => 5,
|
||||||
|
SessionState::Stopped => 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -2015,6 +2112,77 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_file_overlaps_reports_other_active_sessions_sharing_paths() -> Result<()> {
|
||||||
|
let tempdir = TestDir::new("store-file-overlaps")?;
|
||||||
|
let db = StateStore::open(&tempdir.path().join("state.db"))?;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "session-1".to_string(),
|
||||||
|
task: "focus".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(),
|
||||||
|
})?;
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "session-2".to_string(),
|
||||||
|
task: "delegate".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: PathBuf::from("/tmp"),
|
||||||
|
state: SessionState::Idle,
|
||||||
|
pid: None,
|
||||||
|
worktree: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_heartbeat_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "session-3".to_string(),
|
||||||
|
task: "done".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: PathBuf::from("/tmp"),
|
||||||
|
state: SessionState::Completed,
|
||||||
|
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\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"updated lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:02:00Z\"}\n",
|
||||||
|
"{\"id\":\"evt-2\",\"session_id\":\"session-2\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"touched lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:03:00Z\"}\n",
|
||||||
|
"{\"id\":\"evt-3\",\"session_id\":\"session-3\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"completed overlap\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:04:00Z\"}\n"
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
db.sync_tool_activity_metrics(&metrics_path)?;
|
||||||
|
|
||||||
|
let overlaps = db.list_file_overlaps("session-1", 10)?;
|
||||||
|
assert_eq!(overlaps.len(), 1);
|
||||||
|
assert_eq!(overlaps[0].path, "src/lib.rs");
|
||||||
|
assert_eq!(overlaps[0].current_action, FileActivityAction::Modify);
|
||||||
|
assert_eq!(overlaps[0].other_action, FileActivityAction::Modify);
|
||||||
|
assert_eq!(overlaps[0].other_session_id, "session-2");
|
||||||
|
assert_eq!(overlaps[0].other_session_state, SessionState::Idle);
|
||||||
|
|
||||||
|
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")?;
|
||||||
|
|||||||
@ -19,7 +19,7 @@ use crate::session::manager;
|
|||||||
use crate::session::output::{
|
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, FileActivityOverlap, StateStore};
|
||||||
use crate::session::{FileActivityEntry, Session, SessionMessage, SessionState};
|
use crate::session::{FileActivityEntry, Session, SessionMessage, SessionState};
|
||||||
use crate::worktree;
|
use crate::worktree;
|
||||||
|
|
||||||
@ -4169,6 +4169,22 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let file_overlaps = self
|
||||||
|
.db
|
||||||
|
.list_file_overlaps(&session.id, 3)
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !file_overlaps.is_empty() {
|
||||||
|
lines.push("Potential overlaps".to_string());
|
||||||
|
for overlap in file_overlaps {
|
||||||
|
lines.push(format!(
|
||||||
|
"- {}",
|
||||||
|
file_overlap_summary(
|
||||||
|
&overlap,
|
||||||
|
&self.short_timestamp(&overlap.timestamp.to_rfc3339())
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
@ -5447,6 +5463,18 @@ fn file_activity_patch_lines(entry: &FileActivityEntry, max_lines: usize) -> Vec
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn file_overlap_summary(entry: &FileActivityOverlap, timestamp: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{} {} | {} {} as {} | {}",
|
||||||
|
file_activity_verb(entry.current_action.clone()),
|
||||||
|
truncate_for_dashboard(&entry.path, 48),
|
||||||
|
entry.other_session_state,
|
||||||
|
format_session_id(&entry.other_session_id),
|
||||||
|
file_activity_verb(entry.other_action.clone()),
|
||||||
|
timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str {
|
fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str {
|
||||||
match action {
|
match action {
|
||||||
crate::session::FileActivityAction::Read => "read",
|
crate::session::FileActivityAction::Read => "read",
|
||||||
@ -6152,6 +6180,58 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metrics_text_surfaces_file_activity_overlaps() -> Result<()> {
|
||||||
|
let root = std::env::temp_dir().join(format!("ecc2-file-overlaps-{}", Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(&root)?;
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut focus = sample_session(
|
||||||
|
"focus-12345678",
|
||||||
|
"planner",
|
||||||
|
SessionState::Running,
|
||||||
|
Some("ecc/focus"),
|
||||||
|
512,
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
focus.created_at = now - chrono::Duration::hours(1);
|
||||||
|
focus.updated_at = now - chrono::Duration::minutes(3);
|
||||||
|
|
||||||
|
let mut delegate = sample_session(
|
||||||
|
"delegate-87654321",
|
||||||
|
"coder",
|
||||||
|
SessionState::Idle,
|
||||||
|
Some("ecc/delegate"),
|
||||||
|
256,
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
delegate.created_at = now - chrono::Duration::minutes(50);
|
||||||
|
delegate.updated_at = now - chrono::Duration::minutes(2);
|
||||||
|
|
||||||
|
let mut dashboard = test_dashboard(vec![focus.clone(), delegate.clone()], 0);
|
||||||
|
dashboard.db.insert_session(&focus)?;
|
||||||
|
dashboard.db.insert_session(&delegate)?;
|
||||||
|
|
||||||
|
let metrics_path = root.join("tool-usage.jsonl");
|
||||||
|
fs::write(
|
||||||
|
&metrics_path,
|
||||||
|
concat!(
|
||||||
|
"{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"updated lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
||||||
|
"{\"id\":\"evt-2\",\"session_id\":\"delegate-87654321\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"touched lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n"
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
dashboard.db.sync_tool_activity_metrics(&metrics_path)?;
|
||||||
|
dashboard.sync_from_store();
|
||||||
|
|
||||||
|
let metrics_text = dashboard.selected_session_metrics_text();
|
||||||
|
assert!(metrics_text.contains("Potential overlaps"));
|
||||||
|
assert!(metrics_text.contains("modify src/lib.rs"));
|
||||||
|
assert!(metrics_text.contains("idle delegate"));
|
||||||
|
assert!(metrics_text.contains("as modify"));
|
||||||
|
|
||||||
|
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();
|
||||||
|
|||||||
@ -260,6 +260,54 @@ function runTests() {
|
|||||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||||
}) ? passed++ : failed++);
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('captures tracked Delete activity using git diff context', () => {
|
||||||
|
const tmpHome = makeTempDir();
|
||||||
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-delete-repo-'));
|
||||||
|
|
||||||
|
spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' });
|
||||||
|
spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' });
|
||||||
|
spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' });
|
||||||
|
|
||||||
|
const srcDir = path.join(repoDir, 'src');
|
||||||
|
fs.mkdirSync(srcDir, { recursive: true });
|
||||||
|
const trackedFile = path.join(srcDir, 'obsolete.ts');
|
||||||
|
fs.writeFileSync(trackedFile, 'export const obsolete = true;\n', 'utf8');
|
||||||
|
spawnSync('git', ['add', 'src/obsolete.ts'], { cwd: repoDir, encoding: 'utf8' });
|
||||||
|
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
|
||||||
|
|
||||||
|
fs.rmSync(trackedFile, { force: true });
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
tool_name: 'Delete',
|
||||||
|
tool_input: {
|
||||||
|
file_path: 'src/obsolete.ts',
|
||||||
|
},
|
||||||
|
tool_output: { output: 'deleted src/obsolete.ts' },
|
||||||
|
};
|
||||||
|
const result = runScript(input, {
|
||||||
|
...withTempHome(tmpHome),
|
||||||
|
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||||
|
ECC_SESSION_ID: 'ecc-session-delete',
|
||||||
|
}, {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
|
||||||
|
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
|
||||||
|
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
|
||||||
|
assert.deepStrictEqual(row.file_events, [
|
||||||
|
{
|
||||||
|
path: 'src/obsolete.ts',
|
||||||
|
action: 'delete',
|
||||||
|
diff_preview: 'export const obsolete = true; ->',
|
||||||
|
patch_preview: '@@ -1 +0,0 @@\n-export const obsolete = true;',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
|
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => {
|
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => {
|
||||||
const tmpHome = makeTempDir();
|
const tmpHome = makeTempDir();
|
||||||
const input = {
|
const input = {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user