mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-18 06:43:05 +08:00
Compare commits
41 Commits
8fc40da739
...
0513898b9d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0513898b9d | ||
|
|
2048f0d6f5 | ||
|
|
f5437078e1 | ||
|
|
13f99cbf1c | ||
|
|
491f213fbd | ||
|
|
941d4e6172 | ||
|
|
b01a300c31 | ||
|
|
f28f55c41e | ||
|
|
31f672275e | ||
|
|
eee9768cd8 | ||
|
|
c395b42d2c | ||
|
|
edd027edd4 | ||
|
|
a0f69cec92 | ||
|
|
24a3ffa234 | ||
|
|
48fd68115e | ||
|
|
6f08e78456 | ||
|
|
67d06687a0 | ||
|
|
95c33d3c04 | ||
|
|
08f61f667d | ||
|
|
cf9c68846c | ||
|
|
a54799127c | ||
|
|
c6e26ddea4 | ||
|
|
f136a4e0d6 | ||
|
|
3c16c85a75 | ||
|
|
0c509fe57e | ||
|
|
996edff6d1 | ||
|
|
f2cfaee6fe | ||
|
|
dc36a636af | ||
|
|
6fc3f7c3f4 | ||
|
|
f29e70883c | ||
|
|
e50c97c29b | ||
|
|
7e3bb3aec2 | ||
|
|
92c9d1f2c9 | ||
|
|
669d9cc790 | ||
|
|
1c27f7b29a | ||
|
|
cc5fe121bf | ||
|
|
15e05d96ad | ||
|
|
bab03bd8af | ||
|
|
1755069df2 | ||
|
|
3b700c8715 | ||
|
|
077f46b777 |
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -19,15 +20,26 @@ pub struct RiskThresholds {
|
||||
pub block: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BudgetAlertThresholds {
|
||||
pub advisory: f64,
|
||||
pub warning: f64,
|
||||
pub critical: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub db_path: PathBuf,
|
||||
pub worktree_root: PathBuf,
|
||||
pub worktree_branch_prefix: String,
|
||||
pub max_parallel_sessions: usize,
|
||||
pub max_parallel_worktrees: usize,
|
||||
pub worktree_retention_secs: u64,
|
||||
pub session_timeout_secs: u64,
|
||||
pub heartbeat_interval_secs: u64,
|
||||
pub auto_terminate_stale_sessions: bool,
|
||||
pub default_agent: String,
|
||||
pub auto_dispatch_unread_handoffs: bool,
|
||||
pub auto_dispatch_limit_per_session: usize,
|
||||
@@ -35,13 +47,42 @@ pub struct Config {
|
||||
pub auto_merge_ready_worktrees: bool,
|
||||
pub cost_budget_usd: f64,
|
||||
pub token_budget: u64,
|
||||
pub budget_alert_thresholds: BudgetAlertThresholds,
|
||||
pub theme: Theme,
|
||||
pub pane_layout: PaneLayout,
|
||||
pub pane_navigation: PaneNavigationConfig,
|
||||
pub linear_pane_size_percent: u16,
|
||||
pub grid_pane_size_percent: u16,
|
||||
pub risk_thresholds: RiskThresholds,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PaneNavigationConfig {
|
||||
pub focus_sessions: String,
|
||||
pub focus_output: String,
|
||||
pub focus_metrics: String,
|
||||
pub focus_log: String,
|
||||
pub move_left: String,
|
||||
pub move_down: String,
|
||||
pub move_up: String,
|
||||
pub move_right: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ProjectWorktreeConfigOverride {
|
||||
max_parallel_worktrees: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PaneNavigationAction {
|
||||
FocusSlot(usize),
|
||||
MoveLeft,
|
||||
MoveDown,
|
||||
MoveUp,
|
||||
MoveRight,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Theme {
|
||||
Dark,
|
||||
@@ -54,10 +95,13 @@ impl Default for Config {
|
||||
Self {
|
||||
db_path: home.join(".claude").join("ecc2.db"),
|
||||
worktree_root: PathBuf::from("/tmp/ecc-worktrees"),
|
||||
worktree_branch_prefix: "ecc".to_string(),
|
||||
max_parallel_sessions: 8,
|
||||
max_parallel_worktrees: 6,
|
||||
worktree_retention_secs: 0,
|
||||
session_timeout_secs: 3600,
|
||||
heartbeat_interval_secs: 30,
|
||||
auto_terminate_stale_sessions: false,
|
||||
default_agent: "claude".to_string(),
|
||||
auto_dispatch_unread_handoffs: false,
|
||||
auto_dispatch_limit_per_session: 5,
|
||||
@@ -65,8 +109,10 @@ impl Default for Config {
|
||||
auto_merge_ready_worktrees: false,
|
||||
cost_budget_usd: 10.0,
|
||||
token_budget: 500_000,
|
||||
budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS,
|
||||
theme: Theme::Dark,
|
||||
pane_layout: PaneLayout::Horizontal,
|
||||
pane_navigation: PaneNavigationConfig::default(),
|
||||
linear_pane_size_percent: 35,
|
||||
grid_pane_size_percent: 50,
|
||||
risk_thresholds: Self::RISK_THRESHOLDS,
|
||||
@@ -81,6 +127,12 @@ impl Config {
|
||||
block: 0.85,
|
||||
};
|
||||
|
||||
pub const BUDGET_ALERT_THRESHOLDS: BudgetAlertThresholds = BudgetAlertThresholds {
|
||||
advisory: 0.50,
|
||||
warning: 0.75,
|
||||
critical: 0.90,
|
||||
};
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
@@ -88,16 +140,69 @@ impl Config {
|
||||
.join("ecc2.toml")
|
||||
}
|
||||
|
||||
pub fn cost_metrics_path(&self) -> PathBuf {
|
||||
self.db_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("metrics")
|
||||
.join("costs.jsonl")
|
||||
}
|
||||
|
||||
pub fn tool_activity_metrics_path(&self) -> PathBuf {
|
||||
self.db_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("."))
|
||||
.join("metrics")
|
||||
.join("tool-usage.jsonl")
|
||||
}
|
||||
|
||||
pub fn effective_budget_alert_thresholds(&self) -> BudgetAlertThresholds {
|
||||
self.budget_alert_thresholds.sanitized()
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = Self::config_path();
|
||||
let project_path = std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|cwd| Self::project_config_path_from(&cwd));
|
||||
Self::load_from_paths(&config_path, project_path.as_deref())
|
||||
}
|
||||
|
||||
if config_path.exists() {
|
||||
let content = std::fs::read_to_string(&config_path)?;
|
||||
let config: Config = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
fn load_from_paths(
|
||||
config_path: &std::path::Path,
|
||||
project_override_path: Option<&std::path::Path>,
|
||||
) -> Result<Self> {
|
||||
let mut config = if config_path.exists() {
|
||||
let content = std::fs::read_to_string(config_path)?;
|
||||
toml::from_str(&content)?
|
||||
} else {
|
||||
Ok(Config::default())
|
||||
Config::default()
|
||||
};
|
||||
|
||||
if let Some(project_path) = project_override_path.filter(|path| path.exists()) {
|
||||
let content = std::fs::read_to_string(project_path)?;
|
||||
let overrides: ProjectWorktreeConfigOverride = toml::from_str(&content)?;
|
||||
if let Some(limit) = overrides.max_parallel_worktrees {
|
||||
config.max_parallel_worktrees = limit;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn project_config_path_from(start: &std::path::Path) -> Option<PathBuf> {
|
||||
let global = Self::config_path();
|
||||
let mut current = Some(start);
|
||||
|
||||
while let Some(path) = current {
|
||||
let candidate = path.join(".claude").join("ecc2.toml");
|
||||
if candidate.exists() && candidate != global {
|
||||
return Some(candidate);
|
||||
}
|
||||
current = path.parent();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
@@ -115,15 +220,151 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PaneNavigationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
focus_sessions: "1".to_string(),
|
||||
focus_output: "2".to_string(),
|
||||
focus_metrics: "3".to_string(),
|
||||
focus_log: "4".to_string(),
|
||||
move_left: "ctrl-h".to_string(),
|
||||
move_down: "ctrl-j".to_string(),
|
||||
move_up: "ctrl-k".to_string(),
|
||||
move_right: "ctrl-l".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PaneNavigationConfig {
|
||||
pub fn action_for_key(&self, key: KeyEvent) -> Option<PaneNavigationAction> {
|
||||
[
|
||||
(&self.focus_sessions, PaneNavigationAction::FocusSlot(1)),
|
||||
(&self.focus_output, PaneNavigationAction::FocusSlot(2)),
|
||||
(&self.focus_metrics, PaneNavigationAction::FocusSlot(3)),
|
||||
(&self.focus_log, PaneNavigationAction::FocusSlot(4)),
|
||||
(&self.move_left, PaneNavigationAction::MoveLeft),
|
||||
(&self.move_down, PaneNavigationAction::MoveDown),
|
||||
(&self.move_up, PaneNavigationAction::MoveUp),
|
||||
(&self.move_right, PaneNavigationAction::MoveRight),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(binding, action)| shortcut_matches(binding, key).then_some(action))
|
||||
}
|
||||
|
||||
pub fn focus_shortcuts_label(&self) -> String {
|
||||
[
|
||||
self.focus_sessions.as_str(),
|
||||
self.focus_output.as_str(),
|
||||
self.focus_metrics.as_str(),
|
||||
self.focus_log.as_str(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(shortcut_label)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
pub fn movement_shortcuts_label(&self) -> String {
|
||||
[
|
||||
self.move_left.as_str(),
|
||||
self.move_down.as_str(),
|
||||
self.move_up.as_str(),
|
||||
self.move_right.as_str(),
|
||||
]
|
||||
.into_iter()
|
||||
.map(shortcut_label)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
}
|
||||
|
||||
fn shortcut_matches(spec: &str, key: KeyEvent) -> bool {
|
||||
parse_shortcut(spec)
|
||||
.is_some_and(|(modifiers, code)| key.modifiers == modifiers && key.code == code)
|
||||
}
|
||||
|
||||
fn parse_shortcut(spec: &str) -> Option<(KeyModifiers, KeyCode)> {
|
||||
let normalized = spec.trim().to_ascii_lowercase().replace('+', "-");
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if normalized == "tab" {
|
||||
return Some((KeyModifiers::NONE, KeyCode::Tab));
|
||||
}
|
||||
|
||||
if normalized == "shift-tab" || normalized == "s-tab" {
|
||||
return Some((KeyModifiers::SHIFT, KeyCode::BackTab));
|
||||
}
|
||||
|
||||
if let Some(rest) = normalized
|
||||
.strip_prefix("ctrl-")
|
||||
.or_else(|| normalized.strip_prefix("c-"))
|
||||
{
|
||||
return parse_single_char(rest).map(|ch| (KeyModifiers::CONTROL, KeyCode::Char(ch)));
|
||||
}
|
||||
|
||||
parse_single_char(&normalized).map(|ch| (KeyModifiers::NONE, KeyCode::Char(ch)))
|
||||
}
|
||||
|
||||
fn parse_single_char(value: &str) -> Option<char> {
|
||||
let mut chars = value.chars();
|
||||
let ch = chars.next()?;
|
||||
(chars.next().is_none()).then_some(ch)
|
||||
}
|
||||
|
||||
fn shortcut_label(spec: &str) -> String {
|
||||
let normalized = spec.trim().to_ascii_lowercase().replace('+', "-");
|
||||
if normalized == "tab" {
|
||||
return "Tab".to_string();
|
||||
}
|
||||
if normalized == "shift-tab" || normalized == "s-tab" {
|
||||
return "S-Tab".to_string();
|
||||
}
|
||||
if let Some(rest) = normalized
|
||||
.strip_prefix("ctrl-")
|
||||
.or_else(|| normalized.strip_prefix("c-"))
|
||||
{
|
||||
if let Some(ch) = parse_single_char(rest) {
|
||||
return format!("Ctrl+{ch}");
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
impl Default for RiskThresholds {
|
||||
fn default() -> Self {
|
||||
Config::RISK_THRESHOLDS
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BudgetAlertThresholds {
|
||||
fn default() -> Self {
|
||||
Config::BUDGET_ALERT_THRESHOLDS
|
||||
}
|
||||
}
|
||||
|
||||
impl BudgetAlertThresholds {
|
||||
pub fn sanitized(self) -> Self {
|
||||
let values = [self.advisory, self.warning, self.critical];
|
||||
let valid = values.into_iter().all(f64::is_finite)
|
||||
&& self.advisory > 0.0
|
||||
&& self.advisory < self.warning
|
||||
&& self.warning < self.critical
|
||||
&& self.critical < 1.0;
|
||||
|
||||
if valid {
|
||||
self
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Config, PaneLayout};
|
||||
use super::{BudgetAlertThresholds, Config, PaneLayout};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
@@ -141,8 +382,10 @@ db_path = "/tmp/ecc2.db"
|
||||
worktree_root = "/tmp/ecc-worktrees"
|
||||
max_parallel_sessions = 8
|
||||
max_parallel_worktrees = 6
|
||||
worktree_retention_secs = 0
|
||||
session_timeout_secs = 3600
|
||||
heartbeat_interval_secs = 30
|
||||
auto_terminate_stale_sessions = false
|
||||
default_agent = "claude"
|
||||
theme = "Dark"
|
||||
"#;
|
||||
@@ -150,9 +393,22 @@ theme = "Dark"
|
||||
let config: Config = toml::from_str(legacy_config).unwrap();
|
||||
let defaults = Config::default();
|
||||
|
||||
assert_eq!(
|
||||
config.worktree_branch_prefix,
|
||||
defaults.worktree_branch_prefix
|
||||
);
|
||||
assert_eq!(
|
||||
config.worktree_retention_secs,
|
||||
defaults.worktree_retention_secs
|
||||
);
|
||||
assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd);
|
||||
assert_eq!(config.token_budget, defaults.token_budget);
|
||||
assert_eq!(
|
||||
config.budget_alert_thresholds,
|
||||
defaults.budget_alert_thresholds
|
||||
);
|
||||
assert_eq!(config.pane_layout, defaults.pane_layout);
|
||||
assert_eq!(config.pane_navigation, defaults.pane_navigation);
|
||||
assert_eq!(
|
||||
config.linear_pane_size_percent,
|
||||
defaults.linear_pane_size_percent
|
||||
@@ -175,6 +431,10 @@ theme = "Dark"
|
||||
config.auto_merge_ready_worktrees,
|
||||
defaults.auto_merge_ready_worktrees
|
||||
);
|
||||
assert_eq!(
|
||||
config.auto_terminate_stale_sessions,
|
||||
defaults.auto_terminate_stale_sessions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -197,11 +457,149 @@ theme = "Dark"
|
||||
assert_eq!(config.pane_layout, PaneLayout::Grid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_branch_prefix_deserializes_from_toml() {
|
||||
let config: Config = toml::from_str(r#"worktree_branch_prefix = "bots/ecc""#).unwrap();
|
||||
|
||||
assert_eq!(config.worktree_branch_prefix, "bots/ecc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_worktree_limit_override_replaces_global_limit() {
|
||||
let tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4()));
|
||||
let global_path = tempdir.join("global.toml");
|
||||
let project_path = tempdir.join("project.toml");
|
||||
std::fs::create_dir_all(&tempdir).unwrap();
|
||||
std::fs::write(&global_path, "max_parallel_worktrees = 6\n").unwrap();
|
||||
std::fs::write(&project_path, "max_parallel_worktrees = 2\n").unwrap();
|
||||
|
||||
let config = Config::load_from_paths(&global_path, Some(&project_path)).unwrap();
|
||||
assert_eq!(config.max_parallel_worktrees, 2);
|
||||
|
||||
let _ = std::fs::remove_dir_all(tempdir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_navigation_deserializes_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[pane_navigation]
|
||||
focus_sessions = "q"
|
||||
focus_output = "w"
|
||||
focus_metrics = "e"
|
||||
focus_log = "r"
|
||||
move_left = "a"
|
||||
move_down = "s"
|
||||
move_up = "w"
|
||||
move_right = "d"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.pane_navigation.focus_sessions, "q");
|
||||
assert_eq!(config.pane_navigation.focus_output, "w");
|
||||
assert_eq!(config.pane_navigation.focus_metrics, "e");
|
||||
assert_eq!(config.pane_navigation.focus_log, "r");
|
||||
assert_eq!(config.pane_navigation.move_left, "a");
|
||||
assert_eq!(config.pane_navigation.move_down, "s");
|
||||
assert_eq!(config.pane_navigation.move_up, "w");
|
||||
assert_eq!(config.pane_navigation.move_right, "d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_navigation_matches_default_shortcuts() {
|
||||
let navigation = Config::default().pane_navigation;
|
||||
|
||||
assert_eq!(
|
||||
navigation.action_for_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)),
|
||||
Some(super::PaneNavigationAction::FocusSlot(1))
|
||||
);
|
||||
assert_eq!(
|
||||
navigation.action_for_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)),
|
||||
Some(super::PaneNavigationAction::MoveRight)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_navigation_matches_custom_shortcuts() {
|
||||
let navigation = super::PaneNavigationConfig {
|
||||
focus_sessions: "q".to_string(),
|
||||
focus_output: "w".to_string(),
|
||||
focus_metrics: "e".to_string(),
|
||||
focus_log: "r".to_string(),
|
||||
move_left: "a".to_string(),
|
||||
move_down: "s".to_string(),
|
||||
move_up: "w".to_string(),
|
||||
move_right: "d".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
navigation.action_for_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)),
|
||||
Some(super::PaneNavigationAction::FocusSlot(3))
|
||||
);
|
||||
assert_eq!(
|
||||
navigation.action_for_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)),
|
||||
Some(super::PaneNavigationAction::MoveRight)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_risk_thresholds_are_applied() {
|
||||
assert_eq!(Config::default().risk_thresholds, Config::RISK_THRESHOLDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_budget_alert_thresholds_are_applied() {
|
||||
assert_eq!(
|
||||
Config::default().budget_alert_thresholds,
|
||||
Config::BUDGET_ALERT_THRESHOLDS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_alert_thresholds_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[budget_alert_thresholds]
|
||||
advisory = 0.40
|
||||
warning = 0.70
|
||||
critical = 0.85
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.budget_alert_thresholds,
|
||||
BudgetAlertThresholds {
|
||||
advisory: 0.40,
|
||||
warning: 0.70,
|
||||
critical: 0.85,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
config.effective_budget_alert_thresholds(),
|
||||
config.budget_alert_thresholds
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_budget_alert_thresholds_fall_back_to_defaults() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[budget_alert_thresholds]
|
||||
advisory = 0.80
|
||||
warning = 0.70
|
||||
critical = 1.10
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.effective_budget_alert_thresholds(),
|
||||
Config::BUDGET_ALERT_THRESHOLDS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_round_trips_automation_settings() {
|
||||
let path = std::env::temp_dir().join(format!("ecc2-config-{}.toml", Uuid::new_v4()));
|
||||
@@ -210,6 +608,14 @@ theme = "Dark"
|
||||
config.auto_dispatch_limit_per_session = 9;
|
||||
config.auto_create_worktrees = false;
|
||||
config.auto_merge_ready_worktrees = true;
|
||||
config.worktree_branch_prefix = "bots/ecc".to_string();
|
||||
config.budget_alert_thresholds = BudgetAlertThresholds {
|
||||
advisory: 0.45,
|
||||
warning: 0.70,
|
||||
critical: 0.88,
|
||||
};
|
||||
config.pane_navigation.focus_metrics = "e".to_string();
|
||||
config.pane_navigation.move_right = "d".to_string();
|
||||
config.linear_pane_size_percent = 42;
|
||||
config.grid_pane_size_percent = 55;
|
||||
|
||||
@@ -221,6 +627,17 @@ theme = "Dark"
|
||||
assert_eq!(loaded.auto_dispatch_limit_per_session, 9);
|
||||
assert!(!loaded.auto_create_worktrees);
|
||||
assert!(loaded.auto_merge_ready_worktrees);
|
||||
assert_eq!(loaded.worktree_branch_prefix, "bots/ecc");
|
||||
assert_eq!(
|
||||
loaded.budget_alert_thresholds,
|
||||
BudgetAlertThresholds {
|
||||
advisory: 0.45,
|
||||
warning: 0.70,
|
||||
critical: 0.88,
|
||||
}
|
||||
);
|
||||
assert_eq!(loaded.pane_navigation.focus_metrics, "e");
|
||||
assert_eq!(loaded.pane_navigation.move_right, "d");
|
||||
assert_eq!(loaded.linear_pane_size_percent, 42);
|
||||
assert_eq!(loaded.grid_pane_size_percent, 55);
|
||||
|
||||
|
||||
523
ecc2/src/main.rs
523
ecc2/src/main.rs
@@ -250,6 +250,14 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Export sessions, tool spans, and metrics in OTLP-compatible JSON
|
||||
ExportOtel {
|
||||
/// Session ID or alias. Omit to export all sessions.
|
||||
session_id: Option<String>,
|
||||
/// Write the export to a file instead of stdout
|
||||
#[arg(long)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
/// Stop a running session
|
||||
Stop {
|
||||
/// Session ID or alias
|
||||
@@ -673,17 +681,20 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
Some(Commands::Sessions) => {
|
||||
sync_runtime_session_metrics(&db, &cfg)?;
|
||||
let sessions = session::manager::list_sessions(&db)?;
|
||||
for s in sessions {
|
||||
println!("{} [{}] {}", s.id, s.state, s.task);
|
||||
}
|
||||
}
|
||||
Some(Commands::Status { session_id }) => {
|
||||
sync_runtime_session_metrics(&db, &cfg)?;
|
||||
let id = session_id.unwrap_or_else(|| "latest".to_string());
|
||||
let status = session::manager::get_status(&db, &id)?;
|
||||
println!("{status}");
|
||||
}
|
||||
Some(Commands::Team { session_id, depth }) => {
|
||||
sync_runtime_session_metrics(&db, &cfg)?;
|
||||
let id = session_id.unwrap_or_else(|| "latest".to_string());
|
||||
let team = session::manager::get_team_status(&db, &id, depth)?;
|
||||
println!("{team}");
|
||||
@@ -798,13 +809,28 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
Some(Commands::PruneWorktrees { json }) => {
|
||||
let outcome = session::manager::prune_inactive_worktrees(&db).await?;
|
||||
let outcome = session::manager::prune_inactive_worktrees(&db, &cfg).await?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&outcome)?);
|
||||
} else {
|
||||
println!("{}", format_prune_worktrees_human(&outcome));
|
||||
}
|
||||
}
|
||||
Some(Commands::ExportOtel { session_id, output }) => {
|
||||
sync_runtime_session_metrics(&db, &cfg)?;
|
||||
let resolved_session_id = session_id
|
||||
.as_deref()
|
||||
.map(|value| resolve_session_id(&db, value))
|
||||
.transpose()?;
|
||||
let export = build_otel_export(&db, resolved_session_id.as_deref())?;
|
||||
let rendered = serde_json::to_string_pretty(&export)?;
|
||||
if let Some(path) = output {
|
||||
std::fs::write(&path, rendered)?;
|
||||
println!("OTLP export written to {}", path.display());
|
||||
} else {
|
||||
println!("{rendered}");
|
||||
}
|
||||
}
|
||||
Some(Commands::Stop { session_id }) => {
|
||||
session::manager::stop_session(&db, &session_id).await?;
|
||||
println!("Session stopped: {session_id}");
|
||||
@@ -890,6 +916,18 @@ fn resolve_session_id(db: &session::store::StateStore, value: &str) -> Result<St
|
||||
.ok_or_else(|| anyhow::anyhow!("Session not found: {value}"))
|
||||
}
|
||||
|
||||
fn sync_runtime_session_metrics(
|
||||
db: &session::store::StateStore,
|
||||
cfg: &config::Config,
|
||||
) -> Result<()> {
|
||||
db.refresh_session_durations()?;
|
||||
db.sync_cost_tracker_metrics(&cfg.cost_metrics_path())?;
|
||||
db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path())?;
|
||||
let _ = session::manager::enforce_session_heartbeats(db, cfg)?;
|
||||
let _ = session::manager::enforce_budget_hard_limits(db, cfg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_message(
|
||||
kind: MessageKindArg,
|
||||
text: String,
|
||||
@@ -1066,6 +1104,93 @@ struct WorktreeResolutionReport {
|
||||
resolution_steps: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpExport {
|
||||
resource_spans: Vec<OtlpResourceSpans>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpResourceSpans {
|
||||
resource: OtlpResource,
|
||||
scope_spans: Vec<OtlpScopeSpans>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpResource {
|
||||
attributes: Vec<OtlpKeyValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpScopeSpans {
|
||||
scope: OtlpInstrumentationScope,
|
||||
spans: Vec<OtlpSpan>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpInstrumentationScope {
|
||||
name: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpSpan {
|
||||
trace_id: String,
|
||||
span_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parent_span_id: Option<String>,
|
||||
name: String,
|
||||
kind: String,
|
||||
start_time_unix_nano: String,
|
||||
end_time_unix_nano: String,
|
||||
attributes: Vec<OtlpKeyValue>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
links: Vec<OtlpSpanLink>,
|
||||
status: OtlpSpanStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpSpanLink {
|
||||
trace_id: String,
|
||||
span_id: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
attributes: Vec<OtlpKeyValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpSpanStatus {
|
||||
code: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpKeyValue {
|
||||
key: String,
|
||||
value: OtlpAnyValue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OtlpAnyValue {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
string_value: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
int_value: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
double_value: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
bool_value: Option<bool>,
|
||||
}
|
||||
|
||||
fn build_worktree_status_report(
|
||||
session: &session::Session,
|
||||
include_patch: bool,
|
||||
@@ -1419,9 +1544,229 @@ fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome
|
||||
}
|
||||
}
|
||||
|
||||
if outcome.retained_session_ids.is_empty() {
|
||||
lines.push("No inactive worktrees are being retained".to_string());
|
||||
} else {
|
||||
lines.push(format!(
|
||||
"Deferred {} inactive worktree(s) still within retention",
|
||||
outcome.retained_session_ids.len()
|
||||
));
|
||||
for session_id in &outcome.retained_session_ids {
|
||||
lines.push(format!("- retained {}", short_session(session_id)));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn build_otel_export(
|
||||
db: &session::store::StateStore,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<OtlpExport> {
|
||||
let sessions = if let Some(session_id) = session_id {
|
||||
vec![db
|
||||
.get_session(session_id)?
|
||||
.ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?]
|
||||
} else {
|
||||
db.list_sessions()?
|
||||
};
|
||||
|
||||
let mut spans = Vec::new();
|
||||
for session in &sessions {
|
||||
spans.extend(build_session_otel_spans(db, session)?);
|
||||
}
|
||||
|
||||
Ok(OtlpExport {
|
||||
resource_spans: vec![OtlpResourceSpans {
|
||||
resource: OtlpResource {
|
||||
attributes: vec![
|
||||
otlp_string_attr("service.name", "ecc2"),
|
||||
otlp_string_attr("service.version", env!("CARGO_PKG_VERSION")),
|
||||
otlp_string_attr("telemetry.sdk.language", "rust"),
|
||||
],
|
||||
},
|
||||
scope_spans: vec![OtlpScopeSpans {
|
||||
scope: OtlpInstrumentationScope {
|
||||
name: "ecc2".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
},
|
||||
spans,
|
||||
}],
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn build_session_otel_spans(
|
||||
db: &session::store::StateStore,
|
||||
session: &session::Session,
|
||||
) -> Result<Vec<OtlpSpan>> {
|
||||
let trace_id = otlp_trace_id(&session.id);
|
||||
let session_span_id = otlp_span_id(&format!("session:{}", session.id));
|
||||
let parent_link = db.latest_task_handoff_source(&session.id)?;
|
||||
let session_end = session.updated_at.max(session.created_at);
|
||||
let mut spans = vec![OtlpSpan {
|
||||
trace_id: trace_id.clone(),
|
||||
span_id: session_span_id.clone(),
|
||||
parent_span_id: None,
|
||||
name: format!("session {}", session.task),
|
||||
kind: "SPAN_KIND_INTERNAL".to_string(),
|
||||
start_time_unix_nano: otlp_timestamp_nanos(session.created_at),
|
||||
end_time_unix_nano: otlp_timestamp_nanos(session_end),
|
||||
attributes: vec![
|
||||
otlp_string_attr("ecc.session.id", &session.id),
|
||||
otlp_string_attr("ecc.session.state", &session.state.to_string()),
|
||||
otlp_string_attr("ecc.agent.type", &session.agent_type),
|
||||
otlp_string_attr("ecc.session.task", &session.task),
|
||||
otlp_string_attr(
|
||||
"ecc.working_dir",
|
||||
session.working_dir.to_string_lossy().as_ref(),
|
||||
),
|
||||
otlp_int_attr("ecc.metrics.input_tokens", session.metrics.input_tokens),
|
||||
otlp_int_attr("ecc.metrics.output_tokens", session.metrics.output_tokens),
|
||||
otlp_int_attr("ecc.metrics.tokens_used", session.metrics.tokens_used),
|
||||
otlp_int_attr("ecc.metrics.tool_calls", session.metrics.tool_calls),
|
||||
otlp_int_attr(
|
||||
"ecc.metrics.files_changed",
|
||||
u64::from(session.metrics.files_changed),
|
||||
),
|
||||
otlp_int_attr("ecc.metrics.duration_secs", session.metrics.duration_secs),
|
||||
otlp_double_attr("ecc.metrics.cost_usd", session.metrics.cost_usd),
|
||||
],
|
||||
links: parent_link
|
||||
.into_iter()
|
||||
.map(|parent_session_id| OtlpSpanLink {
|
||||
trace_id: otlp_trace_id(&parent_session_id),
|
||||
span_id: otlp_span_id(&format!("session:{parent_session_id}")),
|
||||
attributes: vec![otlp_string_attr(
|
||||
"ecc.parent_session.id",
|
||||
&parent_session_id,
|
||||
)],
|
||||
})
|
||||
.collect(),
|
||||
status: otlp_session_status(&session.state),
|
||||
}];
|
||||
|
||||
for entry in db.list_tool_logs_for_session(&session.id)? {
|
||||
let span_end = chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
|
||||
.unwrap_or_else(|_| session.updated_at.into())
|
||||
.with_timezone(&chrono::Utc);
|
||||
let span_start = span_end - chrono::Duration::milliseconds(entry.duration_ms as i64);
|
||||
|
||||
spans.push(OtlpSpan {
|
||||
trace_id: trace_id.clone(),
|
||||
span_id: otlp_span_id(&format!("tool:{}:{}", session.id, entry.id)),
|
||||
parent_span_id: Some(session_span_id.clone()),
|
||||
name: format!("tool {}", entry.tool_name),
|
||||
kind: "SPAN_KIND_INTERNAL".to_string(),
|
||||
start_time_unix_nano: otlp_timestamp_nanos(span_start),
|
||||
end_time_unix_nano: otlp_timestamp_nanos(span_end),
|
||||
attributes: vec![
|
||||
otlp_string_attr("ecc.session.id", &entry.session_id),
|
||||
otlp_string_attr("tool.name", &entry.tool_name),
|
||||
otlp_string_attr("tool.input_summary", &entry.input_summary),
|
||||
otlp_string_attr("tool.output_summary", &entry.output_summary),
|
||||
otlp_string_attr("tool.trigger_summary", &entry.trigger_summary),
|
||||
otlp_string_attr("tool.input_params_json", &entry.input_params_json),
|
||||
otlp_int_attr("tool.duration_ms", entry.duration_ms),
|
||||
otlp_double_attr("tool.risk_score", entry.risk_score),
|
||||
],
|
||||
links: Vec::new(),
|
||||
status: OtlpSpanStatus {
|
||||
code: "STATUS_CODE_UNSET".to_string(),
|
||||
message: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Ok(spans)
|
||||
}
|
||||
|
||||
fn otlp_timestamp_nanos(value: chrono::DateTime<chrono::Utc>) -> String {
|
||||
value
|
||||
.timestamp_nanos_opt()
|
||||
.unwrap_or_default()
|
||||
.max(0)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn otlp_trace_id(seed: &str) -> String {
|
||||
format!(
|
||||
"{:016x}{:016x}",
|
||||
fnv1a64(seed.as_bytes()),
|
||||
fnv1a64_with_seed(seed.as_bytes(), 1099511628211)
|
||||
)
|
||||
}
|
||||
|
||||
fn otlp_span_id(seed: &str) -> String {
|
||||
format!("{:016x}", fnv1a64(seed.as_bytes()))
|
||||
}
|
||||
|
||||
fn fnv1a64(bytes: &[u8]) -> u64 {
|
||||
fnv1a64_with_seed(bytes, 14695981039346656037)
|
||||
}
|
||||
|
||||
fn fnv1a64_with_seed(bytes: &[u8], offset_basis: u64) -> u64 {
|
||||
let mut hash = offset_basis;
|
||||
for byte in bytes {
|
||||
hash ^= u64::from(*byte);
|
||||
hash = hash.wrapping_mul(1099511628211);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn otlp_string_attr(key: &str, value: &str) -> OtlpKeyValue {
|
||||
OtlpKeyValue {
|
||||
key: key.to_string(),
|
||||
value: OtlpAnyValue {
|
||||
string_value: Some(value.to_string()),
|
||||
int_value: None,
|
||||
double_value: None,
|
||||
bool_value: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn otlp_int_attr(key: &str, value: u64) -> OtlpKeyValue {
|
||||
OtlpKeyValue {
|
||||
key: key.to_string(),
|
||||
value: OtlpAnyValue {
|
||||
string_value: None,
|
||||
int_value: Some(value.to_string()),
|
||||
double_value: None,
|
||||
bool_value: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn otlp_double_attr(key: &str, value: f64) -> OtlpKeyValue {
|
||||
OtlpKeyValue {
|
||||
key: key.to_string(),
|
||||
value: OtlpAnyValue {
|
||||
string_value: None,
|
||||
int_value: None,
|
||||
double_value: Some(value),
|
||||
bool_value: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn otlp_session_status(state: &session::SessionState) -> OtlpSpanStatus {
|
||||
match state {
|
||||
session::SessionState::Completed => OtlpSpanStatus {
|
||||
code: "STATUS_CODE_OK".to_string(),
|
||||
message: None,
|
||||
},
|
||||
session::SessionState::Failed => OtlpSpanStatus {
|
||||
code: "STATUS_CODE_ERROR".to_string(),
|
||||
message: Some("session failed".to_string()),
|
||||
},
|
||||
_ => OtlpSpanStatus {
|
||||
code: "STATUS_CODE_UNSET".to_string(),
|
||||
message: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_coordinate_backlog(
|
||||
outcome: &session::manager::CoordinateBacklogOutcome,
|
||||
) -> CoordinateBacklogPassSummary {
|
||||
@@ -1529,6 +1874,66 @@ fn send_handoff_message(db: &session::store::StateStore, from_id: &str, to_id: &
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::session::store::StateStore;
|
||||
use crate::session::{Session, SessionMetrics, SessionState};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct TestDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestDir {
|
||||
fn new(label: &str) -> Result<Self> {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("ecc2-main-{label}-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_session(id: &str, task: &str, state: SessionState) -> Session {
|
||||
let now = Utc::now();
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
task: task.to_string(),
|
||||
agent_type: "claude".to_string(),
|
||||
working_dir: PathBuf::from("/tmp/ecc"),
|
||||
state,
|
||||
pid: None,
|
||||
worktree: None,
|
||||
created_at: now - Duration::seconds(5),
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics {
|
||||
input_tokens: 120,
|
||||
output_tokens: 30,
|
||||
tokens_used: 150,
|
||||
tool_calls: 2,
|
||||
files_changed: 1,
|
||||
duration_secs: 5,
|
||||
cost_usd: 0.42,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn attr_value<'a>(attrs: &'a [OtlpKeyValue], key: &str) -> Option<&'a OtlpAnyValue> {
|
||||
attrs
|
||||
.iter()
|
||||
.find(|attr| attr.key == key)
|
||||
.map(|attr| &attr.value)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_policy_defaults_to_config_setting() {
|
||||
@@ -1571,6 +1976,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_export_otel_command() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"ecc",
|
||||
"export-otel",
|
||||
"worker-1234",
|
||||
"--output",
|
||||
"/tmp/ecc-otel.json",
|
||||
])
|
||||
.expect("export-otel should parse");
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::ExportOtel { session_id, output }) => {
|
||||
assert_eq!(session_id.as_deref(), Some("worker-1234"));
|
||||
assert_eq!(output.as_deref(), Some(Path::new("/tmp/ecc-otel.json")));
|
||||
}
|
||||
_ => panic!("expected export-otel subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_messages_send_command() {
|
||||
let cli = Cli::try_parse_from([
|
||||
@@ -1859,6 +2284,99 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_otel_export_includes_session_and_tool_spans() -> Result<()> {
|
||||
let tempdir = TestDir::new("otel-export-session")?;
|
||||
let db = StateStore::open(&tempdir.path().join("state.db"))?;
|
||||
let session = build_session("session-1", "Investigate export", SessionState::Completed);
|
||||
db.insert_session(&session)?;
|
||||
db.insert_tool_log(
|
||||
&session.id,
|
||||
"Write",
|
||||
"Write src/lib.rs",
|
||||
"{\"file\":\"src/lib.rs\"}",
|
||||
"Updated file",
|
||||
"manual test",
|
||||
120,
|
||||
0.75,
|
||||
&Utc::now().to_rfc3339(),
|
||||
)?;
|
||||
|
||||
let export = build_otel_export(&db, Some("session-1"))?;
|
||||
let spans = &export.resource_spans[0].scope_spans[0].spans;
|
||||
assert_eq!(spans.len(), 2);
|
||||
|
||||
let session_span = spans
|
||||
.iter()
|
||||
.find(|span| span.parent_span_id.is_none())
|
||||
.expect("session root span");
|
||||
let tool_span = spans
|
||||
.iter()
|
||||
.find(|span| span.parent_span_id.is_some())
|
||||
.expect("tool child span");
|
||||
|
||||
assert_eq!(session_span.trace_id, tool_span.trace_id);
|
||||
assert_eq!(
|
||||
tool_span.parent_span_id.as_deref(),
|
||||
Some(session_span.span_id.as_str())
|
||||
);
|
||||
assert_eq!(session_span.status.code, "STATUS_CODE_OK");
|
||||
assert_eq!(
|
||||
attr_value(&session_span.attributes, "ecc.session.id")
|
||||
.and_then(|value| value.string_value.as_deref()),
|
||||
Some("session-1")
|
||||
);
|
||||
assert_eq!(
|
||||
attr_value(&tool_span.attributes, "tool.name")
|
||||
.and_then(|value| value.string_value.as_deref()),
|
||||
Some("Write")
|
||||
);
|
||||
assert_eq!(
|
||||
attr_value(&tool_span.attributes, "tool.duration_ms")
|
||||
.and_then(|value| value.int_value.as_deref()),
|
||||
Some("120")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_otel_export_links_delegated_session_to_parent_trace() -> Result<()> {
|
||||
let tempdir = TestDir::new("otel-export-parent-link")?;
|
||||
let db = StateStore::open(&tempdir.path().join("state.db"))?;
|
||||
let parent = build_session("lead-1", "Lead task", SessionState::Running);
|
||||
let child = build_session("worker-1", "Delegated task", SessionState::Running);
|
||||
db.insert_session(&parent)?;
|
||||
db.insert_session(&child)?;
|
||||
db.send_message(
|
||||
&parent.id,
|
||||
&child.id,
|
||||
"{\"task\":\"Delegated task\",\"context\":\"Delegated from lead\"}",
|
||||
"task_handoff",
|
||||
)?;
|
||||
|
||||
let export = build_otel_export(&db, Some("worker-1"))?;
|
||||
let session_span = export.resource_spans[0].scope_spans[0]
|
||||
.spans
|
||||
.iter()
|
||||
.find(|span| span.parent_span_id.is_none())
|
||||
.expect("session root span");
|
||||
|
||||
assert_eq!(session_span.links.len(), 1);
|
||||
assert_eq!(session_span.links[0].trace_id, otlp_trace_id("lead-1"));
|
||||
assert_eq!(
|
||||
session_span.links[0].span_id,
|
||||
otlp_span_id("session:lead-1")
|
||||
);
|
||||
assert_eq!(
|
||||
attr_value(&session_span.links[0].attributes, "ecc.parent_session.id")
|
||||
.and_then(|value| value.string_value.as_deref()),
|
||||
Some("lead-1")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_parses_worktree_status_check_flag() {
|
||||
let cli = Cli::try_parse_from(["ecc", "worktree-status", "--check"])
|
||||
@@ -2090,12 +2608,15 @@ mod tests {
|
||||
let text = format_prune_worktrees_human(&session::manager::WorktreePruneOutcome {
|
||||
cleaned_session_ids: vec!["deadbeefcafefeed".to_string()],
|
||||
active_with_worktree_ids: vec!["facefeed12345678".to_string()],
|
||||
retained_session_ids: vec!["retain1234567890".to_string()],
|
||||
});
|
||||
|
||||
assert!(text.contains("Pruned 1 inactive worktree(s)"));
|
||||
assert!(text.contains("- cleaned deadbeef"));
|
||||
assert!(text.contains("Skipped 1 active session(s) still holding worktrees"));
|
||||
assert!(text.contains("- active facefeed"));
|
||||
assert!(text.contains("Deferred 1 inactive worktree(s) still within retention"));
|
||||
assert!(text.contains("- retained retain12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,9 @@ pub struct ToolCallEvent {
|
||||
pub session_id: String,
|
||||
pub tool_name: String,
|
||||
pub input_summary: String,
|
||||
pub input_params_json: String,
|
||||
pub output_summary: String,
|
||||
pub trigger_summary: String,
|
||||
pub duration_ms: u64,
|
||||
pub risk_score: f64,
|
||||
}
|
||||
@@ -47,7 +49,9 @@ impl ToolCallEvent {
|
||||
.score,
|
||||
tool_name,
|
||||
input_summary,
|
||||
input_params_json: "{}".to_string(),
|
||||
output_summary: output_summary.into(),
|
||||
trigger_summary: String::new(),
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
@@ -238,7 +242,9 @@ pub struct ToolLogEntry {
|
||||
pub session_id: String,
|
||||
pub tool_name: String,
|
||||
pub input_summary: String,
|
||||
pub input_params_json: String,
|
||||
pub output_summary: String,
|
||||
pub trigger_summary: String,
|
||||
pub duration_ms: u64,
|
||||
pub risk_score: f64,
|
||||
pub timestamp: String,
|
||||
@@ -268,7 +274,9 @@ impl<'a> ToolLogger<'a> {
|
||||
&event.session_id,
|
||||
&event.tool_name,
|
||||
&event.input_summary,
|
||||
&event.input_params_json,
|
||||
&event.output_summary,
|
||||
&event.trigger_summary,
|
||||
event.duration_ms,
|
||||
event.risk_score,
|
||||
×tamp,
|
||||
@@ -313,6 +321,7 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
}
|
||||
}
|
||||
@@ -397,6 +406,8 @@ mod tests {
|
||||
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");
|
||||
assert_eq!(first_page.entries[0].input_params_json, "{}");
|
||||
assert_eq!(first_page.entries[0].trigger_summary, "");
|
||||
|
||||
let second_page = logger.query("sess-1", 2, 2)?;
|
||||
assert_eq!(second_page.total, 3);
|
||||
|
||||
@@ -22,10 +22,8 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
resume_crashed_sessions(&db)?;
|
||||
|
||||
let heartbeat_interval = Duration::from_secs(cfg.heartbeat_interval_secs);
|
||||
let timeout = Duration::from_secs(cfg.session_timeout_secs);
|
||||
|
||||
loop {
|
||||
if let Err(e) = check_sessions(&db, timeout) {
|
||||
if let Err(e) = check_sessions(&db, &cfg) {
|
||||
tracing::error!("Session check failed: {e}");
|
||||
}
|
||||
|
||||
@@ -37,10 +35,14 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
tracing::error!("Worktree auto-merge pass failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db).await {
|
||||
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db, &cfg).await {
|
||||
tracing::error!("Worktree auto-prune pass failed: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = manager::activate_pending_worktree_sessions(&db, &cfg).await {
|
||||
tracing::error!("Queued worktree activation pass failed: {e}");
|
||||
}
|
||||
|
||||
time::sleep(heartbeat_interval).await;
|
||||
}
|
||||
}
|
||||
@@ -82,25 +84,8 @@ where
|
||||
Ok(failed_sessions)
|
||||
}
|
||||
|
||||
fn check_sessions(db: &StateStore, timeout: Duration) -> Result<()> {
|
||||
let sessions = db.list_sessions()?;
|
||||
|
||||
for session in sessions {
|
||||
if session.state != SessionState::Running {
|
||||
continue;
|
||||
}
|
||||
|
||||
let elapsed = chrono::Utc::now()
|
||||
.signed_duration_since(session.updated_at)
|
||||
.to_std()
|
||||
.unwrap_or(Duration::ZERO);
|
||||
|
||||
if elapsed > timeout {
|
||||
tracing::warn!("Session {} timed out after {:?}", session.id, elapsed);
|
||||
db.update_state_and_pid(&session.id, &SessionState::Failed, None)?;
|
||||
}
|
||||
}
|
||||
|
||||
fn check_sessions(db: &StateStore, cfg: &Config) -> Result<()> {
|
||||
let _ = manager::enforce_session_heartbeats(db, cfg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -408,9 +393,9 @@ where
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result<usize> {
|
||||
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||
maybe_auto_prune_inactive_worktrees_with_recorder(
|
||||
|| manager::prune_inactive_worktrees(db),
|
||||
|| manager::prune_inactive_worktrees(db, cfg),
|
||||
|pruned, active| db.record_daemon_auto_prune_pass(pruned, active),
|
||||
)
|
||||
.await
|
||||
@@ -436,6 +421,7 @@ where
|
||||
let outcome = prune().await?;
|
||||
let pruned = outcome.cleaned_session_ids.len();
|
||||
let active = outcome.active_with_worktree_ids.len();
|
||||
let retained = outcome.retained_session_ids.len();
|
||||
record(pruned, active)?;
|
||||
|
||||
if pruned > 0 {
|
||||
@@ -444,6 +430,9 @@ where
|
||||
if active > 0 {
|
||||
tracing::info!("Skipped {active} active worktree(s) during auto-prune");
|
||||
}
|
||||
if retained > 0 {
|
||||
tracing::info!("Deferred {retained} inactive worktree(s) within retention");
|
||||
}
|
||||
|
||||
Ok(pruned)
|
||||
}
|
||||
@@ -498,6 +487,7 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
}
|
||||
}
|
||||
@@ -1269,6 +1259,7 @@ mod tests {
|
||||
Ok(manager::WorktreePruneOutcome {
|
||||
cleaned_session_ids: vec!["stopped-a".to_string(), "stopped-b".to_string()],
|
||||
active_with_worktree_ids: vec!["running-a".to_string()],
|
||||
retained_session_ids: vec!["retained-a".to_string()],
|
||||
})
|
||||
},
|
||||
move |pruned, active| {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ pub struct Session {
|
||||
pub worktree: Option<WorktreeInfo>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_heartbeat_at: DateTime<Utc>,
|
||||
pub metrics: SessionMetrics,
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ pub enum SessionState {
|
||||
Pending,
|
||||
Running,
|
||||
Idle,
|
||||
Stale,
|
||||
Completed,
|
||||
Failed,
|
||||
Stopped,
|
||||
@@ -39,6 +41,7 @@ impl fmt::Display for SessionState {
|
||||
SessionState::Pending => write!(f, "pending"),
|
||||
SessionState::Running => write!(f, "running"),
|
||||
SessionState::Idle => write!(f, "idle"),
|
||||
SessionState::Stale => write!(f, "stale"),
|
||||
SessionState::Completed => write!(f, "completed"),
|
||||
SessionState::Failed => write!(f, "failed"),
|
||||
SessionState::Stopped => write!(f, "stopped"),
|
||||
@@ -60,12 +63,21 @@ impl SessionState {
|
||||
) | (
|
||||
SessionState::Running,
|
||||
SessionState::Idle
|
||||
| SessionState::Stale
|
||||
| SessionState::Completed
|
||||
| SessionState::Failed
|
||||
| SessionState::Stopped
|
||||
) | (
|
||||
SessionState::Idle,
|
||||
SessionState::Running
|
||||
| SessionState::Stale
|
||||
| SessionState::Completed
|
||||
| SessionState::Failed
|
||||
| SessionState::Stopped
|
||||
) | (
|
||||
SessionState::Stale,
|
||||
SessionState::Running
|
||||
| SessionState::Idle
|
||||
| SessionState::Completed
|
||||
| SessionState::Failed
|
||||
| SessionState::Stopped
|
||||
@@ -78,6 +90,7 @@ impl SessionState {
|
||||
match value {
|
||||
"running" => SessionState::Running,
|
||||
"idle" => SessionState::Idle,
|
||||
"stale" => SessionState::Stale,
|
||||
"completed" => SessionState::Completed,
|
||||
"failed" => SessionState::Failed,
|
||||
"stopped" => SessionState::Stopped,
|
||||
@@ -95,6 +108,8 @@ pub struct WorktreeInfo {
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct SessionMetrics {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub tokens_used: u64,
|
||||
pub tool_calls: u64,
|
||||
pub files_changed: u32,
|
||||
@@ -112,3 +127,25 @@ pub struct SessionMessage {
|
||||
pub read: bool,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FileActivityEntry {
|
||||
pub session_id: String,
|
||||
pub action: FileActivityAction,
|
||||
pub path: String,
|
||||
pub summary: String,
|
||||
pub diff_preview: Option<String>,
|
||||
pub patch_preview: Option<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,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,31 @@ impl OutputStream {
|
||||
pub struct OutputLine {
|
||||
pub stream: OutputStream,
|
||||
pub text: String,
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
impl OutputLine {
|
||||
pub fn new(
|
||||
stream: OutputStream,
|
||||
text: impl Into<String>,
|
||||
timestamp: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
text: text.into(),
|
||||
timestamp: timestamp.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_current_timestamp(stream: OutputStream, text: impl Into<String>) -> Self {
|
||||
Self::new(stream, text, chrono::Utc::now().to_rfc3339())
|
||||
}
|
||||
|
||||
pub fn occurred_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
chrono::DateTime::parse_from_rfc3339(&self.timestamp)
|
||||
.ok()
|
||||
.map(|timestamp| timestamp.with_timezone(&chrono::Utc))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -70,10 +95,7 @@ impl SessionOutputStore {
|
||||
}
|
||||
|
||||
pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into<String>) {
|
||||
let line = OutputLine {
|
||||
stream,
|
||||
text: text.into(),
|
||||
};
|
||||
let line = OutputLine::with_current_timestamp(stream, text);
|
||||
|
||||
{
|
||||
let mut buffers = self.lock_buffers();
|
||||
@@ -145,5 +167,6 @@ mod tests {
|
||||
assert_eq!(event.session_id, "session-1");
|
||||
assert_eq!(event.line.stream, OutputStream::Stderr);
|
||||
assert_eq!(event.line.text, "problem");
|
||||
assert!(event.line.occurred_at().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::time::{self, MissedTickBehavior};
|
||||
|
||||
use super::output::{OutputStream, SessionOutputStore};
|
||||
use super::store::StateStore;
|
||||
@@ -26,6 +27,9 @@ enum DbMessage {
|
||||
line: String,
|
||||
ack: oneshot::Sender<DbAck>,
|
||||
},
|
||||
TouchHeartbeat {
|
||||
ack: oneshot::Sender<DbAck>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -53,6 +57,10 @@ impl DbWriter {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn touch_heartbeat(&self) -> Result<()> {
|
||||
self.send(|ack| DbMessage::TouchHeartbeat { ack }).await
|
||||
}
|
||||
|
||||
async fn send<F>(&self, build: F) -> Result<()>
|
||||
where
|
||||
F: FnOnce(oneshot::Sender<DbAck>) -> DbMessage,
|
||||
@@ -111,6 +119,17 @@ fn run_db_writer(db_path: PathBuf, session_id: String, mut rx: mpsc::UnboundedRe
|
||||
};
|
||||
let _ = ack.send(result);
|
||||
}
|
||||
DbMessage::TouchHeartbeat { ack } => {
|
||||
let result = match opened.as_ref() {
|
||||
Some(db) => db
|
||||
.touch_heartbeat(&session_id)
|
||||
.map_err(|error| error.to_string()),
|
||||
None => Err(open_error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Failed to open state store".to_string())),
|
||||
};
|
||||
let _ = ack.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +139,7 @@ pub async fn capture_command_output(
|
||||
session_id: String,
|
||||
mut command: Command,
|
||||
output_store: SessionOutputStore,
|
||||
heartbeat_interval: std::time::Duration,
|
||||
) -> Result<ExitStatus> {
|
||||
let db_writer = DbWriter::start(db_path, session_id.clone());
|
||||
|
||||
@@ -152,6 +172,19 @@ pub async fn capture_command_output(
|
||||
.ok_or_else(|| anyhow::anyhow!("Spawned process did not expose a process id"))?;
|
||||
db_writer.update_pid(Some(pid)).await?;
|
||||
db_writer.update_state(SessionState::Running).await?;
|
||||
db_writer.touch_heartbeat().await?;
|
||||
|
||||
let heartbeat_writer = db_writer.clone();
|
||||
let heartbeat_task = tokio::spawn(async move {
|
||||
let mut ticker = time::interval(heartbeat_interval);
|
||||
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if heartbeat_writer.touch_heartbeat().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stdout_task = tokio::spawn(capture_stream(
|
||||
session_id.clone(),
|
||||
@@ -169,6 +202,8 @@ pub async fn capture_command_output(
|
||||
));
|
||||
|
||||
let status = child.wait().await?;
|
||||
heartbeat_task.abort();
|
||||
let _ = heartbeat_task.await;
|
||||
stdout_task.await??;
|
||||
stderr_task.await??;
|
||||
|
||||
@@ -244,6 +279,7 @@ mod tests {
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
})?;
|
||||
|
||||
@@ -254,9 +290,14 @@ mod tests {
|
||||
.arg("-c")
|
||||
.arg("printf 'alpha\\n'; printf 'beta\\n' >&2");
|
||||
|
||||
let status =
|
||||
capture_command_output(db_path.clone(), session_id.clone(), command, output_store)
|
||||
.await?;
|
||||
let status = capture_command_output(
|
||||
db_path.clone(),
|
||||
session_id.clone(),
|
||||
command,
|
||||
output_store,
|
||||
std::time::Duration::from_millis(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(status.success());
|
||||
|
||||
@@ -286,4 +327,49 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_command_output_updates_heartbeat_for_quiet_processes() -> Result<()> {
|
||||
let db_path = env::temp_dir().join(format!("ecc2-runtime-heartbeat-{}.db", Uuid::new_v4()));
|
||||
let db = StateStore::open(&db_path)?;
|
||||
let session_id = "session-heartbeat".to_string();
|
||||
let now = Utc::now();
|
||||
|
||||
db.insert_session(&Session {
|
||||
id: session_id.clone(),
|
||||
task: "quiet process".to_string(),
|
||||
agent_type: "test".to_string(),
|
||||
working_dir: env::temp_dir(),
|
||||
state: SessionState::Pending,
|
||||
pid: None,
|
||||
worktree: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_heartbeat_at: now,
|
||||
metrics: SessionMetrics::default(),
|
||||
})?;
|
||||
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.arg("-c").arg("sleep 0.05");
|
||||
|
||||
let _ = capture_command_output(
|
||||
db_path.clone(),
|
||||
session_id.clone(),
|
||||
command,
|
||||
SessionOutputStore::default(),
|
||||
std::time::Duration::from_millis(10),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let db = StateStore::open(&db_path)?;
|
||||
let session = db
|
||||
.get_session(&session_id)?
|
||||
.expect("session should still exist");
|
||||
|
||||
assert!(session.last_heartbeat_at > now);
|
||||
assert_eq!(session.state, SessionState::Completed);
|
||||
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,17 +27,17 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
|
||||
if event::poll(Duration::from_millis(250))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if dashboard.is_search_mode() {
|
||||
if dashboard.is_input_mode() {
|
||||
match (key.modifiers, key.code) {
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
||||
(_, KeyCode::Esc) => dashboard.cancel_search_input(),
|
||||
(_, KeyCode::Enter) => dashboard.submit_search(),
|
||||
(_, KeyCode::Backspace) => dashboard.pop_search_char(),
|
||||
(_, KeyCode::Esc) => dashboard.cancel_input(),
|
||||
(_, KeyCode::Enter) => dashboard.submit_input().await,
|
||||
(_, KeyCode::Backspace) => dashboard.pop_input_char(),
|
||||
(modifiers, KeyCode::Char(ch))
|
||||
if !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
dashboard.push_search_char(ch);
|
||||
dashboard.push_input_char(ch);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -45,9 +45,19 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if dashboard.is_pane_command_mode() {
|
||||
if dashboard.handle_pane_command_key(key) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
match (key.modifiers, key.code) {
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('w')) => {
|
||||
dashboard.begin_pane_command_mode()
|
||||
}
|
||||
(_, KeyCode::Char('q')) => break,
|
||||
_ if dashboard.handle_pane_navigation_key(key) => {}
|
||||
(_, KeyCode::Tab) => dashboard.next_pane(),
|
||||
(KeyModifiers::SHIFT, KeyCode::BackTab) => dashboard.prev_pane(),
|
||||
(_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => {
|
||||
@@ -56,6 +66,9 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
(_, KeyCode::Char('-')) => dashboard.decrease_pane_size(),
|
||||
(_, KeyCode::Char('j')) | (_, KeyCode::Down) => dashboard.scroll_down(),
|
||||
(_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(),
|
||||
(_, KeyCode::Char('[')) => dashboard.focus_previous_delegate(),
|
||||
(_, KeyCode::Char(']')) => dashboard.focus_next_delegate(),
|
||||
(_, KeyCode::Enter) => dashboard.open_focused_delegate(),
|
||||
(_, KeyCode::Char('/')) => dashboard.begin_search(),
|
||||
(_, KeyCode::Esc) => dashboard.clear_search(),
|
||||
(_, KeyCode::Char('n')) if dashboard.has_active_search() => {
|
||||
@@ -64,15 +77,28 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
(_, KeyCode::Char('N')) if dashboard.has_active_search() => {
|
||||
dashboard.prev_search_match()
|
||||
}
|
||||
(_, KeyCode::Char('N')) => dashboard.begin_spawn_prompt(),
|
||||
(_, KeyCode::Char('n')) => dashboard.new_session().await,
|
||||
(_, KeyCode::Char('a')) => dashboard.assign_selected().await,
|
||||
(_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await,
|
||||
(_, KeyCode::Char('B')) => dashboard.rebalance_all_teams().await,
|
||||
(_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await,
|
||||
(_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(),
|
||||
(_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await,
|
||||
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
|
||||
(_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(),
|
||||
(_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(),
|
||||
(_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(),
|
||||
(_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(),
|
||||
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
|
||||
(_, KeyCode::Char('V')) => dashboard.toggle_diff_view_mode(),
|
||||
(_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(),
|
||||
(_, KeyCode::Char('}')) => dashboard.next_diff_hunk(),
|
||||
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
|
||||
(_, KeyCode::Char('e')) => dashboard.toggle_output_filter(),
|
||||
(_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(),
|
||||
(_, KeyCode::Char('A')) => dashboard.toggle_search_scope(),
|
||||
(_, KeyCode::Char('o')) => dashboard.toggle_search_agent_filter(),
|
||||
(_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await,
|
||||
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
|
||||
(_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,49 @@
|
||||
use crate::config::BudgetAlertThresholds;
|
||||
|
||||
use ratatui::{
|
||||
prelude::*,
|
||||
text::{Line, Span},
|
||||
widgets::{Gauge, Paragraph, Widget},
|
||||
};
|
||||
|
||||
pub(crate) const WARNING_THRESHOLD: f64 = 0.8;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum BudgetState {
|
||||
Unconfigured,
|
||||
Normal,
|
||||
Warning,
|
||||
Alert50,
|
||||
Alert75,
|
||||
Alert90,
|
||||
OverBudget,
|
||||
}
|
||||
|
||||
impl BudgetState {
|
||||
pub(crate) const fn is_warning(self) -> bool {
|
||||
matches!(self, Self::Warning | Self::OverBudget)
|
||||
fn badge(self, thresholds: BudgetAlertThresholds) -> Option<String> {
|
||||
match self {
|
||||
Self::Alert50 => Some(threshold_label(thresholds.advisory)),
|
||||
Self::Alert75 => Some(threshold_label(thresholds.warning)),
|
||||
Self::Alert90 => Some(threshold_label(thresholds.critical)),
|
||||
Self::OverBudget => Some("over budget".to_string()),
|
||||
Self::Unconfigured => Some("no budget".to_string()),
|
||||
Self::Normal => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn badge(self) -> Option<&'static str> {
|
||||
pub(crate) fn summary_suffix(self, thresholds: BudgetAlertThresholds) -> Option<String> {
|
||||
match self {
|
||||
Self::Warning => Some("warning"),
|
||||
Self::OverBudget => Some("over budget"),
|
||||
Self::Unconfigured => Some("no budget"),
|
||||
Self::Normal => None,
|
||||
Self::Alert50 => Some(format!(
|
||||
"Budget alert {}",
|
||||
threshold_label(thresholds.advisory)
|
||||
)),
|
||||
Self::Alert75 => Some(format!(
|
||||
"Budget alert {}",
|
||||
threshold_label(thresholds.warning)
|
||||
)),
|
||||
Self::Alert90 => Some(format!(
|
||||
"Budget alert {}",
|
||||
threshold_label(thresholds.critical)
|
||||
)),
|
||||
Self::OverBudget => Some("Budget exceeded".to_string()),
|
||||
Self::Unconfigured | Self::Normal => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +51,13 @@ impl BudgetState {
|
||||
let base = Style::default().fg(match self {
|
||||
Self::Unconfigured => Color::DarkGray,
|
||||
Self::Normal => Color::DarkGray,
|
||||
Self::Warning => Color::Yellow,
|
||||
Self::Alert50 => Color::Cyan,
|
||||
Self::Alert75 => Color::Yellow,
|
||||
Self::Alert90 => Color::LightRed,
|
||||
Self::OverBudget => Color::Red,
|
||||
});
|
||||
|
||||
if self.is_warning() {
|
||||
if matches!(self, Self::Alert75 | Self::Alert90 | Self::OverBudget) {
|
||||
base.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
base
|
||||
@@ -55,30 +76,43 @@ pub(crate) struct TokenMeter<'a> {
|
||||
title: &'a str,
|
||||
used: f64,
|
||||
budget: f64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
format: MeterFormat,
|
||||
}
|
||||
|
||||
impl<'a> TokenMeter<'a> {
|
||||
pub(crate) fn tokens(title: &'a str, used: u64, budget: u64) -> Self {
|
||||
pub(crate) fn tokens(
|
||||
title: &'a str,
|
||||
used: u64,
|
||||
budget: u64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
) -> Self {
|
||||
Self {
|
||||
title,
|
||||
used: used as f64,
|
||||
budget: budget as f64,
|
||||
thresholds,
|
||||
format: MeterFormat::Tokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn currency(title: &'a str, used: f64, budget: f64) -> Self {
|
||||
pub(crate) fn currency(
|
||||
title: &'a str,
|
||||
used: f64,
|
||||
budget: f64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
) -> Self {
|
||||
Self {
|
||||
title,
|
||||
used,
|
||||
budget,
|
||||
thresholds,
|
||||
format: MeterFormat::Currency,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn state(&self) -> BudgetState {
|
||||
budget_state(self.used, self.budget)
|
||||
budget_state(self.used, self.budget, self.thresholds)
|
||||
}
|
||||
|
||||
fn ratio(&self) -> f64 {
|
||||
@@ -97,7 +131,7 @@ impl<'a> TokenMeter<'a> {
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
|
||||
if let Some(badge) = self.state().badge() {
|
||||
if let Some(badge) = self.state().badge(self.thresholds) {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(format!("[{badge}]"), self.state().style()));
|
||||
}
|
||||
@@ -165,7 +199,7 @@ impl Widget for TokenMeter<'_> {
|
||||
.label(self.display_label())
|
||||
.gauge_style(
|
||||
Style::default()
|
||||
.fg(gradient_color(self.ratio()))
|
||||
.fg(gradient_color(self.ratio(), self.thresholds))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
@@ -182,35 +216,51 @@ pub(crate) fn budget_ratio(used: f64, budget: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn budget_state(used: f64, budget: f64) -> BudgetState {
|
||||
pub(crate) fn budget_state(
|
||||
used: f64,
|
||||
budget: f64,
|
||||
thresholds: BudgetAlertThresholds,
|
||||
) -> BudgetState {
|
||||
if budget <= 0.0 {
|
||||
BudgetState::Unconfigured
|
||||
} else if used / budget >= 1.0 {
|
||||
BudgetState::OverBudget
|
||||
} else if used / budget >= WARNING_THRESHOLD {
|
||||
BudgetState::Warning
|
||||
} else if used / budget >= thresholds.critical {
|
||||
BudgetState::Alert90
|
||||
} else if used / budget >= thresholds.warning {
|
||||
BudgetState::Alert75
|
||||
} else if used / budget >= thresholds.advisory {
|
||||
BudgetState::Alert50
|
||||
} else {
|
||||
BudgetState::Normal
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gradient_color(ratio: f64) -> Color {
|
||||
pub(crate) fn gradient_color(ratio: f64, thresholds: BudgetAlertThresholds) -> Color {
|
||||
const GREEN: (u8, u8, u8) = (34, 197, 94);
|
||||
const YELLOW: (u8, u8, u8) = (234, 179, 8);
|
||||
const RED: (u8, u8, u8) = (239, 68, 68);
|
||||
|
||||
let clamped = ratio.clamp(0.0, 1.0);
|
||||
if clamped <= WARNING_THRESHOLD {
|
||||
interpolate_rgb(GREEN, YELLOW, clamped / WARNING_THRESHOLD)
|
||||
if clamped <= thresholds.warning {
|
||||
interpolate_rgb(
|
||||
GREEN,
|
||||
YELLOW,
|
||||
clamped / thresholds.warning.max(f64::EPSILON),
|
||||
)
|
||||
} else {
|
||||
interpolate_rgb(
|
||||
YELLOW,
|
||||
RED,
|
||||
(clamped - WARNING_THRESHOLD) / (1.0 - WARNING_THRESHOLD),
|
||||
(clamped - thresholds.warning) / (1.0 - thresholds.warning),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn threshold_label(value: f64) -> String {
|
||||
format!("{}%", (value * 100.0).round() as u64)
|
||||
}
|
||||
|
||||
pub(crate) fn format_currency(value: f64) -> String {
|
||||
format!("${value:.2}")
|
||||
}
|
||||
@@ -246,25 +296,76 @@ fn interpolate_rgb(from: (u8, u8, u8), to: (u8, u8, u8), ratio: f64) -> Color {
|
||||
mod tests {
|
||||
use ratatui::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget};
|
||||
|
||||
use super::{gradient_color, BudgetState, TokenMeter};
|
||||
use crate::config::{BudgetAlertThresholds, Config};
|
||||
|
||||
use super::{gradient_color, threshold_label, BudgetState, TokenMeter};
|
||||
|
||||
#[test]
|
||||
fn warning_state_starts_at_eighty_percent() {
|
||||
let meter = TokenMeter::tokens("Token Budget", 80, 100);
|
||||
|
||||
assert_eq!(meter.state(), BudgetState::Warning);
|
||||
fn budget_state_uses_alert_threshold_ladder() {
|
||||
assert_eq!(
|
||||
TokenMeter::tokens("Token Budget", 50, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
|
||||
BudgetState::Alert50
|
||||
);
|
||||
assert_eq!(
|
||||
TokenMeter::tokens("Token Budget", 75, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
|
||||
BudgetState::Alert75
|
||||
);
|
||||
assert_eq!(
|
||||
TokenMeter::tokens("Token Budget", 90, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
|
||||
BudgetState::Alert90
|
||||
);
|
||||
assert_eq!(
|
||||
TokenMeter::tokens("Token Budget", 100, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
|
||||
BudgetState::OverBudget
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_runs_from_green_to_yellow_to_red() {
|
||||
assert_eq!(gradient_color(0.0), Color::Rgb(34, 197, 94));
|
||||
assert_eq!(gradient_color(0.8), Color::Rgb(234, 179, 8));
|
||||
assert_eq!(gradient_color(1.0), Color::Rgb(239, 68, 68));
|
||||
assert_eq!(
|
||||
gradient_color(0.0, Config::BUDGET_ALERT_THRESHOLDS),
|
||||
Color::Rgb(34, 197, 94)
|
||||
);
|
||||
assert_eq!(
|
||||
gradient_color(0.75, Config::BUDGET_ALERT_THRESHOLDS),
|
||||
Color::Rgb(234, 179, 8)
|
||||
);
|
||||
assert_eq!(
|
||||
gradient_color(1.0, Config::BUDGET_ALERT_THRESHOLDS),
|
||||
Color::Rgb(239, 68, 68)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_meter_uses_custom_budget_thresholds() {
|
||||
let meter = TokenMeter::tokens(
|
||||
"Token Budget",
|
||||
45,
|
||||
100,
|
||||
BudgetAlertThresholds {
|
||||
advisory: 0.40,
|
||||
warning: 0.70,
|
||||
critical: 0.85,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(meter.state(), BudgetState::Alert50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_label_rounds_to_percent() {
|
||||
assert_eq!(threshold_label(0.4), "40%");
|
||||
assert_eq!(threshold_label(0.875), "88%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_meter_renders_compact_usage_label() {
|
||||
let meter = TokenMeter::tokens("Token Budget", 4_000, 10_000);
|
||||
let meter = TokenMeter::tokens(
|
||||
"Token Budget",
|
||||
4_000,
|
||||
10_000,
|
||||
Config::BUDGET_ALERT_THRESHOLDS,
|
||||
);
|
||||
let area = Rect::new(0, 0, 48, 2);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ pub(crate) fn create_for_session_in_repo(
|
||||
cfg: &Config,
|
||||
repo_root: &Path,
|
||||
) -> Result<WorktreeInfo> {
|
||||
let branch = format!("ecc/{session_id}");
|
||||
let branch = branch_name_for_session(session_id, cfg, repo_root)?;
|
||||
let path = cfg.worktree_root.join(session_id);
|
||||
|
||||
// Get current branch as base
|
||||
@@ -80,6 +80,27 @@ pub(crate) fn create_for_session_in_repo(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn branch_name_for_session(
|
||||
session_id: &str,
|
||||
cfg: &Config,
|
||||
repo_root: &Path,
|
||||
) -> Result<String> {
|
||||
let prefix = cfg.worktree_branch_prefix.trim().trim_matches('/');
|
||||
if prefix.is_empty() {
|
||||
anyhow::bail!("worktree_branch_prefix cannot be empty");
|
||||
}
|
||||
|
||||
let branch = format!("{prefix}/{session_id}");
|
||||
validate_branch_name(repo_root, &branch).with_context(|| {
|
||||
format!(
|
||||
"Invalid worktree branch '{branch}' derived from prefix '{}' and session id '{session_id}'",
|
||||
cfg.worktree_branch_prefix
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(branch)
|
||||
}
|
||||
|
||||
/// Remove a worktree and its branch.
|
||||
pub fn remove(worktree: &WorktreeInfo) -> Result<()> {
|
||||
let repo_root = match base_checkout_path(worktree) {
|
||||
@@ -461,6 +482,26 @@ fn git_status_short(worktree_path: &Path) -> Result<Vec<String>> {
|
||||
Ok(parse_nonempty_lines(&output.stdout))
|
||||
}
|
||||
|
||||
fn validate_branch_name(repo_root: &Path, branch: &str) -> Result<()> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_root)
|
||||
.args(["check-ref-format", "--branch", branch])
|
||||
.output()
|
||||
.context("Failed to validate worktree branch name")?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
anyhow::bail!("branch name is not a valid git ref");
|
||||
} else {
|
||||
anyhow::bail!("{stderr}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_nonempty_lines(stdout: &[u8]) -> Vec<String> {
|
||||
String::from_utf8_lossy(stdout)
|
||||
.lines()
|
||||
@@ -576,9 +617,7 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_summary_reports_clean_and_dirty_worktrees() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("ecc2-worktree-{}", Uuid::new_v4()));
|
||||
fn init_repo(root: &Path) -> Result<PathBuf> {
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(&repo)?;
|
||||
|
||||
@@ -589,6 +628,60 @@ mod tests {
|
||||
run_git(&repo, &["add", "README.md"])?;
|
||||
run_git(&repo, &["commit", "-m", "init"])?;
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_for_session_uses_configured_branch_prefix() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("ecc2-worktree-prefix-{}", Uuid::new_v4()));
|
||||
let repo = init_repo(&root)?;
|
||||
let mut cfg = Config::default();
|
||||
cfg.worktree_root = root.join("worktrees");
|
||||
cfg.worktree_branch_prefix = "bots/ecc".to_string();
|
||||
|
||||
let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?;
|
||||
assert_eq!(worktree.branch, "bots/ecc/worker-123");
|
||||
|
||||
let branch = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo)
|
||||
.args(["rev-parse", "--abbrev-ref", "bots/ecc/worker-123"])
|
||||
.output()?;
|
||||
assert!(branch.status.success());
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&branch.stdout).trim(),
|
||||
"bots/ecc/worker-123"
|
||||
);
|
||||
|
||||
remove(&worktree)?;
|
||||
let _ = fs::remove_dir_all(root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_for_session_rejects_invalid_branch_prefix() -> Result<()> {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ecc2-worktree-invalid-prefix-{}", Uuid::new_v4()));
|
||||
let repo = init_repo(&root)?;
|
||||
let mut cfg = Config::default();
|
||||
cfg.worktree_root = root.join("worktrees");
|
||||
cfg.worktree_branch_prefix = "bad prefix".to_string();
|
||||
|
||||
let error = create_for_session_in_repo("worker-123", &cfg, &repo).unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("Invalid worktree branch"));
|
||||
assert!(message.contains("bad prefix"));
|
||||
assert!(!cfg.worktree_root.join("worker-123").exists());
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_summary_reports_clean_and_dirty_worktrees() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("ecc2-worktree-{}", Uuid::new_v4()));
|
||||
let repo = init_repo(&root)?;
|
||||
|
||||
let worktree_dir = root.join("wt-1");
|
||||
run_git(
|
||||
&repo,
|
||||
@@ -631,15 +724,7 @@ mod tests {
|
||||
#[test]
|
||||
fn diff_file_preview_reports_branch_and_working_tree_files() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("ecc2-worktree-preview-{}", Uuid::new_v4()));
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(&repo)?;
|
||||
|
||||
run_git(&repo, &["init", "-b", "main"])?;
|
||||
run_git(&repo, &["config", "user.email", "ecc@example.com"])?;
|
||||
run_git(&repo, &["config", "user.name", "ECC"])?;
|
||||
fs::write(repo.join("README.md"), "hello\n")?;
|
||||
run_git(&repo, &["add", "README.md"])?;
|
||||
run_git(&repo, &["commit", "-m", "init"])?;
|
||||
let repo = init_repo(&root)?;
|
||||
|
||||
let worktree_dir = root.join("wt-1");
|
||||
run_git(
|
||||
@@ -686,15 +771,7 @@ mod tests {
|
||||
#[test]
|
||||
fn diff_patch_preview_reports_branch_and_working_tree_sections() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("ecc2-worktree-patch-{}", Uuid::new_v4()));
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(&repo)?;
|
||||
|
||||
run_git(&repo, &["init", "-b", "main"])?;
|
||||
run_git(&repo, &["config", "user.email", "ecc@example.com"])?;
|
||||
run_git(&repo, &["config", "user.name", "ECC"])?;
|
||||
fs::write(repo.join("README.md"), "hello\n")?;
|
||||
run_git(&repo, &["add", "README.md"])?;
|
||||
run_git(&repo, &["commit", "-m", "init"])?;
|
||||
let repo = init_repo(&root)?;
|
||||
|
||||
let worktree_dir = root.join("wt-1");
|
||||
run_git(
|
||||
@@ -740,15 +817,7 @@ mod tests {
|
||||
fn merge_readiness_reports_ready_worktree() -> Result<()> {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ecc2-worktree-merge-ready-{}", Uuid::new_v4()));
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(&repo)?;
|
||||
|
||||
run_git(&repo, &["init", "-b", "main"])?;
|
||||
run_git(&repo, &["config", "user.email", "ecc@example.com"])?;
|
||||
run_git(&repo, &["config", "user.name", "ECC"])?;
|
||||
fs::write(repo.join("README.md"), "hello\n")?;
|
||||
run_git(&repo, &["add", "README.md"])?;
|
||||
run_git(&repo, &["commit", "-m", "init"])?;
|
||||
let repo = init_repo(&root)?;
|
||||
|
||||
let worktree_dir = root.join("wt-1");
|
||||
run_git(
|
||||
@@ -792,15 +861,7 @@ mod tests {
|
||||
fn merge_readiness_reports_conflicted_worktree() -> Result<()> {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ecc2-worktree-merge-conflict-{}", Uuid::new_v4()));
|
||||
let repo = root.join("repo");
|
||||
fs::create_dir_all(&repo)?;
|
||||
|
||||
run_git(&repo, &["init", "-b", "main"])?;
|
||||
run_git(&repo, &["config", "user.email", "ecc@example.com"])?;
|
||||
run_git(&repo, &["config", "user.name", "ECC"])?;
|
||||
fs::write(repo.join("README.md"), "hello\n")?;
|
||||
run_git(&repo, &["add", "README.md"])?;
|
||||
run_git(&repo, &["commit", "-m", "init"])?;
|
||||
let repo = init_repo(&root)?;
|
||||
|
||||
let worktree_dir = root.join("wt-1");
|
||||
run_git(
|
||||
|
||||
@@ -260,6 +260,18 @@
|
||||
"description": "Capture governance events from tool outputs. Enable with ECC_GOVERNANCE_CAPTURE=1",
|
||||
"id": "post:governance-capture"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:session-activity-tracker\" \"scripts/hooks/session-activity-tracker.js\" \"standard,strict\"",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Track per-session tool calls and file activity for ECC2 metrics",
|
||||
"id": "post:session-activity-tracker"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
|
||||
@@ -55,7 +55,7 @@ process.stdin.on('end', () => {
|
||||
const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0);
|
||||
|
||||
const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown');
|
||||
const sessionId = String(process.env.CLAUDE_SESSION_ID || 'default');
|
||||
const sessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
|
||||
|
||||
const metricsDir = path.join(getClaudeDir(), 'metrics');
|
||||
ensureDir(metricsDir);
|
||||
|
||||
611
scripts/hooks/session-activity-tracker.js
Normal file
611
scripts/hooks/session-activity-tracker.js
Normal file
@@ -0,0 +1,611 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Session Activity Tracker Hook
|
||||
*
|
||||
* PostToolUse hook that records sanitized per-tool activity to
|
||||
* ~/.claude/metrics/tool-usage.jsonl for ECC2 metric sync.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
const {
|
||||
appendFile,
|
||||
getClaudeDir,
|
||||
stripAnsi,
|
||||
} = require('../lib/utils');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
const METRICS_FILE_NAME = 'tool-usage.jsonl';
|
||||
const FILE_PATH_KEYS = new Set([
|
||||
'file_path',
|
||||
'file_paths',
|
||||
'source_path',
|
||||
'destination_path',
|
||||
'old_file_path',
|
||||
'new_file_path',
|
||||
]);
|
||||
|
||||
function redactSecrets(value) {
|
||||
return String(value || '')
|
||||
.replace(/\n/g, ' ')
|
||||
.replace(/--token[= ][^ ]*/g, '--token=<REDACTED>')
|
||||
.replace(/Authorization:[: ]*[^ ]*[: ]*[^ ]*/gi, 'Authorization:<REDACTED>')
|
||||
.replace(/\bAKIA[A-Z0-9]{16}\b/g, '<REDACTED>')
|
||||
.replace(/\bASIA[A-Z0-9]{16}\b/g, '<REDACTED>')
|
||||
.replace(/password[= ][^ ]*/gi, 'password=<REDACTED>')
|
||||
.replace(/\bghp_[A-Za-z0-9_]+\b/g, '<REDACTED>')
|
||||
.replace(/\bgho_[A-Za-z0-9_]+\b/g, '<REDACTED>')
|
||||
.replace(/\bghs_[A-Za-z0-9_]+\b/g, '<REDACTED>')
|
||||
.replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, '<REDACTED>');
|
||||
}
|
||||
|
||||
function truncateSummary(value, maxLength = 220) {
|
||||
const normalized = stripAnsi(redactSecrets(value)).trim().replace(/\s+/g, ' ');
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function sanitizeParamValue(value, depth = 0) {
|
||||
if (depth >= 4) {
|
||||
return '[Truncated]';
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return truncateSummary(value, 160);
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, 8).map(entry => sanitizeParamValue(entry, depth + 1));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const output = {};
|
||||
for (const [key, nested] of Object.entries(value).slice(0, 20)) {
|
||||
output[key] = sanitizeParamValue(nested, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
return truncateSummary(String(value), 160);
|
||||
}
|
||||
|
||||
function sanitizeInputParams(toolInput) {
|
||||
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(sanitizeParamValue(toolInput));
|
||||
} catch {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
function pushPathCandidate(paths, value) {
|
||||
const candidate = String(value || '').trim();
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
|
||||
return;
|
||||
}
|
||||
if (!paths.includes(candidate)) {
|
||||
paths.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
function pushFileEvent(events, value, action, diffPreview, patchPreview) {
|
||||
const candidate = String(value || '').trim();
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
|
||||
return;
|
||||
}
|
||||
const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim()
|
||||
? diffPreview.trim()
|
||||
: undefined;
|
||||
const normalizedPatchPreview = typeof patchPreview === 'string' && patchPreview.trim()
|
||||
? patchPreview.trim()
|
||||
: undefined;
|
||||
if (!events.some(event =>
|
||||
event.path === candidate
|
||||
&& event.action === action
|
||||
&& (event.diff_preview || undefined) === normalizedDiffPreview
|
||||
&& (event.patch_preview || undefined) === normalizedPatchPreview
|
||||
)) {
|
||||
const event = { path: candidate, action };
|
||||
if (normalizedDiffPreview) {
|
||||
event.diff_preview = normalizedDiffPreview;
|
||||
}
|
||||
if (normalizedPatchPreview) {
|
||||
event.patch_preview = normalizedPatchPreview;
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeDiffText(value, maxLength = 96) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return '';
|
||||
}
|
||||
return truncateSummary(value, maxLength);
|
||||
}
|
||||
|
||||
function sanitizePatchLines(value, maxLines = 4, maxLineLength = 120) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return stripAnsi(redactSecrets(value))
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, maxLines)
|
||||
.map(line => line.length <= maxLineLength ? line : `${line.slice(0, maxLineLength - 3)}...`);
|
||||
}
|
||||
|
||||
function buildReplacementPreview(oldValue, newValue) {
|
||||
const before = sanitizeDiffText(oldValue);
|
||||
const after = sanitizeDiffText(newValue);
|
||||
if (!before && !after) {
|
||||
return undefined;
|
||||
}
|
||||
if (!before) {
|
||||
return `-> ${after}`;
|
||||
}
|
||||
if (!after) {
|
||||
return `${before} ->`;
|
||||
}
|
||||
return `${before} -> ${after}`;
|
||||
}
|
||||
|
||||
function buildCreationPreview(content) {
|
||||
const normalized = sanitizeDiffText(content);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return `+ ${normalized}`;
|
||||
}
|
||||
|
||||
function buildPatchPreviewFromReplacement(oldValue, newValue) {
|
||||
const beforeLines = sanitizePatchLines(oldValue);
|
||||
const afterLines = sanitizePatchLines(newValue);
|
||||
if (beforeLines.length === 0 && afterLines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = ['@@'];
|
||||
for (const line of beforeLines) {
|
||||
lines.push(`- ${line}`);
|
||||
}
|
||||
for (const line of afterLines) {
|
||||
lines.push(`+ ${line}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildPatchPreviewFromContent(content, prefix) {
|
||||
const lines = sanitizePatchLines(content);
|
||||
if (lines.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return lines.map(line => `${prefix} ${line}`).join('\n');
|
||||
}
|
||||
|
||||
function buildDiffPreviewFromPatchPreview(patchPreview) {
|
||||
if (typeof patchPreview !== 'string' || !patchPreview.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lines = patchPreview
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
const removed = lines.find(line => line.startsWith('- ') || line.startsWith('-'));
|
||||
const added = lines.find(line => line.startsWith('+ ') || line.startsWith('+'));
|
||||
|
||||
if (!removed && !added) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const before = removed ? removed.replace(/^- ?/, '') : '';
|
||||
const after = added ? added.replace(/^\+ ?/, '') : '';
|
||||
if (before && after) {
|
||||
return `${before} -> ${after}`;
|
||||
}
|
||||
if (before) {
|
||||
return `${before} ->`;
|
||||
}
|
||||
return `-> ${after}`;
|
||||
}
|
||||
|
||||
function inferDefaultFileAction(toolName) {
|
||||
const normalized = String(toolName || '').trim().toLowerCase();
|
||||
if (normalized.includes('read')) {
|
||||
return 'read';
|
||||
}
|
||||
if (normalized.includes('write')) {
|
||||
return 'create';
|
||||
}
|
||||
if (normalized.includes('edit')) {
|
||||
return 'modify';
|
||||
}
|
||||
if (normalized.includes('delete') || normalized.includes('remove')) {
|
||||
return 'delete';
|
||||
}
|
||||
if (normalized.includes('move') || normalized.includes('rename')) {
|
||||
return 'move';
|
||||
}
|
||||
return 'touch';
|
||||
}
|
||||
|
||||
function actionForFileKey(toolName, key) {
|
||||
if (key === 'source_path' || key === 'old_file_path') {
|
||||
return 'move';
|
||||
}
|
||||
if (key === 'destination_path' || key === 'new_file_path') {
|
||||
return 'move';
|
||||
}
|
||||
return inferDefaultFileAction(toolName);
|
||||
}
|
||||
|
||||
function collectFilePaths(value, paths) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
collectFilePaths(entry, paths);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
pushPathCandidate(paths, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (FILE_PATH_KEYS.has(key)) {
|
||||
collectFilePaths(nested, paths);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
|
||||
collectFilePaths(nested, paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractFilePaths(toolInput) {
|
||||
const paths = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') {
|
||||
return paths;
|
||||
}
|
||||
collectFilePaths(toolInput, paths);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function fileEventDiffPreview(toolName, value, action) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
|
||||
return buildReplacementPreview(value.old_string, value.new_string);
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
return buildCreationPreview(value.content || value.file_text || value.text);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fileEventPatchPreview(value, action) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
|
||||
return buildPatchPreviewFromReplacement(value.old_string, value.new_string);
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
return buildPatchPreviewFromContent(value.content || value.file_text || value.text, '+');
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
return buildPatchPreviewFromContent(value.content || value.old_string || value.file_text, '-');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runGit(args, cwd) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
timeout: 2500,
|
||||
});
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(result.stdout || '').trim();
|
||||
}
|
||||
|
||||
function gitRepoRoot(cwd) {
|
||||
return runGit(['rev-parse', '--show-toplevel'], cwd);
|
||||
}
|
||||
|
||||
function repoRelativePath(repoRoot, filePath) {
|
||||
const absolute = path.isAbsolute(filePath)
|
||||
? path.resolve(filePath)
|
||||
: path.resolve(process.cwd(), filePath);
|
||||
const relative = path.relative(repoRoot, absolute);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
return relative.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function patchPreviewFromGitDiff(repoRoot, repoRelative) {
|
||||
const patch = runGit(
|
||||
['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', repoRelative],
|
||||
repoRoot
|
||||
);
|
||||
if (!patch) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const relevant = patch
|
||||
.split(/\r?\n/)
|
||||
.filter(line =>
|
||||
line.startsWith('@@')
|
||||
|| (line.startsWith('+') && !line.startsWith('+++'))
|
||||
|| (line.startsWith('-') && !line.startsWith('---'))
|
||||
)
|
||||
.slice(0, 6);
|
||||
|
||||
if (relevant.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return relevant.join('\n');
|
||||
}
|
||||
|
||||
function trackedInGit(repoRoot, repoRelative) {
|
||||
return runGit(['ls-files', '--error-unmatch', '--', repoRelative], repoRoot) !== null;
|
||||
}
|
||||
|
||||
function enrichFileEventFromWorkingTree(toolName, event) {
|
||||
if (!event || typeof event !== 'object' || !event.path) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const repoRoot = gitRepoRoot(process.cwd());
|
||||
if (!repoRoot) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const repoRelative = repoRelativePath(repoRoot, event.path);
|
||||
if (!repoRelative) {
|
||||
return event;
|
||||
}
|
||||
|
||||
const tool = String(toolName || '').trim().toLowerCase();
|
||||
const tracked = trackedInGit(repoRoot, repoRelative);
|
||||
const patchPreview = patchPreviewFromGitDiff(repoRoot, repoRelative) || event.patch_preview;
|
||||
const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview;
|
||||
|
||||
if (tool.includes('write')) {
|
||||
return {
|
||||
...event,
|
||||
action: tracked ? 'modify' : event.action,
|
||||
diff_preview: diffPreview,
|
||||
patch_preview: patchPreview,
|
||||
};
|
||||
}
|
||||
|
||||
if (tracked && patchPreview) {
|
||||
return {
|
||||
...event,
|
||||
diff_preview: diffPreview,
|
||||
patch_preview: patchPreview,
|
||||
};
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
function collectFileEvents(toolName, value, events, key = null, parentValue = null) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
collectFileEvents(toolName, entry, events, key, parentValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (key && FILE_PATH_KEYS.has(key)) {
|
||||
const action = actionForFileKey(toolName, key);
|
||||
pushFileEvent(
|
||||
events,
|
||||
value,
|
||||
action,
|
||||
fileEventDiffPreview(toolName, parentValue, action),
|
||||
fileEventPatchPreview(parentValue, action)
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [nestedKey, nested] of Object.entries(value)) {
|
||||
if (FILE_PATH_KEYS.has(nestedKey)) {
|
||||
collectFileEvents(toolName, nested, events, nestedKey, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
|
||||
collectFileEvents(toolName, nested, events, null, nested);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractFileEvents(toolName, toolInput) {
|
||||
const events = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') {
|
||||
return events;
|
||||
}
|
||||
collectFileEvents(toolName, toolInput, events);
|
||||
return events;
|
||||
}
|
||||
|
||||
function summarizeInput(toolName, toolInput, filePaths) {
|
||||
if (toolName === 'Bash') {
|
||||
return truncateSummary(toolInput?.command || 'bash');
|
||||
}
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
return truncateSummary(`${toolName} ${filePaths.join(', ')}`);
|
||||
}
|
||||
|
||||
if (toolInput && typeof toolInput === 'object') {
|
||||
const shallow = {};
|
||||
for (const [key, value] of Object.entries(toolInput)) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
shallow[key] = value;
|
||||
}
|
||||
}
|
||||
const serialized = Object.keys(shallow).length > 0 ? JSON.stringify(shallow) : toolName;
|
||||
return truncateSummary(serialized);
|
||||
}
|
||||
|
||||
return truncateSummary(toolName);
|
||||
}
|
||||
|
||||
function summarizeOutput(toolOutput) {
|
||||
if (toolOutput == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof toolOutput === 'string') {
|
||||
return truncateSummary(toolOutput);
|
||||
}
|
||||
|
||||
if (typeof toolOutput === 'object' && typeof toolOutput.output === 'string') {
|
||||
return truncateSummary(toolOutput.output);
|
||||
}
|
||||
|
||||
return truncateSummary(JSON.stringify(toolOutput));
|
||||
}
|
||||
|
||||
function buildActivityRow(input, env = process.env) {
|
||||
const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim();
|
||||
if (hookEvent && hookEvent !== 'PostToolUse') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolName = String(input?.tool_name || '').trim();
|
||||
const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim();
|
||||
if (!toolName || !sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolInput = input?.tool_input || {};
|
||||
const fileEvents = extractFileEvents(toolName, toolInput).map(event =>
|
||||
enrichFileEventFromWorkingTree(toolName, event)
|
||||
);
|
||||
const filePaths = fileEvents.length > 0
|
||||
? [...new Set(fileEvents.map(event => event.path))]
|
||||
: extractFilePaths(toolInput);
|
||||
|
||||
return {
|
||||
id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: sessionId,
|
||||
tool_name: toolName,
|
||||
input_summary: summarizeInput(toolName, toolInput, filePaths),
|
||||
input_params_json: sanitizeInputParams(toolInput),
|
||||
output_summary: summarizeOutput(input?.tool_output),
|
||||
duration_ms: 0,
|
||||
file_paths: filePaths,
|
||||
file_events: fileEvents,
|
||||
};
|
||||
}
|
||||
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
const row = buildActivityRow(input);
|
||||
if (row) {
|
||||
appendFile(
|
||||
path.join(getClaudeDir(), 'metrics', METRICS_FILE_NAME),
|
||||
`${JSON.stringify(row)}\n`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Keep hook non-blocking.
|
||||
}
|
||||
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
function main() {
|
||||
let raw = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
const remaining = MAX_STDIN - raw.length;
|
||||
raw += chunk.substring(0, remaining);
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(raw));
|
||||
});
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildActivityRow,
|
||||
extractFileEvents,
|
||||
extractFilePaths,
|
||||
summarizeInput,
|
||||
summarizeOutput,
|
||||
run,
|
||||
};
|
||||
@@ -131,6 +131,27 @@ function runTests() {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 6. Prefers ECC_SESSION_ID for ECC2 session correlation
|
||||
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID when both are present', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
usage: { input_tokens: 120, output_tokens: 30 },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
ECC_SESSION_ID: 'ecc-session-1234',
|
||||
CLAUDE_SESSION_ID: 'claude-session-9999',
|
||||
});
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
|
||||
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
|
||||
assert.strictEqual(row.session_id, 'ecc-session-1234', 'Expected ECC_SESSION_ID to win');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
360
tests/hooks/session-activity-tracker.test.js
Normal file
360
tests/hooks/session-activity-tracker.test.js
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Tests for session-activity-tracker.js hook.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const script = path.join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'scripts',
|
||||
'hooks',
|
||||
'session-activity-tracker.js'
|
||||
);
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-test-'));
|
||||
}
|
||||
|
||||
function withTempHome(homeDir) {
|
||||
return {
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
};
|
||||
}
|
||||
|
||||
function runScript(input, envOverrides = {}, options = {}) {
|
||||
const inputStr = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync('node', [script], {
|
||||
encoding: 'utf8',
|
||||
input: inputStr,
|
||||
timeout: 10000,
|
||||
env: { ...process.env, ...envOverrides },
|
||||
cwd: options.cwd,
|
||||
});
|
||||
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing session-activity-tracker.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
(test('passes through input on stdout', () => {
|
||||
const input = {
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: 'README.md' },
|
||||
tool_output: { output: 'ok' },
|
||||
};
|
||||
const inputStr = JSON.stringify(input);
|
||||
const result = runScript(input, {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'sess-123',
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.strictEqual(result.stdout, inputStr);
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('creates tool activity metrics rows with file paths', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: 'src/app.rs',
|
||||
},
|
||||
tool_output: { output: 'wrote src/app.rs' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-1234',
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
|
||||
assert.ok(fs.existsSync(metricsFile), `Expected metrics file at ${metricsFile}`);
|
||||
|
||||
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
|
||||
assert.strictEqual(row.session_id, 'ecc-session-1234');
|
||||
assert.strictEqual(row.tool_name, 'Write');
|
||||
assert.strictEqual(row.input_params_json, '{"file_path":"src/app.rs"}');
|
||||
assert.deepStrictEqual(row.file_paths, ['src/app.rs']);
|
||||
assert.deepStrictEqual(row.file_events, [{ path: 'src/app.rs', action: 'create' }]);
|
||||
assert.ok(row.id, 'Expected stable event id');
|
||||
assert.ok(row.timestamp, 'Expected timestamp');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('captures typed move file events from source/destination inputs', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
tool_name: 'Move',
|
||||
tool_input: {
|
||||
source_path: 'src/old.rs',
|
||||
destination_path: 'src/new.rs',
|
||||
},
|
||||
tool_output: { output: 'moved file' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-5678',
|
||||
});
|
||||
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_paths, ['src/old.rs', 'src/new.rs']);
|
||||
assert.deepStrictEqual(row.file_events, [
|
||||
{ path: 'src/old.rs', action: 'move' },
|
||||
{ path: 'src/new.rs', action: 'move' },
|
||||
]);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('captures replacement diff previews for edit tool input', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: {
|
||||
file_path: 'src/config.ts',
|
||||
old_string: 'API_URL=http://localhost:3000',
|
||||
new_string: 'API_URL=https://api.example.com',
|
||||
},
|
||||
tool_output: { output: 'updated config' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-edit',
|
||||
});
|
||||
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/config.ts',
|
||||
action: 'modify',
|
||||
diff_preview: 'API_URL=http://localhost:3000 -> API_URL=https://api.example.com',
|
||||
patch_preview: '@@\n- API_URL=http://localhost:3000\n+ API_URL=https://api.example.com',
|
||||
},
|
||||
]);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('captures MultiEdit nested edits with typed diff previews', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: {
|
||||
edits: [
|
||||
{
|
||||
file_path: 'src/a.ts',
|
||||
old_string: 'const a = 1;',
|
||||
new_string: 'const a = 2;',
|
||||
},
|
||||
{
|
||||
file_path: 'src/b.ts',
|
||||
old_string: 'old name',
|
||||
new_string: 'new name',
|
||||
},
|
||||
],
|
||||
},
|
||||
tool_output: { output: 'updated two files' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-multiedit',
|
||||
});
|
||||
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_paths, ['src/a.ts', 'src/b.ts']);
|
||||
assert.deepStrictEqual(row.file_events, [
|
||||
{
|
||||
path: 'src/a.ts',
|
||||
action: 'modify',
|
||||
diff_preview: 'const a = 1; -> const a = 2;',
|
||||
patch_preview: '@@\n- const a = 1;\n+ const a = 2;',
|
||||
},
|
||||
{
|
||||
path: 'src/b.ts',
|
||||
action: 'modify',
|
||||
diff_preview: 'old name -> new name',
|
||||
patch_preview: '@@\n- old name\n+ new name',
|
||||
},
|
||||
]);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('reclassifies tracked Write activity as modify using git diff context', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-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, 'app.ts');
|
||||
fs.writeFileSync(trackedFile, 'const count = 1;\n', 'utf8');
|
||||
spawnSync('git', ['add', 'src/app.ts'], { cwd: repoDir, encoding: 'utf8' });
|
||||
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
|
||||
|
||||
fs.writeFileSync(trackedFile, 'const count = 2;\n', 'utf8');
|
||||
|
||||
const input = {
|
||||
tool_name: 'Write',
|
||||
tool_input: {
|
||||
file_path: 'src/app.ts',
|
||||
content: 'const count = 2;\n',
|
||||
},
|
||||
tool_output: { output: 'updated src/app.ts' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-write-modify',
|
||||
}, {
|
||||
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/app.ts',
|
||||
action: 'modify',
|
||||
diff_preview: 'const count = 1; -> const count = 2;',
|
||||
patch_preview: '@@ -1 +1 @@\n-const count = 1;\n+const count = 2;',
|
||||
},
|
||||
]);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
}) ? 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', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const input = {
|
||||
tool_name: 'Bash',
|
||||
tool_input: {
|
||||
command: 'curl --token abc123 -H "Authorization: Bearer topsecret" https://example.com',
|
||||
},
|
||||
tool_output: { output: 'done' },
|
||||
};
|
||||
const result = runScript(input, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'ecc-session-1',
|
||||
CLAUDE_SESSION_ID: 'claude-session-2',
|
||||
});
|
||||
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.strictEqual(row.session_id, 'ecc-session-1');
|
||||
assert.ok(row.input_summary.includes('<REDACTED>'));
|
||||
assert.ok(!row.input_summary.includes('abc123'));
|
||||
assert.ok(!row.input_summary.includes('topsecret'));
|
||||
assert.ok(row.input_params_json.includes('<REDACTED>'));
|
||||
assert.ok(!row.input_params_json.includes('abc123'));
|
||||
assert.ok(!row.input_params_json.includes('topsecret'));
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('handles invalid JSON gracefully', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const invalidInput = 'not valid json {{{';
|
||||
const result = runScript(invalidInput, {
|
||||
...withTempHome(tmpHome),
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
|
||||
ECC_SESSION_ID: 'sess-123',
|
||||
});
|
||||
assert.strictEqual(result.code, 0);
|
||||
assert.strictEqual(result.stdout, invalidInput);
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
runTests();
|
||||
Reference in New Issue
Block a user