41 Commits

Author SHA1 Message Date
Affaan Mustafa
0513898b9d feat: add otel export for ecc sessions 2026-04-09 09:02:39 -07:00
Affaan Mustafa
2048f0d6f5 feat: add word diff highlighting to tui diffs 2026-04-09 08:55:53 -07:00
Affaan Mustafa
f5437078e1 feat: add diff view modes and hunk navigation 2026-04-09 08:41:10 -07:00
Affaan Mustafa
13f99cbf1c feat: add worktree retention cleanup policy 2026-04-09 08:29:21 -07:00
Affaan Mustafa
491f213fbd feat: enforce queued parallel worktree limits 2026-04-09 08:23:01 -07:00
Affaan Mustafa
941d4e6172 feat(ecc2): enforce configurable worktree branch prefixes 2026-04-09 08:08:42 -07:00
Affaan Mustafa
b01a300c31 feat(ecc2): persist tool log params and trigger context 2026-04-09 08:04:18 -07:00
Affaan Mustafa
f28f55c41e feat(ecc2): surface overlapping file activity 2026-04-09 07:54:27 -07:00
Affaan Mustafa
31f672275e feat(ecc2): infer tracked write modifications 2026-04-09 07:48:29 -07:00
Affaan Mustafa
eee9768cd8 feat(ecc2): persist file activity patch previews 2026-04-09 07:45:37 -07:00
Affaan Mustafa
c395b42d2c feat(ecc2): persist file activity diff previews 2026-04-09 07:40:28 -07:00
Affaan Mustafa
edd027edd4 feat(ecc2): classify typed file activity 2026-04-09 07:33:42 -07:00
Affaan Mustafa
a0f69cec92 feat(ecc2): surface per-file session activity 2026-04-09 07:27:17 -07:00
Affaan Mustafa
24a3ffa234 feat(ecc2): add session heartbeat stale detection 2026-04-09 07:20:40 -07:00
Affaan Mustafa
48fd68115e feat(ecc2): sync hook activity into session metrics 2026-04-09 07:02:24 -07:00
Affaan Mustafa
6f08e78456 feat: auto-pause ecc2 sessions when budgets are exceeded 2026-04-09 06:47:28 -07:00
Affaan Mustafa
67d06687a0 feat: add ecc2 configurable budget thresholds 2026-04-09 06:36:22 -07:00
Affaan Mustafa
95c33d3c04 feat: add ecc2 budget alert thresholds 2026-04-09 06:31:54 -07:00
Affaan Mustafa
08f61f667d feat: sync ecc2 cost tracker metrics 2026-04-09 06:22:20 -07:00
Affaan Mustafa
cf9c68846c feat: add ecc2 ctrl-w pane commands 2026-04-09 06:08:59 -07:00
Affaan Mustafa
a54799127c feat: make ecc2 pane navigation shortcuts configurable 2026-04-09 06:05:27 -07:00
Affaan Mustafa
c6e26ddea4 feat: surface ecc2 tool and file metrics in sessions pane 2026-04-09 05:58:54 -07:00
Affaan Mustafa
f136a4e0d6 feat: add ecc2 direct pane focus shortcuts 2026-04-09 05:53:55 -07:00
Affaan Mustafa
3c16c85a75 feat: add ecc2 global timeline scope 2026-04-09 05:48:58 -07:00
Affaan Mustafa
0c509fe57e feat: add ecc2 session timeline mode 2026-04-09 05:43:34 -07:00
Affaan Mustafa
996edff6d1 feat: collapse ecc2 detail panes 2026-04-09 05:34:36 -07:00
Affaan Mustafa
f2cfaee6fe feat: jump ecc2 approval queue targets 2026-04-09 05:27:43 -07:00
Affaan Mustafa
dc36a636af feat: navigate delegates from ecc2 lead board 2026-04-09 05:21:02 -07:00
Affaan Mustafa
6fc3f7c3f4 feat: scroll ecc2 metrics across full teams 2026-04-09 05:10:40 -07:00
Affaan Mustafa
f29e70883c feat: add ecc2 delegate blocker hints 2026-04-09 05:05:53 -07:00
Affaan Mustafa
e50c97c29b feat: add ecc2 delegate progress signals 2026-04-09 04:59:45 -07:00
Affaan Mustafa
7e3bb3aec2 feat: add ecc2 delegate activity board 2026-04-09 04:56:26 -07:00
Affaan Mustafa
92c9d1f2c9 feat: keep ecc2 lead selected after multi-spawn 2026-04-09 04:52:36 -07:00
Affaan Mustafa
669d9cc790 feat: auto-split ecc2 after multi-agent spawn 2026-04-09 04:48:46 -07:00
Affaan Mustafa
1c27f7b29a feat: add ecc2 approval queue sidebar 2026-04-09 04:42:13 -07:00
Affaan Mustafa
cc5fe121bf feat: add ecc2 natural-language session spawner 2026-04-09 04:33:17 -07:00
Affaan Mustafa
15e05d96ad feat: add ecc2 output content filters 2026-04-09 04:26:06 -07:00
Affaan Mustafa
bab03bd8af feat: add ecc2 agent output filters 2026-04-09 04:21:23 -07:00
Affaan Mustafa
1755069df2 feat: add ecc2 global output search 2026-04-09 04:17:03 -07:00
Affaan Mustafa
3b700c8715 feat: add ecc2 output time filters 2026-04-09 04:10:51 -07:00
Affaan Mustafa
077f46b777 feat: add ecc2 stderr output filter 2026-04-09 04:04:25 -07:00
18 changed files with 10168 additions and 448 deletions

View File

@@ -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);

View File

@@ -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]

View File

@@ -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,
&timestamp,
@@ -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);

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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());
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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(

View File

@@ -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": [

View File

@@ -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);

View 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,
};

View File

@@ -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);
}

View 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();