feat: add ecc2 webhook notifications

This commit is contained in:
Affaan Mustafa
2026-04-09 21:14:09 -07:00
parent b45a6ca810
commit 5fb2e62216
7 changed files with 816 additions and 5 deletions

154
ecc2/Cargo.lock generated
View File

@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -300,6 +306,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossterm" name = "crossterm"
version = "0.28.1" version = "0.28.1"
@@ -507,6 +522,7 @@ dependencies = [
"toml", "toml",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"ureq",
"uuid", "uuid",
] ]
@@ -592,6 +608,16 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@@ -1141,6 +1167,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@@ -1612,6 +1648,20 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.32.1" version = "0.32.1"
@@ -1661,6 +1711,41 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.22" version = "1.0.22"
@@ -1794,6 +1879,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.2" version = "1.0.2"
@@ -1855,6 +1946,12 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@@ -2208,6 +2305,30 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -2374,6 +2495,24 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "wezterm-bidi" name = "wezterm-bidi"
version = "0.2.3" version = "0.2.3"
@@ -2527,6 +2666,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.59.0" version = "0.59.0"
@@ -2776,6 +2924,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.3" version = "0.2.3"

View File

@@ -27,6 +27,7 @@ serde_json = "1"
toml = "0.8" toml = "0.8"
regex = "1" regex = "1"
sha2 = "0.10" sha2 = "0.10"
ureq = { version = "2", features = ["json"] }
# CLI # CLI
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }

View File

@@ -3,7 +3,9 @@ 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::{CompletionSummaryConfig, DesktopNotificationConfig}; use crate::notifications::{
CompletionSummaryConfig, DesktopNotificationConfig, WebhookNotificationConfig,
};
#[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 +50,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 webhook_notifications: WebhookNotificationConfig,
pub completion_summary_notifications: CompletionSummaryConfig, pub completion_summary_notifications: CompletionSummaryConfig,
pub cost_budget_usd: f64, pub cost_budget_usd: f64,
pub token_budget: u64, pub token_budget: u64,
@@ -112,6 +115,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(),
webhook_notifications: WebhookNotificationConfig::default(),
completion_summary_notifications: CompletionSummaryConfig::default(), completion_summary_notifications: CompletionSummaryConfig::default(),
cost_budget_usd: 10.0, cost_budget_usd: 10.0,
token_budget: 500_000, token_budget: 500_000,
@@ -438,6 +442,7 @@ theme = "Dark"
defaults.auto_merge_ready_worktrees defaults.auto_merge_ready_worktrees
); );
assert_eq!(config.desktop_notifications, defaults.desktop_notifications); assert_eq!(config.desktop_notifications, defaults.desktop_notifications);
assert_eq!(config.webhook_notifications, defaults.webhook_notifications);
assert_eq!( assert_eq!(
config.auto_terminate_stale_sessions, config.auto_terminate_stale_sessions,
defaults.auto_terminate_stale_sessions defaults.auto_terminate_stale_sessions
@@ -636,6 +641,42 @@ delivery = "desktop_and_tui_popup"
); );
} }
#[test]
fn webhook_notifications_deserialize_from_toml() {
let config: Config = toml::from_str(
r#"
[webhook_notifications]
enabled = true
session_started = true
session_completed = true
session_failed = true
budget_alerts = true
approval_requests = false
[[webhook_notifications.targets]]
provider = "slack"
url = "https://hooks.slack.test/services/abc"
[[webhook_notifications.targets]]
provider = "discord"
url = "https://discord.test/api/webhooks/123"
"#,
)
.unwrap();
assert!(config.webhook_notifications.enabled);
assert!(config.webhook_notifications.session_started);
assert_eq!(config.webhook_notifications.targets.len(), 2);
assert_eq!(
config.webhook_notifications.targets[0].provider,
crate::notifications::WebhookProvider::Slack
);
assert_eq!(
config.webhook_notifications.targets[1].provider,
crate::notifications::WebhookProvider::Discord
);
}
#[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(
@@ -663,6 +704,11 @@ 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.webhook_notifications.enabled = true;
config.webhook_notifications.targets = vec![crate::notifications::WebhookTarget {
provider: crate::notifications::WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
}];
config.completion_summary_notifications.delivery = config.completion_summary_notifications.delivery =
crate::notifications::CompletionSummaryDelivery::TuiPopup; crate::notifications::CompletionSummaryDelivery::TuiPopup;
config.desktop_notifications.quiet_hours.enabled = true; config.desktop_notifications.quiet_hours.enabled = true;
@@ -688,6 +734,12 @@ 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!(loaded.webhook_notifications.enabled);
assert_eq!(loaded.webhook_notifications.targets.len(), 1);
assert_eq!(
loaded.webhook_notifications.targets[0].provider,
crate::notifications::WebhookProvider::Slack
);
assert_eq!( assert_eq!(
loaded.completion_summary_notifications.delivery, loaded.completion_summary_notifications.delivery,
crate::notifications::CompletionSummaryDelivery::TuiPopup crate::notifications::CompletionSummaryDelivery::TuiPopup

View File

@@ -1,12 +1,14 @@
use anyhow::Result; use anyhow::Result;
use chrono::{DateTime, Local, Timelike}; use chrono::{DateTime, Local, Timelike};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json;
#[cfg(not(test))] #[cfg(not(test))]
use anyhow::Context; use anyhow::Context;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationEvent { pub enum NotificationEvent {
SessionStarted,
SessionCompleted, SessionCompleted,
SessionFailed, SessionFailed,
BudgetAlert, BudgetAlert,
@@ -25,6 +27,7 @@ pub struct QuietHoursConfig {
#[serde(default)] #[serde(default)]
pub struct DesktopNotificationConfig { pub struct DesktopNotificationConfig {
pub enabled: bool, pub enabled: bool,
pub session_started: bool,
pub session_completed: bool, pub session_completed: bool,
pub session_failed: bool, pub session_failed: bool,
pub budget_alerts: bool, pub budget_alerts: bool,
@@ -48,11 +51,43 @@ pub struct CompletionSummaryConfig {
pub delivery: CompletionSummaryDelivery, pub delivery: CompletionSummaryDelivery,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookProvider {
#[default]
Slack,
Discord,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookTarget {
pub provider: WebhookProvider,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookNotificationConfig {
pub enabled: bool,
pub session_started: bool,
pub session_completed: bool,
pub session_failed: bool,
pub budget_alerts: bool,
pub approval_requests: bool,
pub targets: Vec<WebhookTarget>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DesktopNotifier { pub struct DesktopNotifier {
config: DesktopNotificationConfig, config: DesktopNotificationConfig,
} }
#[derive(Debug, Clone)]
pub struct WebhookNotifier {
config: WebhookNotificationConfig,
}
impl Default for QuietHoursConfig { impl Default for QuietHoursConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -96,6 +131,7 @@ impl Default for DesktopNotificationConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: true, enabled: true,
session_started: false,
session_completed: true, session_completed: true,
session_failed: true, session_failed: true,
budget_alerts: true, budget_alerts: true,
@@ -120,6 +156,7 @@ impl DesktopNotificationConfig {
} }
match event { match event {
NotificationEvent::SessionStarted => config.session_started,
NotificationEvent::SessionCompleted => config.session_completed, NotificationEvent::SessionCompleted => config.session_completed,
NotificationEvent::SessionFailed => config.session_failed, NotificationEvent::SessionFailed => config.session_failed,
NotificationEvent::BudgetAlert => config.budget_alerts, NotificationEvent::BudgetAlert => config.budget_alerts,
@@ -155,6 +192,68 @@ impl CompletionSummaryConfig {
} }
} }
impl Default for WebhookTarget {
fn default() -> Self {
Self {
provider: WebhookProvider::Slack,
url: String::new(),
}
}
}
impl WebhookTarget {
fn sanitized(self) -> Option<Self> {
let url = self.url.trim().to_string();
if url.starts_with("https://") || url.starts_with("http://") {
Some(Self { url, ..self })
} else {
None
}
}
}
impl Default for WebhookNotificationConfig {
fn default() -> Self {
Self {
enabled: false,
session_started: true,
session_completed: true,
session_failed: true,
budget_alerts: true,
approval_requests: false,
targets: Vec::new(),
}
}
}
impl WebhookNotificationConfig {
pub fn sanitized(self) -> Self {
Self {
targets: self
.targets
.into_iter()
.filter_map(WebhookTarget::sanitized)
.collect(),
..self
}
}
pub fn allows(&self, event: NotificationEvent) -> bool {
let config = self.clone().sanitized();
if !config.enabled || config.targets.is_empty() {
return false;
}
match event {
NotificationEvent::SessionStarted => config.session_started,
NotificationEvent::SessionCompleted => config.session_completed,
NotificationEvent::SessionFailed => config.session_failed,
NotificationEvent::BudgetAlert => config.budget_alerts,
NotificationEvent::ApprovalRequest => config.approval_requests,
}
}
}
impl DesktopNotifier { impl DesktopNotifier {
pub fn new(config: DesktopNotificationConfig) -> Self { pub fn new(config: DesktopNotificationConfig) -> Self {
Self { Self {
@@ -192,6 +291,57 @@ impl DesktopNotifier {
} }
} }
impl WebhookNotifier {
pub fn new(config: WebhookNotificationConfig) -> Self {
Self {
config: config.sanitized(),
}
}
pub fn notify(&self, event: NotificationEvent, message: &str) -> bool {
match self.try_notify(event, message) {
Ok(sent) => sent,
Err(error) => {
tracing::warn!("Failed to send webhook notification: {error}");
false
}
}
}
fn try_notify(&self, event: NotificationEvent, message: &str) -> Result<bool> {
self.try_notify_with(event, message, send_webhook_request)
}
fn try_notify_with<F>(
&self,
event: NotificationEvent,
message: &str,
mut sender: F,
) -> Result<bool>
where
F: FnMut(&WebhookTarget, serde_json::Value) -> Result<()>,
{
if !self.config.allows(event) {
return Ok(false);
}
let mut delivered = false;
for target in &self.config.targets {
let payload = webhook_payload(target, message);
match sender(target, payload) {
Ok(()) => delivered = true,
Err(error) => tracing::warn!(
"Failed to deliver {:?} webhook notification to {}: {error}",
target.provider,
target.url
),
}
}
Ok(delivered)
}
}
fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec<String>)> { fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec<String>)> {
match platform { match platform {
"macos" => Some(( "macos" => Some((
@@ -218,6 +368,20 @@ fn notification_command(platform: &str, title: &str, body: &str) -> Option<(Stri
} }
} }
fn webhook_payload(target: &WebhookTarget, message: &str) -> serde_json::Value {
match target.provider {
WebhookProvider::Slack => json!({
"text": message,
}),
WebhookProvider::Discord => json!({
"content": message,
"allowed_mentions": {
"parse": []
}
}),
}
}
#[cfg(not(test))] #[cfg(not(test))]
fn run_notification_command(program: &str, args: &[String]) -> Result<()> { fn run_notification_command(program: &str, args: &[String]) -> Result<()> {
let status = std::process::Command::new(program) let status = std::process::Command::new(program)
@@ -237,6 +401,29 @@ fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(not(test))]
fn send_webhook_request(target: &WebhookTarget, payload: serde_json::Value) -> Result<()> {
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout_read(std::time::Duration::from_secs(5))
.build();
let response = agent
.post(&target.url)
.send_json(payload)
.with_context(|| format!("POST {}", target.url))?;
if response.status() >= 200 && response.status() < 300 {
Ok(())
} else {
anyhow::bail!("{} returned {}", target.url, response.status());
}
}
#[cfg(test)]
fn send_webhook_request(_target: &WebhookTarget, _payload: serde_json::Value) -> Result<()> {
Ok(())
}
fn sanitize_osascript(value: &str) -> String { fn sanitize_osascript(value: &str) -> String {
value value
.replace('\\', "") .replace('\\', "")
@@ -247,10 +434,12 @@ fn sanitize_osascript(value: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
notification_command, DesktopNotificationConfig, DesktopNotifier, NotificationEvent, notification_command, webhook_payload, CompletionSummaryDelivery,
QuietHoursConfig, DesktopNotificationConfig, DesktopNotifier, NotificationEvent, QuietHoursConfig,
WebhookNotificationConfig, WebhookNotifier, WebhookProvider, WebhookTarget,
}; };
use chrono::{Local, TimeZone}; use chrono::{Local, TimeZone};
use serde_json::json;
#[test] #[test]
fn quiet_hours_support_cross_midnight_ranges() { fn quiet_hours_support_cross_midnight_ranges() {
@@ -285,6 +474,7 @@ mod tests {
assert!(!config.allows(NotificationEvent::SessionCompleted, now)); assert!(!config.allows(NotificationEvent::SessionCompleted, now));
assert!(config.allows(NotificationEvent::BudgetAlert, now)); assert!(config.allows(NotificationEvent::BudgetAlert, now));
assert!(!config.allows(NotificationEvent::SessionStarted, now));
} }
#[test] #[test]
@@ -329,4 +519,117 @@ mod tests {
assert_eq!(args[2], "ECC 2.0: Approval needed"); assert_eq!(args[2], "ECC 2.0: Approval needed");
assert_eq!(args[3], "worker-123"); assert_eq!(args[3], "worker-123");
} }
#[test]
fn webhook_notifications_require_enabled_targets_and_event() {
let mut config = WebhookNotificationConfig::default();
assert!(!config.allows(NotificationEvent::SessionCompleted));
config.enabled = true;
config.targets = vec![WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
}];
assert!(config.allows(NotificationEvent::SessionCompleted));
assert!(config.allows(NotificationEvent::SessionStarted));
assert!(!config.allows(NotificationEvent::ApprovalRequest));
}
#[test]
fn webhook_sanitization_filters_invalid_urls() {
let config = WebhookNotificationConfig {
enabled: true,
targets: vec![
WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
},
WebhookTarget {
provider: WebhookProvider::Discord,
url: "ftp://discord.invalid".to_string(),
},
],
..WebhookNotificationConfig::default()
}
.sanitized();
assert_eq!(config.targets.len(), 1);
assert_eq!(config.targets[0].provider, WebhookProvider::Slack);
}
#[test]
fn slack_webhook_payload_uses_text() {
let payload = webhook_payload(
&WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
},
"*ECC 2.0* hello",
);
assert_eq!(payload, json!({ "text": "*ECC 2.0* hello" }));
}
#[test]
fn discord_webhook_payload_disables_mentions() {
let payload = webhook_payload(
&WebhookTarget {
provider: WebhookProvider::Discord,
url: "https://discord.test/api/webhooks/123".to_string(),
},
"```text\nsummary\n```",
);
assert_eq!(
payload,
json!({
"content": "```text\nsummary\n```",
"allowed_mentions": { "parse": [] }
})
);
}
#[test]
fn webhook_notifier_sends_to_each_target() {
let notifier = WebhookNotifier::new(WebhookNotificationConfig {
enabled: true,
targets: vec![
WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
},
WebhookTarget {
provider: WebhookProvider::Discord,
url: "https://discord.test/api/webhooks/123".to_string(),
},
],
..WebhookNotificationConfig::default()
});
let mut sent = Vec::new();
let delivered = notifier
.try_notify_with(
NotificationEvent::SessionCompleted,
"payload text",
|target, payload| {
sent.push((target.provider, payload));
Ok(())
},
)
.unwrap();
assert!(delivered);
assert_eq!(sent.len(), 2);
assert_eq!(sent[0].0, WebhookProvider::Slack);
assert_eq!(sent[1].0, WebhookProvider::Discord);
}
#[test]
fn completion_summary_delivery_defaults_to_desktop() {
assert_eq!(
CompletionSummaryDelivery::default(),
CompletionSummaryDelivery::Desktop
);
}
} }

View File

@@ -2246,6 +2246,7 @@ 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(),
webhook_notifications: crate::notifications::WebhookNotificationConfig::default(),
completion_summary_notifications: completion_summary_notifications:
crate::notifications::CompletionSummaryConfig::default(), crate::notifications::CompletionSummaryConfig::default(),
cost_budget_usd: 10.0, cost_budget_usd: 10.0,

View File

@@ -15,7 +15,7 @@ use tokio::sync::broadcast;
use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter};
use crate::comms; use crate::comms;
use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme}; use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme};
use crate::notifications::{DesktopNotifier, NotificationEvent}; use crate::notifications::{DesktopNotifier, NotificationEvent, WebhookNotifier};
use crate::observability::ToolLogEntry; use crate::observability::ToolLogEntry;
use crate::session::manager; use crate::session::manager;
use crate::session::output::{ use crate::session::output::{
@@ -81,6 +81,7 @@ pub struct Dashboard {
output_store: SessionOutputStore, output_store: SessionOutputStore,
output_rx: broadcast::Receiver<OutputEvent>, output_rx: broadcast::Receiver<OutputEvent>,
notifier: DesktopNotifier, notifier: DesktopNotifier,
webhook_notifier: WebhookNotifier,
sessions: Vec<Session>, sessions: Vec<Session>,
session_output_cache: HashMap<String, Vec<OutputLine>>, session_output_cache: HashMap<String, Vec<OutputLine>>,
unread_message_counts: HashMap<String, usize>, unread_message_counts: HashMap<String, usize>,
@@ -456,6 +457,7 @@ impl Dashboard {
.map(|message| message.id); .map(|message| message.id);
let output_rx = output_store.subscribe(); let output_rx = output_store.subscribe();
let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone());
let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone());
let mut session_table_state = TableState::default(); let mut session_table_state = TableState::default();
if !sessions.is_empty() { if !sessions.is_empty() {
session_table_state.select(Some(0)); session_table_state.select(Some(0));
@@ -467,6 +469,7 @@ impl Dashboard {
output_store, output_store,
output_rx, output_rx,
notifier, notifier,
webhook_notifier,
sessions, sessions,
session_output_cache: HashMap::new(), session_output_cache: HashMap::new(),
unread_message_counts: HashMap::new(), unread_message_counts: HashMap::new(),
@@ -3649,21 +3652,40 @@ impl Dashboard {
"ECC 2.0: Budget alert", "ECC 2.0: Budget alert",
&format!("{summary_suffix} | tokens {token_budget} | cost {cost_budget}"), &format!("{summary_suffix} | tokens {token_budget} | cost {cost_budget}"),
); );
self.notify_webhook(
NotificationEvent::BudgetAlert,
&budget_alert_webhook_body(
&summary_suffix,
&token_budget,
&cost_budget,
self.active_session_count(),
),
);
} }
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 completion_summaries = Vec::new();
let mut failed_notifications = Vec::new(); let mut failed_notifications = Vec::new();
let mut started_webhooks = Vec::new();
let mut completion_webhooks = Vec::new();
let mut failed_webhooks = 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);
if let Some(previous_state) = previous_state { if let Some(previous_state) = previous_state {
if previous_state != &session.state { if previous_state != &session.state {
match session.state { match session.state {
SessionState::Running => {
started_webhooks.push(session_started_webhook_body(
session,
session_compare_url(session).as_deref(),
));
}
SessionState::Completed => { SessionState::Completed => {
let summary = self.build_completion_summary(session);
if self.cfg.completion_summary_notifications.enabled { if self.cfg.completion_summary_notifications.enabled {
completion_summaries.push(self.build_completion_summary(session)); completion_summaries.push(summary.clone());
} else if self.cfg.desktop_notifications.session_completed { } else if self.cfg.desktop_notifications.session_completed {
self.notify_desktop( self.notify_desktop(
NotificationEvent::SessionCompleted, NotificationEvent::SessionCompleted,
@@ -3675,8 +3697,14 @@ impl Dashboard {
), ),
); );
} }
completion_webhooks.push(completion_summary_webhook_body(
&summary,
session,
session_compare_url(session).as_deref(),
));
} }
SessionState::Failed => { SessionState::Failed => {
let summary = self.build_completion_summary(session);
failed_notifications.push(( failed_notifications.push((
"ECC 2.0: Session failed".to_string(), "ECC 2.0: Session failed".to_string(),
format!( format!(
@@ -3685,10 +3713,20 @@ impl Dashboard {
truncate_for_dashboard(&session.task, 96) truncate_for_dashboard(&session.task, 96)
), ),
)); ));
failed_webhooks.push(completion_summary_webhook_body(
&summary,
session,
session_compare_url(session).as_deref(),
));
} }
_ => {} _ => {}
} }
} }
} else if session.state == SessionState::Running {
started_webhooks.push(session_started_webhook_body(
session,
session_compare_url(session).as_deref(),
));
} }
next_states.insert(session.id.clone(), session.state.clone()); next_states.insert(session.id.clone(), session.state.clone());
@@ -3698,12 +3736,24 @@ impl Dashboard {
self.deliver_completion_summary(summary); self.deliver_completion_summary(summary);
} }
for body in started_webhooks {
self.notify_webhook(NotificationEvent::SessionStarted, &body);
}
if self.cfg.desktop_notifications.session_failed { if self.cfg.desktop_notifications.session_failed {
for (title, body) in failed_notifications { for (title, body) in failed_notifications {
self.notify_desktop(NotificationEvent::SessionFailed, &title, &body); self.notify_desktop(NotificationEvent::SessionFailed, &title, &body);
} }
} }
for body in completion_webhooks {
self.notify_webhook(NotificationEvent::SessionCompleted, &body);
}
for body in failed_webhooks {
self.notify_webhook(NotificationEvent::SessionFailed, &body);
}
self.last_session_states = next_states; self.last_session_states = next_states;
} }
@@ -3740,6 +3790,10 @@ impl Dashboard {
preview preview
), ),
); );
self.notify_webhook(
NotificationEvent::ApprovalRequest,
&approval_request_webhook_body(&message, &preview),
);
} }
fn deliver_completion_summary(&mut self, summary: SessionCompletionSummary) { fn deliver_completion_summary(&mut self, summary: SessionCompletionSummary) {
@@ -3830,6 +3884,10 @@ impl Dashboard {
let _ = self.notifier.notify(event, title, body); let _ = self.notifier.notify(event, title, body);
} }
fn notify_webhook(&self, event: NotificationEvent, body: &str) {
let _ = self.webhook_notifier.notify(event, body);
}
fn sync_selection(&mut self) { fn sync_selection(&mut self) {
if self.sessions.is_empty() { if self.sessions.is_empty() {
self.selected_session = 0; self.selected_session = 0;
@@ -7263,6 +7321,129 @@ fn summarize_completion_warnings(
warnings warnings
} }
fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String {
let mut lines = vec![
"*ECC 2.0: Session started*".to_string(),
format!(
"`{}` {}",
format_session_id(&session.id),
truncate_for_dashboard(&session.task, 96)
),
format!(
"Project `{}` | Group `{}` | Agent `{}`",
session.project, session.task_group, session.agent_type
),
];
if let Some(worktree) = session.worktree.as_ref() {
lines.push(format!(
"```text\nbranch: {}\nbase: {}\nworktree: {}\n```",
worktree.branch,
worktree.base_branch,
worktree.path.display()
));
}
if let Some(compare_url) = compare_url {
lines.push(format!("PR / compare: {compare_url}"));
}
lines.join("\n")
}
fn completion_summary_webhook_body(
summary: &SessionCompletionSummary,
session: &Session,
compare_url: Option<&str>,
) -> String {
let mut lines = vec![
format!("*{}*", summary.title()),
format!(
"`{}` {}",
format_session_id(&summary.session_id),
truncate_for_dashboard(&summary.task, 96)
),
format!(
"Project `{}` | Group `{}` | State `{}`",
session.project, session.task_group, session.state
),
format!(
"Duration `{}` | Files `{}` | Tokens `{}` | Cost `{}`",
format_duration(summary.duration_secs),
summary.files_changed,
format_token_count(summary.tokens_used),
format_currency(summary.cost_usd)
),
if summary.tests_run > 0 {
format!(
"Tests `{}` run / `{}` passed",
summary.tests_run, summary.tests_passed
)
} else {
"Tests `not detected`".to_string()
},
];
if !summary.recent_files.is_empty() {
lines.push(markdown_code_block("Recent files", &summary.recent_files));
}
if !summary.key_decisions.is_empty() {
lines.push(markdown_code_block("Key decisions", &summary.key_decisions));
}
if !summary.warnings.is_empty() {
lines.push(markdown_code_block("Warnings", &summary.warnings));
}
if let Some(compare_url) = compare_url {
lines.push(format!("PR / compare: {compare_url}"));
}
lines.join("\n")
}
fn budget_alert_webhook_body(
summary_suffix: &str,
token_budget: &str,
cost_budget: &str,
active_sessions: usize,
) -> String {
[
"*ECC 2.0: Budget alert*".to_string(),
summary_suffix.to_string(),
format!("Tokens `{token_budget}`"),
format!("Cost `{cost_budget}`"),
format!("Active sessions `{active_sessions}`"),
]
.join("\n")
}
fn approval_request_webhook_body(message: &SessionMessage, preview: &str) -> String {
[
"*ECC 2.0: Approval needed*".to_string(),
format!(
"To `{}` from `{}`",
format_session_id(&message.to_session),
format_session_id(&message.from_session)
),
format!("Type `{}`", message.msg_type),
markdown_code_block("Request", &[preview.to_string()]),
]
.join("\n")
}
fn markdown_code_block(label: &str, lines: &[String]) -> String {
format!("{label}\n```text\n{}\n```", lines.join("\n"))
}
fn session_compare_url(session: &Session) -> Option<String> {
session
.worktree
.as_ref()
.and_then(|worktree| worktree::github_compare_url(worktree).ok().flatten())
}
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",
@@ -11838,6 +12019,7 @@ diff --git a/src/lib.rs b/src/lib.rs
let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let selected_session = selected_session.min(sessions.len().saturating_sub(1));
let cfg = Config::default(); let cfg = Config::default();
let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone());
let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone());
let last_session_states = sessions let last_session_states = sessions
.iter() .iter()
.map(|session| (session.id.clone(), session.state.clone())) .map(|session| (session.id.clone(), session.state.clone()))
@@ -11856,6 +12038,7 @@ diff --git a/src/lib.rs b/src/lib.rs
output_store, output_store,
output_rx, output_rx,
notifier, notifier,
webhook_notifier,
sessions, sessions,
session_output_cache: HashMap::new(), session_output_cache: HashMap::new(),
unread_message_counts: HashMap::new(), unread_message_counts: HashMap::new(),
@@ -11937,6 +12120,7 @@ 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(),
webhook_notifications: crate::notifications::WebhookNotificationConfig::default(),
completion_summary_notifications: completion_summary_notifications:
crate::notifications::CompletionSummaryConfig::default(), crate::notifications::CompletionSummaryConfig::default(),
cost_budget_usd: 10.0, cost_budget_usd: 10.0,

View File

@@ -373,6 +373,20 @@ pub fn create_draft_pr(worktree: &WorktreeInfo, title: &str, body: &str) -> Resu
create_draft_pr_with_gh(worktree, title, body, Path::new("gh")) create_draft_pr_with_gh(worktree, title, body, Path::new("gh"))
} }
pub fn github_compare_url(worktree: &WorktreeInfo) -> Result<Option<String>> {
let repo_root = base_checkout_path(worktree)?;
let origin = git_remote_origin_url(&repo_root)?;
let Some(repo_url) = github_repo_web_url(&origin) else {
return Ok(None);
};
Ok(Some(format!(
"{repo_url}/compare/{}...{}?expand=1",
percent_encode_git_ref(&worktree.base_branch),
percent_encode_git_ref(&worktree.branch)
)))
}
fn create_draft_pr_with_gh( fn create_draft_pr_with_gh(
worktree: &WorktreeInfo, worktree: &WorktreeInfo,
title: &str, title: &str,
@@ -418,6 +432,67 @@ fn create_draft_pr_with_gh(
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} }
fn git_remote_origin_url(repo_root: &Path) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["remote", "get-url", "origin"])
.output()
.context("Failed to resolve git origin remote")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git remote get-url origin failed: {stderr}");
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn github_repo_web_url(origin: &str) -> Option<String> {
let trimmed = origin.trim().trim_end_matches(".git");
if trimmed.is_empty() {
return None;
}
if let Some(rest) = trimmed.strip_prefix("git@") {
let (host, path) = rest.split_once(':')?;
return Some(format!("https://{host}/{}", path.trim_start_matches('/')));
}
if let Some(rest) = trimmed.strip_prefix("ssh://") {
return parse_httpish_remote(rest);
}
if let Some(rest) = trimmed.strip_prefix("https://") {
return parse_httpish_remote(rest);
}
if let Some(rest) = trimmed.strip_prefix("http://") {
return parse_httpish_remote(rest);
}
None
}
fn parse_httpish_remote(rest: &str) -> Option<String> {
let without_user = rest.strip_prefix("git@").unwrap_or(rest);
let (host, path) = without_user.split_once('/')?;
Some(format!("https://{host}/{}", path.trim_start_matches('/')))
}
fn percent_encode_git_ref(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
let ch = byte as char;
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') {
encoded.push(ch);
} else {
encoded.push('%');
encoded.push_str(&format!("{byte:02X}"));
}
}
encoded
}
pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result<Vec<String>> { pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result<Vec<String>> {
let mut preview = Vec::new(); let mut preview = Vec::new();
let base_ref = format!("{}...HEAD", worktree.base_branch); let base_ref = format!("{}...HEAD", worktree.base_branch);
@@ -1730,6 +1805,47 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn github_compare_url_uses_origin_remote_and_encodes_refs() -> Result<()> {
let root = std::env::temp_dir().join(format!("ecc2-compare-url-{}", Uuid::new_v4()));
let repo = init_repo(&root)?;
run_git(
&repo,
&["remote", "add", "origin", "git@github.com:example/ecc.git"],
)?;
let worktree = WorktreeInfo {
path: repo.clone(),
branch: "ecc/worker-123".to_string(),
base_branch: "main".to_string(),
};
let url = github_compare_url(&worktree)?.expect("compare url");
assert_eq!(
url,
"https://github.com/example/ecc/compare/main...ecc%2Fworker-123?expand=1"
);
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn github_repo_web_url_supports_multiple_remote_formats() {
assert_eq!(
github_repo_web_url("git@github.com:example/ecc.git").as_deref(),
Some("https://github.com/example/ecc")
);
assert_eq!(
github_repo_web_url("https://github.example.com/org/repo.git").as_deref(),
Some("https://github.example.com/org/repo")
);
assert_eq!(
github_repo_web_url("ssh://git@github.example.com/org/repo.git").as_deref(),
Some("https://github.example.com/org/repo")
);
}
#[test] #[test]
fn create_for_session_links_shared_node_modules_cache() -> Result<()> { fn create_for_session_links_shared_node_modules_cache() -> Result<()> {
let root = let root =