feat: add ecc2 completion summary notifications

This commit is contained in:
Affaan Mustafa
2026-04-09 20:59:24 -07:00
parent a4d0a4fc14
commit b45a6ca810
7 changed files with 828 additions and 70 deletions

View File

@@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
use crate::notifications::DesktopNotificationConfig; use crate::notifications::{CompletionSummaryConfig, DesktopNotificationConfig};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -48,6 +48,7 @@ pub struct Config {
pub auto_create_worktrees: bool, pub auto_create_worktrees: bool,
pub auto_merge_ready_worktrees: bool, pub auto_merge_ready_worktrees: bool,
pub desktop_notifications: DesktopNotificationConfig, pub desktop_notifications: DesktopNotificationConfig,
pub completion_summary_notifications: CompletionSummaryConfig,
pub cost_budget_usd: f64, pub cost_budget_usd: f64,
pub token_budget: u64, pub token_budget: u64,
pub budget_alert_thresholds: BudgetAlertThresholds, pub budget_alert_thresholds: BudgetAlertThresholds,
@@ -111,6 +112,7 @@ impl Default for Config {
auto_create_worktrees: true, auto_create_worktrees: true,
auto_merge_ready_worktrees: false, auto_merge_ready_worktrees: false,
desktop_notifications: DesktopNotificationConfig::default(), desktop_notifications: DesktopNotificationConfig::default(),
completion_summary_notifications: CompletionSummaryConfig::default(),
cost_budget_usd: 10.0, cost_budget_usd: 10.0,
token_budget: 500_000, token_budget: 500_000,
budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS, budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS,
@@ -616,6 +618,24 @@ end_hour = 7
assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7); assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7);
} }
#[test]
fn completion_summary_notifications_deserialize_from_toml() {
let config: Config = toml::from_str(
r#"
[completion_summary_notifications]
enabled = true
delivery = "desktop_and_tui_popup"
"#,
)
.unwrap();
assert!(config.completion_summary_notifications.enabled);
assert_eq!(
config.completion_summary_notifications.delivery,
crate::notifications::CompletionSummaryDelivery::DesktopAndTuiPopup
);
}
#[test] #[test]
fn invalid_budget_alert_thresholds_fall_back_to_defaults() { fn invalid_budget_alert_thresholds_fall_back_to_defaults() {
let config: Config = toml::from_str( let config: Config = toml::from_str(
@@ -643,6 +663,8 @@ critical = 1.10
config.auto_create_worktrees = false; config.auto_create_worktrees = false;
config.auto_merge_ready_worktrees = true; config.auto_merge_ready_worktrees = true;
config.desktop_notifications.session_completed = false; config.desktop_notifications.session_completed = false;
config.completion_summary_notifications.delivery =
crate::notifications::CompletionSummaryDelivery::TuiPopup;
config.desktop_notifications.quiet_hours.enabled = true; config.desktop_notifications.quiet_hours.enabled = true;
config.desktop_notifications.quiet_hours.start_hour = 21; config.desktop_notifications.quiet_hours.start_hour = 21;
config.desktop_notifications.quiet_hours.end_hour = 7; config.desktop_notifications.quiet_hours.end_hour = 7;
@@ -666,6 +688,10 @@ critical = 1.10
assert!(!loaded.auto_create_worktrees); assert!(!loaded.auto_create_worktrees);
assert!(loaded.auto_merge_ready_worktrees); assert!(loaded.auto_merge_ready_worktrees);
assert!(!loaded.desktop_notifications.session_completed); assert!(!loaded.desktop_notifications.session_completed);
assert_eq!(
loaded.completion_summary_notifications.delivery,
crate::notifications::CompletionSummaryDelivery::TuiPopup
);
assert!(loaded.desktop_notifications.quiet_hours.enabled); assert!(loaded.desktop_notifications.quiet_hours.enabled);
assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21); assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21);
assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7); assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7);

View File

@@ -1634,7 +1634,11 @@ fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> Stri
for entry in &report.blocked_entries { for entry in &report.blocked_entries {
lines.push(format!( lines.push(format!(
"- {} [{}] | {} / {} | {}", "- {} [{}] | {} / {} | {}",
entry.session_id, entry.branch, entry.project, entry.task_group, entry.suggested_action entry.session_id,
entry.branch,
entry.project,
entry.task_group,
entry.suggested_action
)); ));
for blocker in entry.blocked_by.iter().take(2) { for blocker in entry.blocked_by.iter().take(2) {
lines.push(format!( lines.push(format!(
@@ -2781,7 +2785,9 @@ mod tests {
state: session::SessionState::Stopped, state: session::SessionState::Stopped,
conflicts: vec!["README.md".to_string()], conflicts: vec!["README.md".to_string()],
summary: "merge after alpha1234 to avoid branch conflicts".to_string(), summary: "merge after alpha1234 to avoid branch conflicts".to_string(),
conflicting_patch_preview: Some("--- Branch diff vs main ---\nREADME.md".to_string()), conflicting_patch_preview: Some(
"--- Branch diff vs main ---\nREADME.md".to_string(),
),
blocker_patch_preview: None, blocker_patch_preview: None,
}], }],
suggested_action: "merge after alpha1234".to_string(), suggested_action: "merge after alpha1234".to_string(),

View File

@@ -32,6 +32,22 @@ pub struct DesktopNotificationConfig {
pub quiet_hours: QuietHoursConfig, pub quiet_hours: QuietHoursConfig,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompletionSummaryDelivery {
#[default]
Desktop,
TuiPopup,
DesktopAndTuiPopup,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct CompletionSummaryConfig {
pub enabled: bool,
pub delivery: CompletionSummaryDelivery,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DesktopNotifier { pub struct DesktopNotifier {
config: DesktopNotificationConfig, config: DesktopNotificationConfig,
@@ -112,6 +128,33 @@ impl DesktopNotificationConfig {
} }
} }
impl Default for CompletionSummaryConfig {
fn default() -> Self {
Self {
enabled: true,
delivery: CompletionSummaryDelivery::Desktop,
}
}
}
impl CompletionSummaryConfig {
pub fn desktop_enabled(&self) -> bool {
self.enabled
&& matches!(
self.delivery,
CompletionSummaryDelivery::Desktop | CompletionSummaryDelivery::DesktopAndTuiPopup
)
}
pub fn popup_enabled(&self) -> bool {
self.enabled
&& matches!(
self.delivery,
CompletionSummaryDelivery::TuiPopup | CompletionSummaryDelivery::DesktopAndTuiPopup
)
}
}
impl DesktopNotifier { impl DesktopNotifier {
pub fn new(config: DesktopNotificationConfig) -> Self { pub fn new(config: DesktopNotificationConfig) -> Self {
Self { Self {

View File

@@ -46,7 +46,16 @@ pub async fn create_session_with_grouping(
) -> Result<String> { ) -> Result<String> {
let repo_root = let repo_root =
std::env::current_dir().context("Failed to resolve current working directory")?; std::env::current_dir().context("Failed to resolve current working directory")?;
queue_session_in_dir(db, cfg, task, agent_type, use_worktree, &repo_root, grouping).await queue_session_in_dir(
db,
cfg,
task,
agent_type,
use_worktree,
&repo_root,
grouping,
)
.await
} }
pub fn list_sessions(db: &StateStore) -> Result<Vec<Session>> { pub fn list_sessions(db: &StateStore) -> Result<Vec<Session>> {
@@ -219,7 +228,7 @@ pub async fn drain_inbox(
use_worktree, use_worktree,
&repo_root, &repo_root,
&runner_program, &runner_program,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@@ -1037,7 +1046,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
if matches!( if matches!(
session.state, session.state,
SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale SessionState::Pending
| SessionState::Running
| SessionState::Idle
| SessionState::Stale
) { ) {
blocked_by.push(MergeQueueBlocker { blocked_by.push(MergeQueueBlocker {
session_id: session.id.clone(), session_id: session.id.clone(),
@@ -1085,10 +1097,7 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
branch: blocker_worktree.branch.clone(), branch: blocker_worktree.branch.clone(),
state: blocker.state.clone(), state: blocker.state.clone(),
conflicts: conflict.conflicts, conflicts: conflict.conflicts,
summary: format!( summary: format!("merge after {} to avoid branch conflicts", blocker.id),
"merge after {} to avoid branch conflicts",
blocker.id
),
conflicting_patch_preview: conflict.right_patch_preview, conflicting_patch_preview: conflict.right_patch_preview,
blocker_patch_preview: conflict.left_patch_preview, blocker_patch_preview: conflict.left_patch_preview,
}); });
@@ -1107,7 +1116,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
let suggested_action = if let Some(position) = queue_position { let suggested_action = if let Some(position) = queue_position {
format!("merge in queue order #{position}") format!("merge in queue order #{position}")
} else if blocked_by.iter().any(|blocker| blocker.session_id == session.id) { } else if blocked_by
.iter()
.any(|blocker| blocker.session_id == session.id)
{
blocked_by blocked_by
.first() .first()
.map(|blocker| blocker.summary.clone()) .map(|blocker| blocker.summary.clone())
@@ -1369,15 +1381,8 @@ async fn queue_session_in_dir_with_runner_program(
runner_program: &Path, runner_program: &Path,
grouping: SessionGrouping, grouping: SessionGrouping,
) -> Result<String> { ) -> Result<String> {
let session = build_session_record( let session =
db, build_session_record(db, task, agent_type, use_worktree, cfg, repo_root, grouping)?;
task,
agent_type,
use_worktree,
cfg,
repo_root,
grouping,
)?;
db.insert_session(&session)?; db.insert_session(&session)?;
if use_worktree && session.worktree.is_none() { if use_worktree && session.worktree.is_none() {
@@ -1523,7 +1528,10 @@ fn attached_worktree_count(db: &StateStore) -> Result<usize> {
fn merge_queue_priority(session: &Session) -> (u8, chrono::DateTime<chrono::Utc>) { fn merge_queue_priority(session: &Session) -> (u8, chrono::DateTime<chrono::Utc>) {
let active_rank = match session.state { let active_rank = match session.state {
SessionState::Completed | SessionState::Failed | SessionState::Stopped => 0, SessionState::Completed | SessionState::Failed | SessionState::Stopped => 0,
SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale => 1, SessionState::Pending
| SessionState::Running
| SessionState::Idle
| SessionState::Stale => 1,
}; };
(active_rank, session.updated_at) (active_rank, session.updated_at)
} }
@@ -2238,6 +2246,8 @@ mod tests {
auto_create_worktrees: true, auto_create_worktrees: true,
auto_merge_ready_worktrees: false, auto_merge_ready_worktrees: false,
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
completion_summary_notifications:
crate::notifications::CompletionSummaryConfig::default(),
cost_budget_usd: 10.0, cost_budget_usd: 10.0,
token_budget: 500_000, token_budget: 500_000,
budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS, budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS,
@@ -3534,7 +3544,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@@ -3607,7 +3617,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@@ -3820,7 +3830,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;
@@ -3893,7 +3903,7 @@ mod tests {
true, true,
&repo_root, &repo_root,
&fake_runner, &fake_runner,
SessionGrouping::default(), SessionGrouping::default(),
) )
.await?; .await?;

View File

@@ -27,6 +27,18 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
if event::poll(Duration::from_millis(250))? { if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
if dashboard.has_active_completion_popup() {
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
(_, KeyCode::Esc) | (_, KeyCode::Enter) | (_, KeyCode::Char(' ')) => {
dashboard.dismiss_completion_popup();
}
_ => {}
}
continue;
}
if dashboard.is_input_mode() { if dashboard.is_input_mode() {
match (key.modifiers, key.code) { match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break, (KeyModifiers::CONTROL, KeyCode::Char('c')) => break,

View File

@@ -3,11 +3,12 @@ use crossterm::event::KeyEvent;
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
widgets::{ widgets::{
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, Wrap, Block, Borders, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs,
Wrap,
}, },
}; };
use regex::Regex; use regex::Regex;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet, VecDeque};
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -52,6 +53,28 @@ struct ThemePalette {
help_border: Color, help_border: Color,
} }
#[derive(Debug, Clone)]
struct SessionCompletionSummary {
session_id: String,
task: String,
state: SessionState,
files_changed: u32,
tokens_used: u64,
duration_secs: u64,
cost_usd: f64,
tests_run: usize,
tests_passed: usize,
recent_files: Vec<String>,
key_decisions: Vec<String>,
warnings: Vec<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct TestRunSummary {
total: usize,
passed: usize,
}
pub struct Dashboard { pub struct Dashboard {
db: StateStore, db: StateStore,
cfg: Config, cfg: Config,
@@ -112,6 +135,8 @@ pub struct Dashboard {
search_agent_filter: SearchAgentFilter, search_agent_filter: SearchAgentFilter,
search_matches: Vec<SearchMatch>, search_matches: Vec<SearchMatch>,
selected_search_match: usize, selected_search_match: usize,
active_completion_popup: Option<SessionCompletionSummary>,
queued_completion_popups: VecDeque<SessionCompletionSummary>,
session_table_state: TableState, session_table_state: TableState,
last_cost_metrics_signature: Option<(u64, u128)>, last_cost_metrics_signature: Option<(u64, u128)>,
last_tool_activity_signature: Option<(u64, u128)>, last_tool_activity_signature: Option<(u64, u128)>,
@@ -296,6 +321,108 @@ struct TeamSummary {
stopped: usize, stopped: usize,
} }
impl SessionCompletionSummary {
fn title(&self) -> String {
match self.state {
SessionState::Completed => "ECC 2.0: Session completed".to_string(),
SessionState::Failed => "ECC 2.0: Session failed".to_string(),
_ => "ECC 2.0: Session summary".to_string(),
}
}
fn subtitle(&self) -> String {
format!(
"{} | {}",
format_session_id(&self.session_id),
truncate_for_dashboard(&self.task, 88)
)
}
fn notification_body(&self) -> String {
let tests_line = if self.tests_run > 0 {
format!(
"Tests {} run / {} passed",
self.tests_run, self.tests_passed
)
} else {
"Tests not detected".to_string()
};
let warnings_line = if self.warnings.is_empty() {
"Warnings none".to_string()
} else {
format!(
"Warnings {}",
truncate_for_dashboard(&self.warnings.join("; "), 88)
)
};
[
self.subtitle(),
format!(
"Files {} | Tokens {} | Duration {}",
self.files_changed,
format_token_count(self.tokens_used),
format_duration(self.duration_secs)
),
tests_line,
warnings_line,
]
.join("\n")
}
fn popup_text(&self) -> String {
let mut lines = vec![
self.subtitle(),
String::new(),
format!(
"Files {} | Tokens {} | Cost {} | Duration {}",
self.files_changed,
format_token_count(self.tokens_used),
format_currency(self.cost_usd),
format_duration(self.duration_secs)
),
];
if self.tests_run > 0 {
lines.push(format!(
"Tests {} run / {} passed",
self.tests_run, self.tests_passed
));
} else {
lines.push("Tests not detected".to_string());
}
if !self.recent_files.is_empty() {
lines.push(String::new());
lines.push("Recent files".to_string());
for item in &self.recent_files {
lines.push(format!("- {item}"));
}
}
if !self.key_decisions.is_empty() {
lines.push(String::new());
lines.push("Key decisions".to_string());
for item in &self.key_decisions {
lines.push(format!("- {item}"));
}
}
if !self.warnings.is_empty() {
lines.push(String::new());
lines.push("Warnings".to_string());
for item in &self.warnings {
lines.push(format!("- {item}"));
}
}
lines.push(String::new());
lines.push("[Enter]/[Space]/[Esc] dismiss".to_string());
lines.join("\n")
}
}
impl Dashboard { impl Dashboard {
pub fn new(db: StateStore, cfg: Config) -> Self { pub fn new(db: StateStore, cfg: Config) -> Self {
Self::with_output_store(db, cfg, SessionOutputStore::default()) Self::with_output_store(db, cfg, SessionOutputStore::default())
@@ -394,6 +521,8 @@ impl Dashboard {
search_agent_filter: SearchAgentFilter::AllAgents, search_agent_filter: SearchAgentFilter::AllAgents,
search_matches: Vec::new(), search_matches: Vec::new(),
selected_search_match: 0, selected_search_match: 0,
active_completion_popup: None,
queued_completion_popups: VecDeque::new(),
session_table_state, session_table_state,
last_cost_metrics_signature: initial_cost_metrics_signature, last_cost_metrics_signature: initial_cost_metrics_signature,
last_tool_activity_signature: initial_tool_activity_signature, last_tool_activity_signature: initial_tool_activity_signature,
@@ -403,6 +532,7 @@ impl Dashboard {
}; };
sort_sessions_for_display(&mut dashboard.sessions); sort_sessions_for_display(&mut dashboard.sessions);
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default();
dashboard.sync_approval_queue();
dashboard.sync_handoff_backlog_counts(); dashboard.sync_handoff_backlog_counts();
dashboard.sync_global_handoff_backlog(); dashboard.sync_global_handoff_backlog();
dashboard.sync_selected_output(); dashboard.sync_selected_output();
@@ -444,6 +574,10 @@ impl Dashboard {
} }
self.render_status_bar(frame, chunks[2]); self.render_status_bar(frame, chunks[2]);
if let Some(summary) = self.active_completion_popup.as_ref() {
self.render_completion_popup(frame, summary);
}
} }
fn render_header(&self, frame: &mut Frame, area: Rect) { fn render_header(&self, frame: &mut Frame, area: Rect) {
@@ -1045,7 +1179,9 @@ impl Dashboard {
self.theme_label() self.theme_label()
); );
let search_prefix = if let Some(input) = self.spawn_input.as_ref() { let search_prefix = if self.active_completion_popup.is_some() {
" completion summary | [Enter]/[Space]/[Esc] dismiss |".to_string()
} else if let Some(input) = self.spawn_input.as_ref() {
format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |") format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |")
} else if let Some(input) = self.commit_input.as_ref() { } else if let Some(input) = self.commit_input.as_ref() {
format!(" commit>{input}_ | [Enter] commit [Esc] cancel |") format!(" commit>{input}_ | [Enter] commit [Esc] cancel |")
@@ -1076,7 +1212,8 @@ impl Dashboard {
String::new() String::new()
}; };
let text = if self.spawn_input.is_some() let text = if self.active_completion_popup.is_some()
|| self.spawn_input.is_some()
|| self.commit_input.is_some() || self.commit_input.is_some()
|| self.pr_input.is_some() || self.pr_input.is_some()
|| self.search_input.is_some() || self.search_input.is_some()
@@ -1121,6 +1258,31 @@ impl Dashboard {
); );
} }
fn render_completion_popup(&self, frame: &mut Frame, summary: &SessionCompletionSummary) {
let popup_area = centered_rect(72, 65, frame.area());
if popup_area.is_empty() {
return;
}
frame.render_widget(Clear, popup_area);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", summary.title()))
.border_style(self.pane_border_style(Pane::Output));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
if inner.is_empty() {
return;
}
frame.render_widget(
Paragraph::new(summary.popup_text())
.wrap(Wrap { trim: true })
.scroll((0, 0)),
inner,
);
}
fn render_help(&self, frame: &mut Frame, area: Rect) { fn render_help(&self, frame: &mut Frame, area: Rect) {
let help = vec![ let help = vec![
"Keyboard Shortcuts:".to_string(), "Keyboard Shortcuts:".to_string(),
@@ -2697,6 +2859,16 @@ impl Dashboard {
self.search_query.is_some() self.search_query.is_some()
} }
pub fn has_active_completion_popup(&self) -> bool {
self.active_completion_popup.is_some()
}
pub fn dismiss_completion_popup(&mut self) {
if self.active_completion_popup.take().is_some() {
self.active_completion_popup = self.queued_completion_popups.pop_front();
}
}
pub fn begin_spawn_prompt(&mut self) { pub fn begin_spawn_prompt(&mut self) {
if self.search_input.is_some() { if self.search_input.is_some() {
self.set_operator_note( self.set_operator_note(
@@ -3401,10 +3573,11 @@ impl Dashboard {
HashMap::new() HashMap::new()
} }
}; };
self.sync_session_state_notifications(); self.sync_approval_queue();
self.sync_approval_notifications();
self.sync_handoff_backlog_counts(); self.sync_handoff_backlog_counts();
self.sync_worktree_health_by_session(); self.sync_worktree_health_by_session();
self.sync_session_state_notifications();
self.sync_approval_notifications();
self.sync_global_handoff_backlog(); self.sync_global_handoff_backlog();
self.sync_daemon_activity(); self.sync_daemon_activity();
self.sync_output_cache(); self.sync_output_cache();
@@ -3480,6 +3653,8 @@ impl Dashboard {
fn sync_session_state_notifications(&mut self) { fn sync_session_state_notifications(&mut self) {
let mut next_states = HashMap::new(); let mut next_states = HashMap::new();
let mut completion_summaries = Vec::new();
let mut failed_notifications = Vec::new();
for session in &self.sessions { for session in &self.sessions {
let previous_state = self.last_session_states.get(&session.id); let previous_state = self.last_session_states.get(&session.id);
@@ -3487,26 +3662,29 @@ impl Dashboard {
if previous_state != &session.state { if previous_state != &session.state {
match session.state { match session.state {
SessionState::Completed => { SessionState::Completed => {
self.notify_desktop( if self.cfg.completion_summary_notifications.enabled {
NotificationEvent::SessionCompleted, completion_summaries.push(self.build_completion_summary(session));
"ECC 2.0: Session completed", } else if self.cfg.desktop_notifications.session_completed {
&format!( self.notify_desktop(
"{} | {}", NotificationEvent::SessionCompleted,
format_session_id(&session.id), "ECC 2.0: Session completed",
truncate_for_dashboard(&session.task, 96) &format!(
), "{} | {}",
); format_session_id(&session.id),
truncate_for_dashboard(&session.task, 96)
),
);
}
} }
SessionState::Failed => { SessionState::Failed => {
self.notify_desktop( failed_notifications.push((
NotificationEvent::SessionFailed, "ECC 2.0: Session failed".to_string(),
"ECC 2.0: Session failed", format!(
&format!(
"{} | {}", "{} | {}",
format_session_id(&session.id), format_session_id(&session.id),
truncate_for_dashboard(&session.task, 96) truncate_for_dashboard(&session.task, 96)
), ),
); ));
} }
_ => {} _ => {}
} }
@@ -3516,6 +3694,16 @@ impl Dashboard {
next_states.insert(session.id.clone(), session.state.clone()); next_states.insert(session.id.clone(), session.state.clone());
} }
for summary in completion_summaries {
self.deliver_completion_summary(summary);
}
if self.cfg.desktop_notifications.session_failed {
for (title, body) in failed_notifications {
self.notify_desktop(NotificationEvent::SessionFailed, &title, &body);
}
}
self.last_session_states = next_states; self.last_session_states = next_states;
} }
@@ -3554,6 +3742,90 @@ impl Dashboard {
); );
} }
fn deliver_completion_summary(&mut self, summary: SessionCompletionSummary) {
if self.cfg.completion_summary_notifications.desktop_enabled()
&& self.cfg.desktop_notifications.session_completed
{
self.notify_desktop(
NotificationEvent::SessionCompleted,
&summary.title(),
&summary.notification_body(),
);
}
if self.cfg.completion_summary_notifications.popup_enabled() {
if self.active_completion_popup.is_none() {
self.active_completion_popup = Some(summary);
} else {
self.queued_completion_popups.push_back(summary);
}
}
}
fn build_completion_summary(&self, session: &Session) -> SessionCompletionSummary {
let file_activity = match self.db.list_file_activity(&session.id, 5) {
Ok(entries) => entries,
Err(error) => {
tracing::warn!(
"Failed to load file activity for completion summary {}: {error}",
session.id
);
Vec::new()
}
};
let tool_logs = match self.db.list_tool_logs_for_session(&session.id) {
Ok(entries) => entries,
Err(error) => {
tracing::warn!(
"Failed to load tool logs for completion summary {}: {error}",
session.id
);
Vec::new()
}
};
let overlaps = match self.db.list_file_overlaps(&session.id, 3) {
Ok(entries) => entries,
Err(error) => {
tracing::warn!(
"Failed to load file overlaps for completion summary {}: {error}",
session.id
);
Vec::new()
}
};
let tests = summarize_test_runs(&tool_logs, session.state == SessionState::Completed);
let recent_files = recent_completion_files(&file_activity, session.metrics.files_changed);
let key_decisions =
summarize_completion_decisions(&tool_logs, &file_activity, &session.task);
let warnings = summarize_completion_warnings(
session,
&tool_logs,
&tests,
self.worktree_health_by_session.get(&session.id),
self.approval_queue_counts
.get(&session.id)
.copied()
.unwrap_or(0),
overlaps.len(),
);
SessionCompletionSummary {
session_id: session.id.clone(),
task: session.task.clone(),
state: session.state.clone(),
files_changed: session.metrics.files_changed,
tokens_used: session.metrics.tokens_used,
duration_secs: session.metrics.duration_secs,
cost_usd: session.metrics.cost_usd,
tests_run: tests.total,
tests_passed: tests.passed,
recent_files,
key_decisions,
warnings,
}
}
fn notify_desktop(&self, event: NotificationEvent, title: &str, body: &str) { fn notify_desktop(&self, event: NotificationEvent, title: &str, body: &str) {
let _ = self.notifier.notify(event, title, body); let _ = self.notifier.notify(event, title, body);
} }
@@ -6743,6 +7015,254 @@ fn tool_log_detail_lines(entry: &ToolLogEntry) -> Vec<String> {
lines lines
} }
fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - height_percent) / 2),
Constraint::Percentage(height_percent),
Constraint::Percentage((100 - height_percent) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}
fn summarize_test_runs(
tool_logs: &[ToolLogEntry],
assume_success_on_completion: bool,
) -> TestRunSummary {
let mut summary = TestRunSummary::default();
for entry in tool_logs {
if !tool_log_looks_like_test(entry) {
continue;
}
summary.total += 1;
let failed = tool_log_looks_failed(entry);
let passed = tool_log_looks_passed(entry);
if !failed && (passed || assume_success_on_completion) {
summary.passed += 1;
}
}
summary
}
fn tool_log_looks_like_test(entry: &ToolLogEntry) -> bool {
let haystack = format!(
"{} {} {} {}",
entry.tool_name,
entry.input_summary,
extract_tool_command(entry),
entry.output_summary
)
.to_ascii_lowercase();
const TEST_MARKERS: &[&str] = &[
"cargo test",
"npm test",
"pnpm test",
"pnpm exec vitest",
"pnpm exec playwright",
"yarn test",
"bun test",
"vitest",
"jest",
"pytest",
"go test",
"playwright test",
"cypress",
"rspec",
"phpunit",
"e2e",
];
TEST_MARKERS.iter().any(|marker| haystack.contains(marker))
}
fn tool_log_looks_failed(entry: &ToolLogEntry) -> bool {
let haystack = format!(
"{} {} {} {}",
entry.tool_name,
entry.input_summary,
extract_tool_command(entry),
entry.output_summary
)
.to_ascii_lowercase();
const FAILURE_MARKERS: &[&str] = &[
" fail",
"failed",
" error",
"panic",
"timed out",
"non-zero",
"exit code 1",
"exited with",
];
FAILURE_MARKERS
.iter()
.any(|marker| haystack.contains(marker))
}
fn tool_log_looks_passed(entry: &ToolLogEntry) -> bool {
let haystack = format!(
"{} {} {} {}",
entry.tool_name,
entry.input_summary,
extract_tool_command(entry),
entry.output_summary
)
.to_ascii_lowercase();
const SUCCESS_MARKERS: &[&str] = &[" pass", "passed", " ok", "success", "green", "completed"];
SUCCESS_MARKERS
.iter()
.any(|marker| haystack.contains(marker))
}
fn extract_tool_command(entry: &ToolLogEntry) -> String {
let Ok(value) = serde_json::from_str::<serde_json::Value>(&entry.input_params_json) else {
return String::new();
};
value
.get("command")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.unwrap_or_default()
}
fn recent_completion_files(file_activity: &[FileActivityEntry], files_changed: u32) -> Vec<String> {
if !file_activity.is_empty() {
return file_activity
.iter()
.take(3)
.map(file_activity_summary)
.collect();
}
if files_changed > 0 {
return vec![format!("files touched {}", files_changed)];
}
Vec::new()
}
fn summarize_completion_decisions(
tool_logs: &[ToolLogEntry],
file_activity: &[FileActivityEntry],
session_task: &str,
) -> Vec<String> {
let mut seen = HashSet::new();
let mut decisions = Vec::new();
for entry in tool_logs.iter().rev() {
let mut candidates = Vec::new();
if !entry.trigger_summary.trim().is_empty()
&& entry.trigger_summary.trim() != session_task.trim()
{
candidates.push(format!(
"why {}",
truncate_for_dashboard(&entry.trigger_summary, 72)
));
}
let action = if entry.tool_name.eq_ignore_ascii_case("Bash") {
truncate_for_dashboard(&extract_tool_command(entry), 72)
} else if !entry.output_summary.trim().is_empty() && entry.output_summary.trim() != "ok" {
truncate_for_dashboard(&entry.output_summary, 72)
} else {
truncate_for_dashboard(&entry.input_summary, 72)
};
if !action.trim().is_empty() {
candidates.push(action);
}
for candidate in candidates {
let normalized = candidate.to_ascii_lowercase();
if seen.insert(normalized) {
decisions.push(candidate);
}
if decisions.len() >= 3 {
return decisions;
}
}
}
for entry in file_activity.iter().take(3) {
let candidate = file_activity_summary(entry);
let normalized = candidate.to_ascii_lowercase();
if seen.insert(normalized) {
decisions.push(candidate);
}
if decisions.len() >= 3 {
break;
}
}
decisions
}
fn summarize_completion_warnings(
session: &Session,
tool_logs: &[ToolLogEntry],
tests: &TestRunSummary,
worktree_health: Option<&worktree::WorktreeHealth>,
approval_backlog: usize,
overlap_count: usize,
) -> Vec<String> {
let mut warnings = Vec::new();
let high_risk_tool_calls = tool_logs
.iter()
.filter(|entry| entry.risk_score >= Config::RISK_THRESHOLDS.review)
.count();
if session.metrics.files_changed > 0 && tests.total == 0 {
warnings.push("no test runs detected".to_string());
}
if tests.total > tests.passed {
warnings.push(format!(
"{} detected test run(s) were not confirmed passed",
tests.total - tests.passed
));
}
if high_risk_tool_calls > 0 {
warnings.push(format!(
"{high_risk_tool_calls} high-risk tool call(s) recorded"
));
}
if approval_backlog > 0 {
warnings.push(format!(
"{approval_backlog} approval/conflict request(s) remained unread"
));
}
if overlap_count > 0 {
warnings.push(format!(
"{overlap_count} potential file overlap(s) remained"
));
}
match worktree_health {
Some(worktree::WorktreeHealth::Conflicted) => {
warnings.push("worktree still has unresolved conflicts".to_string());
}
Some(worktree::WorktreeHealth::InProgress) => {
warnings.push("worktree still has unmerged changes".to_string());
}
Some(worktree::WorktreeHealth::Clear) | None => {}
}
warnings
}
fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str {
match action { match action {
crate::session::FileActivityAction::Read => "read", crate::session::FileActivityAction::Read => "read",
@@ -9151,6 +9671,111 @@ diff --git a/src/lib.rs b/src/lib.rs
); );
} }
#[test]
fn refresh_builds_completion_summary_popup_from_metrics_activity_and_logs() -> Result<()> {
let root = std::env::temp_dir().join(format!("ecc2-completion-popup-{}", Uuid::new_v4()));
fs::create_dir_all(root.join(".claude").join("metrics"))?;
let mut cfg = build_config(&root.join(".claude"));
cfg.completion_summary_notifications.delivery =
crate::notifications::CompletionSummaryDelivery::TuiPopup;
cfg.desktop_notifications.session_completed = false;
let db = StateStore::open(&cfg.db_path)?;
let mut session = sample_session(
"done-12345678",
"claude",
SessionState::Running,
Some("ecc/done"),
384,
95,
);
session.task = "Finish session summary notifications".to_string();
db.insert_session(&session)?;
let metrics_path = cfg.tool_activity_metrics_path();
fs::create_dir_all(metrics_path.parent().unwrap())?;
fs::write(
&metrics_path,
concat!(
"{\"id\":\"evt-1\",\"session_id\":\"done-12345678\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
"{\"id\":\"evt-2\",\"session_id\":\"done-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ session summary notifications\",\"patch_preview\":\"+ session summary notifications\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n",
"{\"id\":\"evt-3\",\"session_id\":\"done-12345678\",\"tool_name\":\"Bash\",\"input_summary\":\"rm -rf build\",\"input_params_json\":\"{\\\"command\\\":\\\"rm -rf build\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:02:00Z\"}\n"
),
)?;
let mut dashboard = Dashboard::new(db, cfg);
dashboard
.db
.update_state("done-12345678", &SessionState::Completed)?;
dashboard.refresh();
let popup = dashboard
.active_completion_popup
.as_ref()
.expect("completion summary popup");
let popup_text = popup.popup_text();
assert!(popup_text.contains("done-123"));
assert!(popup_text.contains("Tests 1 run / 1 passed"));
assert!(popup_text.contains("Recent files"));
assert!(popup_text.contains("create README.md"));
assert!(popup_text.contains("Warnings"));
assert!(popup_text.contains("high-risk tool call"));
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn dismiss_completion_popup_promotes_the_next_summary() {
let mut dashboard = test_dashboard(Vec::new(), 0);
dashboard.active_completion_popup = Some(SessionCompletionSummary {
session_id: "sess-a".to_string(),
task: "First".to_string(),
state: SessionState::Completed,
files_changed: 1,
tokens_used: 10,
duration_secs: 5,
cost_usd: 0.01,
tests_run: 1,
tests_passed: 1,
recent_files: vec!["create README.md".to_string()],
key_decisions: vec!["cargo test -q".to_string()],
warnings: Vec::new(),
});
dashboard
.queued_completion_popups
.push_back(SessionCompletionSummary {
session_id: "sess-b".to_string(),
task: "Second".to_string(),
state: SessionState::Completed,
files_changed: 2,
tokens_used: 20,
duration_secs: 8,
cost_usd: 0.02,
tests_run: 0,
tests_passed: 0,
recent_files: vec!["modify src/lib.rs".to_string()],
key_decisions: vec!["updated lib".to_string()],
warnings: vec!["no test runs detected".to_string()],
});
dashboard.dismiss_completion_popup();
assert_eq!(
dashboard
.active_completion_popup
.as_ref()
.map(|summary| summary.session_id.as_str()),
Some("sess-b")
);
assert!(dashboard.queued_completion_popups.is_empty());
dashboard.dismiss_completion_popup();
assert!(dashboard.active_completion_popup.is_none());
}
#[test] #[test]
fn refresh_syncs_tool_activity_metrics_from_hook_file() { fn refresh_syncs_tool_activity_metrics_from_hook_file() {
let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4())); let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4()));
@@ -11284,6 +11909,8 @@ diff --git a/src/lib.rs b/src/lib.rs
search_agent_filter: SearchAgentFilter::AllAgents, search_agent_filter: SearchAgentFilter::AllAgents,
search_matches: Vec::new(), search_matches: Vec::new(),
selected_search_match: 0, selected_search_match: 0,
active_completion_popup: None,
queued_completion_popups: VecDeque::new(),
session_table_state, session_table_state,
last_cost_metrics_signature: None, last_cost_metrics_signature: None,
last_tool_activity_signature: None, last_tool_activity_signature: None,
@@ -11310,6 +11937,8 @@ diff --git a/src/lib.rs b/src/lib.rs
auto_create_worktrees: true, auto_create_worktrees: true,
auto_merge_ready_worktrees: false, auto_merge_ready_worktrees: false,
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
completion_summary_notifications:
crate::notifications::CompletionSummaryConfig::default(),
cost_budget_usd: 10.0, cost_budget_usd: 10.0,
token_budget: 500_000, token_budget: 500_000,
budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS, budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS,

View File

@@ -349,7 +349,9 @@ pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result<String> {
anyhow::bail!("git rev-parse failed: {stderr}"); anyhow::bail!("git rev-parse failed: {stderr}");
} }
Ok(String::from_utf8_lossy(&rev_parse.stdout).trim().to_string()) Ok(String::from_utf8_lossy(&rev_parse.stdout)
.trim()
.to_string())
} }
pub fn latest_commit_subject(worktree: &WorktreeInfo) -> Result<String> { pub fn latest_commit_subject(worktree: &WorktreeInfo) -> Result<String> {
@@ -604,7 +606,9 @@ pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result<bool> {
} }
pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result<bool> { pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result<bool> {
Ok(git_status_entries(worktree)?.iter().any(|entry| entry.staged)) Ok(git_status_entries(worktree)?
.iter()
.any(|entry| entry.staged))
} }
pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> { pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
@@ -925,8 +929,12 @@ fn dependency_fingerprint(root: &Path, files: &[&str]) -> Result<String> {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
for rel in files { for rel in files {
let path = root.join(rel); let path = root.join(rel);
let content = fs::read(&path) let content = fs::read(&path).with_context(|| {
.with_context(|| format!("Failed to read dependency fingerprint input {}", path.display()))?; format!(
"Failed to read dependency fingerprint input {}",
path.display()
)
})?;
hasher.update(rel.as_bytes()); hasher.update(rel.as_bytes());
hasher.update([0]); hasher.update([0]);
hasher.update(&content); hasher.update(&content);
@@ -957,10 +965,8 @@ fn is_symlink_to(path: &Path, target: &Path) -> Result<bool> {
fn remove_symlink(path: &Path) -> Result<()> { fn remove_symlink(path: &Path) -> Result<()> {
match fs::remove_file(path) { match fs::remove_file(path) {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => { Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => fs::remove_dir(path)
fs::remove_dir(path) .with_context(|| format!("Failed to remove dependency cache link {}", path.display())),
.with_context(|| format!("Failed to remove dependency cache link {}", path.display()))
}
Err(error) => Err(error) Err(error) => Err(error)
.with_context(|| format!("Failed to remove dependency cache link {}", path.display())), .with_context(|| format!("Failed to remove dependency cache link {}", path.display())),
} }
@@ -1072,10 +1078,7 @@ fn parse_git_status_entry(line: &str) -> Option<GitStatusEntry> {
.to_string(); .to_string();
let conflicted = matches!( let conflicted = matches!(
(index_status, worktree_status), (index_status, worktree_status),
('U', _) ('U', _) | (_, 'U') | ('A', 'A') | ('D', 'D')
| (_, 'U')
| ('A', 'A')
| ('D', 'D')
); );
Some(GitStatusEntry { Some(GitStatusEntry {
path: normalized_path, path: normalized_path,
@@ -1491,8 +1494,10 @@ mod tests {
#[test] #[test]
fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> { fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> {
let root = std::env::temp_dir() let root = std::env::temp_dir().join(format!(
.join(format!("ecc2-worktree-branch-conflict-preview-{}", Uuid::new_v4())); "ecc2-worktree-branch-conflict-preview-{}",
Uuid::new_v4()
));
let repo = init_repo(&root)?; let repo = init_repo(&root)?;
let left_dir = root.join("wt-left"); let left_dir = root.join("wt-left");
@@ -1538,8 +1543,8 @@ mod tests {
base_branch: "main".to_string(), base_branch: "main".to_string(),
}; };
let preview = branch_conflict_preview(&left, &right, 12)? let preview =
.expect("expected branch conflict preview"); branch_conflict_preview(&left, &right, 12)?.expect("expected branch conflict preview");
assert_eq!(preview.conflicts, vec!["README.md".to_string()]); assert_eq!(preview.conflicts, vec!["README.md".to_string()]);
assert!(preview assert!(preview
.left_patch_preview .left_patch_preview
@@ -1622,7 +1627,10 @@ mod tests {
.arg(&repo) .arg(&repo)
.args(["log", "-1", "--pretty=%s"]) .args(["log", "-1", "--pretty=%s"])
.output()?; .output()?;
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "update readme"); assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"update readme"
);
let _ = fs::remove_dir_all(root); let _ = fs::remove_dir_all(root);
Ok(()) Ok(())
@@ -1652,8 +1660,19 @@ mod tests {
let root = std::env::temp_dir().join(format!("ecc2-pr-create-{}", Uuid::new_v4())); let root = std::env::temp_dir().join(format!("ecc2-pr-create-{}", Uuid::new_v4()));
let repo = init_repo(&root)?; let repo = init_repo(&root)?;
let remote = root.join("remote.git"); let remote = root.join("remote.git");
run_git(&root, &["init", "--bare", remote.to_str().expect("utf8 path")])?; run_git(
run_git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf8 path")])?; &root,
&["init", "--bare", remote.to_str().expect("utf8 path")],
)?;
run_git(
&repo,
&[
"remote",
"add",
"origin",
remote.to_str().expect("utf8 path"),
],
)?;
run_git(&repo, &["push", "-u", "origin", "main"])?; run_git(&repo, &["push", "-u", "origin", "main"])?;
run_git(&repo, &["checkout", "-b", "feat/pr-test"])?; run_git(&repo, &["checkout", "-b", "feat/pr-test"])?;
fs::write(repo.join("README.md"), "pr test\n")?; fs::write(repo.join("README.md"), "pr test\n")?;
@@ -1713,10 +1732,14 @@ mod tests {
#[test] #[test]
fn create_for_session_links_shared_node_modules_cache() -> Result<()> { fn create_for_session_links_shared_node_modules_cache() -> Result<()> {
let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4())); let root =
std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4()));
let repo = init_repo(&root)?; let repo = init_repo(&root)?;
fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?; fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?;
fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?; fs::write(
repo.join("package-lock.json"),
"{\n \"lockfileVersion\": 3\n}\n",
)?;
fs::create_dir_all(repo.join("node_modules"))?; fs::create_dir_all(repo.join("node_modules"))?;
fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?; fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?;
run_git(&repo, &["add", "package.json", "package-lock.json"])?; run_git(&repo, &["add", "package.json", "package-lock.json"])?;
@@ -1727,7 +1750,9 @@ mod tests {
let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?;
let node_modules = worktree.path.join("node_modules"); let node_modules = worktree.path.join("node_modules");
assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); assert!(fs::symlink_metadata(&node_modules)?
.file_type()
.is_symlink());
assert_eq!(fs::read_link(&node_modules)?, repo.join("node_modules")); assert_eq!(fs::read_link(&node_modules)?, repo.join("node_modules"));
remove(&worktree)?; remove(&worktree)?;
@@ -1741,7 +1766,10 @@ mod tests {
std::env::temp_dir().join(format!("ecc2-worktree-node-fallback-{}", Uuid::new_v4())); std::env::temp_dir().join(format!("ecc2-worktree-node-fallback-{}", Uuid::new_v4()));
let repo = init_repo(&root)?; let repo = init_repo(&root)?;
fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?; fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?;
fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?; fs::write(
repo.join("package-lock.json"),
"{\n \"lockfileVersion\": 3\n}\n",
)?;
fs::create_dir_all(repo.join("node_modules"))?; fs::create_dir_all(repo.join("node_modules"))?;
fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?; fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?;
run_git(&repo, &["add", "package.json", "package-lock.json"])?; run_git(&repo, &["add", "package.json", "package-lock.json"])?;
@@ -1752,7 +1780,9 @@ mod tests {
let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?;
let node_modules = worktree.path.join("node_modules"); let node_modules = worktree.path.join("node_modules");
assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); assert!(fs::symlink_metadata(&node_modules)?
.file_type()
.is_symlink());
fs::write( fs::write(
worktree.path.join("package-lock.json"), worktree.path.join("package-lock.json"),
@@ -1761,7 +1791,9 @@ mod tests {
let applied = sync_shared_dependency_dirs(&worktree)?; let applied = sync_shared_dependency_dirs(&worktree)?;
assert!(applied.is_empty()); assert!(applied.is_empty());
assert!(node_modules.is_dir()); assert!(node_modules.is_dir());
assert!(!fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); assert!(!fs::symlink_metadata(&node_modules)?
.file_type()
.is_symlink());
assert!(repo.join("node_modules/.cache-marker").exists()); assert!(repo.join("node_modules/.cache-marker").exists());
remove(&worktree)?; remove(&worktree)?;