186 lines
6.9 KiB
Rust
186 lines
6.9 KiB
Rust
//! Project aggregation: which agent works on which project. Derived from
|
|
//! the event journal (project field), with a best-effort cache written to
|
|
//! projects.json (refreshed by 'am doctor --fix').
|
|
|
|
use crate::app::App;
|
|
use crate::config::ProjectProfile;
|
|
use crate::events::{self, EventKind};
|
|
use serde::Serialize;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Clone, Default, Serialize)]
|
|
pub struct ProjectSummary {
|
|
pub root: String,
|
|
pub agents: Vec<String>,
|
|
pub dominant: Option<String>,
|
|
pub sessions: u64,
|
|
pub launches: u64,
|
|
pub total_duration_s: u64,
|
|
pub last_activity: Option<String>,
|
|
}
|
|
|
|
/// Cache file (regenerated on read; deleted by doctor --fix).
|
|
pub fn cache_file(app: &App) -> PathBuf {
|
|
app.events_dir().join("projects.json")
|
|
}
|
|
|
|
/// Aggregate the journal into per-project summaries, sorted by last
|
|
/// activity (most recent first). Also refreshes the cache file.
|
|
pub fn aggregate(app: &App) -> Vec<ProjectSummary> {
|
|
let mut map: BTreeMap<String, ProjectSummary> = BTreeMap::new();
|
|
let mut agent_counts: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
|
|
let mut sessions: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
|
for ev in events::read_events(&app.events_dir(), 0) {
|
|
let Some(project) = ev.project else {
|
|
continue;
|
|
};
|
|
let s = map
|
|
.entry(project.clone())
|
|
.or_insert_with(|| ProjectSummary {
|
|
root: project.clone(),
|
|
..Default::default()
|
|
});
|
|
if let Some(a) = ev.agent {
|
|
*agent_counts
|
|
.entry(project.clone())
|
|
.or_default()
|
|
.entry(a)
|
|
.or_default() += 1;
|
|
}
|
|
if let Some(sid) = ev.session {
|
|
sessions.entry(project.clone()).or_default().insert(sid);
|
|
}
|
|
match ev.kind {
|
|
EventKind::Start | EventKind::Run => s.launches += 1,
|
|
_ => {}
|
|
}
|
|
if let Some(d) = ev.duration_s {
|
|
s.total_duration_s += d;
|
|
}
|
|
if ev.ts > s.last_activity.clone().unwrap_or_default() {
|
|
s.last_activity = Some(ev.ts);
|
|
}
|
|
}
|
|
for (p, s) in map.iter_mut() {
|
|
s.agents = agent_counts
|
|
.get(p)
|
|
.map(|c| c.keys().cloned().collect())
|
|
.unwrap_or_default();
|
|
s.agents.sort();
|
|
s.dominant = agent_counts
|
|
.get(p)
|
|
.and_then(|c| c.iter().max_by_key(|(_, n)| *n).map(|(a, _)| a.clone()));
|
|
s.sessions = sessions.get(p).map(|set| set.len() as u64).unwrap_or(0);
|
|
}
|
|
let mut out: Vec<ProjectSummary> = map.into_values().collect();
|
|
out.sort_by(|a, b| b.last_activity.cmp(&a.last_activity));
|
|
// Best-effort cache (readable by scripts and backups).
|
|
if let Ok(text) = serde_json::to_string_pretty(&out) {
|
|
let _ = std::fs::write(cache_file(app), text);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The project profile whose root matches cwd (the longest root wins).
|
|
pub fn profile_for<'a>(app: &'a App, cwd: &Path) -> Option<(&'a str, &'a ProjectProfile)> {
|
|
let mut best: Option<(&str, &ProjectProfile)> = None;
|
|
for (name, p) in &app.config.projects {
|
|
let root = crate::config::expand_path(&p.root);
|
|
let matches = cwd.starts_with(&root) || cwd == root;
|
|
if matches {
|
|
let better = best
|
|
.map(|(_, b)| {
|
|
let broot = crate::config::expand_path(&b.root);
|
|
root.components().count() > broot.components().count()
|
|
})
|
|
.unwrap_or(true);
|
|
if better {
|
|
best = Some((name.as_str(), p));
|
|
}
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
/// Default agent of the project containing cwd (issue #30 helper).
|
|
pub fn default_agent_for_dir(app: &App, cwd: &Path) -> Option<String> {
|
|
profile_for(app, cwd)
|
|
.and_then(|(_, p)| p.default_agent.clone())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use clap::Parser;
|
|
|
|
fn app_with_events(lines: &[&str]) -> App {
|
|
let guard = tempfile::tempdir().unwrap();
|
|
let dir = guard.path().to_path_buf();
|
|
std::mem::forget(guard);
|
|
let cfg = dir.join("config.yaml");
|
|
std::fs::write(
|
|
&cfg,
|
|
concat!(
|
|
"version: \"1.0\"\n",
|
|
"settings:\n",
|
|
" auto_install_deps: false\n",
|
|
"agents: []\n",
|
|
),
|
|
)
|
|
.unwrap();
|
|
let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]);
|
|
let mut app = App::from_cli(cli).unwrap();
|
|
let mut p = app.paths.clone();
|
|
p.install_dir = dir.join("agents");
|
|
p.bin_dir = dir.join("agents").join("bin");
|
|
p.log_dir = dir.join("logs");
|
|
p.state_file = dir.join("state.json");
|
|
p.probe_cache_file = dir.join("probe-cache.json");
|
|
p.config_dir = Some(dir.join("config"));
|
|
app.paths = p;
|
|
let month = chrono::Utc::now().format("%Y%m");
|
|
let path = dir.join(format!("events-{month}.jsonl"));
|
|
let mut text = String::new();
|
|
for l in lines {
|
|
text.push_str(l);
|
|
text.push('\n');
|
|
}
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(&path, text).unwrap();
|
|
app
|
|
}
|
|
|
|
#[test]
|
|
fn aggregates_projects_from_events() {
|
|
let app = app_with_events(&[
|
|
r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s1","duration_s":10,"exit_code":0}"#,
|
|
r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s2","duration_s":20,"exit_code":0}"#,
|
|
r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"aider","project":"/home/x/api","session":"s3","duration_s":5,"exit_code":0}"#,
|
|
r#"{"ts":"2026-08-04T10:00:00Z","kind":"run","agent":"smelt","project":"/home/x/rusty","session":"s4","duration_s":7,"exit_code":0}"#,
|
|
]);
|
|
let out = aggregate(&app);
|
|
assert_eq!(out.len(), 2);
|
|
let api = out.iter().find(|s| s.root == "/home/x/api").unwrap();
|
|
assert_eq!(api.agents, vec!["aider".to_string(), "claude-code".to_string()]);
|
|
assert_eq!(api.dominant.as_deref(), Some("claude-code"));
|
|
assert_eq!(api.sessions, 3);
|
|
assert_eq!(api.launches, 3);
|
|
assert_eq!(api.total_duration_s, 35);
|
|
assert!(api.last_activity.is_some());
|
|
assert!(cache_file(&app).exists(), "cache written");
|
|
}
|
|
|
|
#[test]
|
|
fn profile_matching_prefers_longest_root() {
|
|
let app = app_with_events(&[]);
|
|
let tmp = std::env::temp_dir();
|
|
let api = tmp.join("p1-api");
|
|
let sub = api.join("src");
|
|
let _ = (api, sub);
|
|
// Ce test est complete dans l'integration (config avec profils) ;
|
|
// ici on verifie juste l'absence de profil par defaut.
|
|
assert!(default_agent_for_dir(&app, &std::env::temp_dir()).is_none());
|
|
}
|
|
}
|