mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-13 19:51:24 +08:00
Compare commits
17 Commits
75c2503abd
...
beaba1ca15
| Author | SHA1 | Date | |
|---|---|---|---|
| beaba1ca15 | |||
| 315b87d391 | |||
| 4adb3324ef | |||
| 08f0e86d76 | |||
| 8653d6d5d5 | |||
| 194bf605c2 | |||
| 1e4d6a4161 | |||
| e48468a9e7 | |||
| ea0fb3c0fc | |||
| b48a52f9a0 | |||
| 913c00c74d | |||
| 8936d09951 | |||
| 599a9d1e7b | |||
| 5fb2e62216 | |||
| b45a6ca810 | |||
| a4d0a4fc14 | |||
| 491ee81889 |
Generated
+154
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -300,6 +306,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.28.1"
|
||||
@@ -507,6 +522,7 @@ dependencies = [
|
||||
"toml",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"ureq",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -592,6 +608,16 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1141,6 +1167,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -1612,6 +1648,20 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
@@ -1661,6 +1711,41 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -1794,6 +1879,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.2"
|
||||
@@ -1855,6 +1946,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
@@ -2208,6 +2305,30 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -2374,6 +2495,24 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wezterm-bidi"
|
||||
version = "0.2.3"
|
||||
@@ -2527,6 +2666,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -2776,6 +2924,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@@ -27,6 +27,7 @@ serde_json = "1"
|
||||
toml = "0.8"
|
||||
regex = "1"
|
||||
sha2 = "0.10"
|
||||
ureq = { version = "2", features = ["json"] }
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
+776
-41
@@ -1,8 +1,14 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::notifications::{
|
||||
CompletionSummaryConfig, DesktopNotificationConfig, WebhookNotificationConfig,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PaneLayout {
|
||||
@@ -28,6 +34,95 @@ pub struct BudgetAlertThresholds {
|
||||
pub critical: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConflictResolutionStrategy {
|
||||
Escalate,
|
||||
LastWriteWins,
|
||||
Merge,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ConflictResolutionConfig {
|
||||
pub enabled: bool,
|
||||
pub strategy: ConflictResolutionStrategy,
|
||||
pub notify_lead: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct AgentProfileConfig {
|
||||
pub inherits: Option<String>,
|
||||
pub agent: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub allowed_tools: Vec<String>,
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub permission_mode: Option<String>,
|
||||
pub add_dirs: Vec<PathBuf>,
|
||||
pub max_budget_usd: Option<f64>,
|
||||
pub token_budget: Option<u64>,
|
||||
pub append_system_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ResolvedAgentProfile {
|
||||
pub profile_name: String,
|
||||
pub agent: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub allowed_tools: Vec<String>,
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub permission_mode: Option<String>,
|
||||
pub add_dirs: Vec<PathBuf>,
|
||||
pub max_budget_usd: Option<f64>,
|
||||
pub token_budget: Option<u64>,
|
||||
pub append_system_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct OrchestrationTemplateConfig {
|
||||
pub description: Option<String>,
|
||||
pub project: Option<String>,
|
||||
pub task_group: Option<String>,
|
||||
pub agent: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub worktree: Option<bool>,
|
||||
pub steps: Vec<OrchestrationTemplateStepConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct OrchestrationTemplateStepConfig {
|
||||
pub name: Option<String>,
|
||||
pub task: String,
|
||||
pub agent: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub worktree: Option<bool>,
|
||||
pub project: Option<String>,
|
||||
pub task_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolvedOrchestrationTemplate {
|
||||
pub template_name: String,
|
||||
pub description: Option<String>,
|
||||
pub project: Option<String>,
|
||||
pub task_group: Option<String>,
|
||||
pub steps: Vec<ResolvedOrchestrationTemplateStep>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolvedOrchestrationTemplateStep {
|
||||
pub name: String,
|
||||
pub task: String,
|
||||
pub agent: Option<String>,
|
||||
pub profile: Option<String>,
|
||||
pub worktree: bool,
|
||||
pub project: Option<String>,
|
||||
pub task_group: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
@@ -41,13 +136,20 @@ pub struct Config {
|
||||
pub heartbeat_interval_secs: u64,
|
||||
pub auto_terminate_stale_sessions: bool,
|
||||
pub default_agent: String,
|
||||
pub default_agent_profile: Option<String>,
|
||||
pub agent_profiles: BTreeMap<String, AgentProfileConfig>,
|
||||
pub orchestration_templates: BTreeMap<String, OrchestrationTemplateConfig>,
|
||||
pub auto_dispatch_unread_handoffs: bool,
|
||||
pub auto_dispatch_limit_per_session: usize,
|
||||
pub auto_create_worktrees: bool,
|
||||
pub auto_merge_ready_worktrees: bool,
|
||||
pub desktop_notifications: DesktopNotificationConfig,
|
||||
pub webhook_notifications: WebhookNotificationConfig,
|
||||
pub completion_summary_notifications: CompletionSummaryConfig,
|
||||
pub cost_budget_usd: f64,
|
||||
pub token_budget: u64,
|
||||
pub budget_alert_thresholds: BudgetAlertThresholds,
|
||||
pub conflict_resolution: ConflictResolutionConfig,
|
||||
pub theme: Theme,
|
||||
pub pane_layout: PaneLayout,
|
||||
pub pane_navigation: PaneNavigationConfig,
|
||||
@@ -69,11 +171,6 @@ pub struct PaneNavigationConfig {
|
||||
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),
|
||||
@@ -103,13 +200,20 @@ impl Default for Config {
|
||||
heartbeat_interval_secs: 30,
|
||||
auto_terminate_stale_sessions: false,
|
||||
default_agent: "claude".to_string(),
|
||||
default_agent_profile: None,
|
||||
agent_profiles: BTreeMap::new(),
|
||||
orchestration_templates: BTreeMap::new(),
|
||||
auto_dispatch_unread_handoffs: false,
|
||||
auto_dispatch_limit_per_session: 5,
|
||||
auto_create_worktrees: true,
|
||||
auto_merge_ready_worktrees: false,
|
||||
desktop_notifications: DesktopNotificationConfig::default(),
|
||||
webhook_notifications: WebhookNotificationConfig::default(),
|
||||
completion_summary_notifications: CompletionSummaryConfig::default(),
|
||||
cost_budget_usd: 10.0,
|
||||
token_budget: 500_000,
|
||||
budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS,
|
||||
conflict_resolution: ConflictResolutionConfig::default(),
|
||||
theme: Theme::Dark,
|
||||
pane_layout: PaneLayout::Horizontal,
|
||||
pane_navigation: PaneNavigationConfig::default(),
|
||||
@@ -134,10 +238,7 @@ impl Config {
|
||||
};
|
||||
|
||||
pub fn config_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".claude")
|
||||
.join("ecc2.toml")
|
||||
Self::config_root().join("ecc2").join("config.toml")
|
||||
}
|
||||
|
||||
pub fn cost_metrics_path(&self) -> PathBuf {
|
||||
@@ -160,49 +261,212 @@ impl Config {
|
||||
self.budget_alert_thresholds.sanitized()
|
||||
}
|
||||
|
||||
pub fn resolve_agent_profile(&self, name: &str) -> Result<ResolvedAgentProfile> {
|
||||
let mut chain = Vec::new();
|
||||
self.resolve_agent_profile_inner(name, &mut chain)
|
||||
}
|
||||
|
||||
pub fn resolve_orchestration_template(
|
||||
&self,
|
||||
name: &str,
|
||||
vars: &BTreeMap<String, String>,
|
||||
) -> Result<ResolvedOrchestrationTemplate> {
|
||||
let template = self
|
||||
.orchestration_templates
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown orchestration template: {name}"))?;
|
||||
|
||||
if template.steps.is_empty() {
|
||||
anyhow::bail!("orchestration template {name} has no steps");
|
||||
}
|
||||
|
||||
let description = interpolate_optional_string(template.description.as_deref(), vars)?;
|
||||
let project = interpolate_optional_string(template.project.as_deref(), vars)?;
|
||||
let task_group = interpolate_optional_string(template.task_group.as_deref(), vars)?;
|
||||
let default_agent = interpolate_optional_string(template.agent.as_deref(), vars)?;
|
||||
let default_profile = interpolate_optional_string(template.profile.as_deref(), vars)?;
|
||||
if let Some(profile_name) = default_profile.as_deref() {
|
||||
self.resolve_agent_profile(profile_name)?;
|
||||
}
|
||||
|
||||
let mut steps = Vec::with_capacity(template.steps.len());
|
||||
for (index, step) in template.steps.iter().enumerate() {
|
||||
let task = interpolate_required_string(&step.task, vars).with_context(|| {
|
||||
format!(
|
||||
"resolve task for orchestration template {name} step {}",
|
||||
index + 1
|
||||
)
|
||||
})?;
|
||||
let step_name = interpolate_optional_string(step.name.as_deref(), vars)?
|
||||
.unwrap_or_else(|| format!("step {}", index + 1));
|
||||
let agent = interpolate_optional_string(
|
||||
step.agent.as_deref().or(default_agent.as_deref()),
|
||||
vars,
|
||||
)?;
|
||||
let profile = interpolate_optional_string(
|
||||
step.profile.as_deref().or(default_profile.as_deref()),
|
||||
vars,
|
||||
)?;
|
||||
if let Some(profile_name) = profile.as_deref() {
|
||||
self.resolve_agent_profile(profile_name)?;
|
||||
}
|
||||
|
||||
steps.push(ResolvedOrchestrationTemplateStep {
|
||||
name: step_name,
|
||||
task,
|
||||
agent,
|
||||
profile,
|
||||
worktree: step
|
||||
.worktree
|
||||
.or(template.worktree)
|
||||
.unwrap_or(self.auto_create_worktrees),
|
||||
project: interpolate_optional_string(
|
||||
step.project.as_deref().or(project.as_deref()),
|
||||
vars,
|
||||
)?,
|
||||
task_group: interpolate_optional_string(
|
||||
step.task_group.as_deref().or(task_group.as_deref()),
|
||||
vars,
|
||||
)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ResolvedOrchestrationTemplate {
|
||||
template_name: name.to_string(),
|
||||
description,
|
||||
project,
|
||||
task_group,
|
||||
steps,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_agent_profile_inner(
|
||||
&self,
|
||||
name: &str,
|
||||
chain: &mut Vec<String>,
|
||||
) -> Result<ResolvedAgentProfile> {
|
||||
if chain.iter().any(|existing| existing == name) {
|
||||
chain.push(name.to_string());
|
||||
anyhow::bail!("agent profile inheritance cycle: {}", chain.join(" -> "));
|
||||
}
|
||||
|
||||
let profile = self
|
||||
.agent_profiles
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("Unknown agent profile: {name}"))?;
|
||||
|
||||
chain.push(name.to_string());
|
||||
let mut resolved = if let Some(parent) = profile.inherits.as_deref() {
|
||||
self.resolve_agent_profile_inner(parent, chain)?
|
||||
} else {
|
||||
ResolvedAgentProfile::default()
|
||||
};
|
||||
chain.pop();
|
||||
|
||||
resolved.apply(name, profile);
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = Self::config_path();
|
||||
let project_path = std::env::current_dir()
|
||||
let global_paths = Self::global_config_paths();
|
||||
let project_paths = std::env::current_dir()
|
||||
.ok()
|
||||
.and_then(|cwd| Self::project_config_path_from(&cwd));
|
||||
Self::load_from_paths(&config_path, project_path.as_deref())
|
||||
.map(|cwd| Self::project_config_paths_from(&cwd))
|
||||
.unwrap_or_default();
|
||||
Self::load_from_paths(&global_paths, &project_paths)
|
||||
}
|
||||
|
||||
fn load_from_paths(
|
||||
config_path: &std::path::Path,
|
||||
project_override_path: Option<&std::path::Path>,
|
||||
global_paths: &[PathBuf],
|
||||
project_override_paths: &[PathBuf],
|
||||
) -> Result<Self> {
|
||||
let mut config = if config_path.exists() {
|
||||
let content = std::fs::read_to_string(config_path)?;
|
||||
toml::from_str(&content)?
|
||||
} else {
|
||||
Config::default()
|
||||
};
|
||||
let mut merged = toml::Value::try_from(Self::default())
|
||||
.context("serialize default ECC 2.0 config for layered merge")?;
|
||||
|
||||
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;
|
||||
for path in global_paths.iter().chain(project_override_paths.iter()) {
|
||||
if path.exists() {
|
||||
Self::merge_config_file(&mut merged, path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
merged
|
||||
.try_into()
|
||||
.context("deserialize merged ECC 2.0 config")
|
||||
}
|
||||
|
||||
fn project_config_path_from(start: &std::path::Path) -> Option<PathBuf> {
|
||||
let global = Self::config_path();
|
||||
fn config_root() -> PathBuf {
|
||||
dirs::config_dir().unwrap_or_else(|| {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".config")
|
||||
})
|
||||
}
|
||||
|
||||
fn legacy_global_config_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".claude")
|
||||
.join("ecc2.toml")
|
||||
}
|
||||
|
||||
fn global_config_paths() -> Vec<PathBuf> {
|
||||
let legacy = Self::legacy_global_config_path();
|
||||
let primary = Self::config_path();
|
||||
|
||||
if legacy == primary {
|
||||
vec![primary]
|
||||
} else {
|
||||
vec![legacy, primary]
|
||||
}
|
||||
}
|
||||
|
||||
fn project_config_paths_from(start: &std::path::Path) -> Vec<PathBuf> {
|
||||
let global_paths = Self::global_config_paths();
|
||||
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);
|
||||
let legacy = path.join(".claude").join("ecc2.toml");
|
||||
let primary = path.join("ecc2.toml");
|
||||
let mut matches = Vec::new();
|
||||
|
||||
if legacy.exists() && !global_paths.iter().any(|global| global == &legacy) {
|
||||
matches.push(legacy);
|
||||
}
|
||||
if primary.exists() && !global_paths.iter().any(|global| global == &primary) {
|
||||
matches.push(primary);
|
||||
}
|
||||
|
||||
if !matches.is_empty() {
|
||||
return matches;
|
||||
}
|
||||
current = path.parent();
|
||||
}
|
||||
|
||||
None
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn merge_config_file(base: &mut toml::Value, path: &std::path::Path) -> Result<()> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read ECC 2.0 config from {}", path.display()))?;
|
||||
let overlay: toml::Value = toml::from_str(&content)
|
||||
.with_context(|| format!("parse ECC 2.0 config from {}", path.display()))?;
|
||||
Self::merge_toml_values(base, overlay);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge_toml_values(base: &mut toml::Value, overlay: toml::Value) {
|
||||
match (base, overlay) {
|
||||
(toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
|
||||
for (key, overlay_value) in overlay_table {
|
||||
if let Some(base_value) = base_table.get_mut(&key) {
|
||||
Self::merge_toml_values(base_value, overlay_value);
|
||||
} else {
|
||||
base_table.insert(key, overlay_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
(base_value, overlay_value) => *base_value = overlay_value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
@@ -344,6 +608,115 @@ impl Default for BudgetAlertThresholds {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConflictResolutionStrategy {
|
||||
fn default() -> Self {
|
||||
Self::Escalate
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConflictResolutionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
strategy: ConflictResolutionStrategy::Escalate,
|
||||
notify_lead: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedAgentProfile {
|
||||
fn apply(&mut self, profile_name: &str, config: &AgentProfileConfig) {
|
||||
self.profile_name = profile_name.to_string();
|
||||
if let Some(agent) = config.agent.as_ref() {
|
||||
self.agent = Some(agent.clone());
|
||||
}
|
||||
if let Some(model) = config.model.as_ref() {
|
||||
self.model = Some(model.clone());
|
||||
}
|
||||
merge_unique(&mut self.allowed_tools, &config.allowed_tools);
|
||||
merge_unique(&mut self.disallowed_tools, &config.disallowed_tools);
|
||||
if let Some(permission_mode) = config.permission_mode.as_ref() {
|
||||
self.permission_mode = Some(permission_mode.clone());
|
||||
}
|
||||
merge_unique(&mut self.add_dirs, &config.add_dirs);
|
||||
if let Some(max_budget_usd) = config.max_budget_usd {
|
||||
self.max_budget_usd = Some(max_budget_usd);
|
||||
}
|
||||
if let Some(token_budget) = config.token_budget {
|
||||
self.token_budget = Some(token_budget);
|
||||
}
|
||||
self.append_system_prompt = match (
|
||||
self.append_system_prompt.take(),
|
||||
config.append_system_prompt.as_ref(),
|
||||
) {
|
||||
(Some(parent), Some(child)) => Some(format!("{parent}\n\n{child}")),
|
||||
(Some(parent), None) => Some(parent),
|
||||
(None, Some(child)) => Some(child.clone()),
|
||||
(None, None) => None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_unique<T>(base: &mut Vec<T>, additions: &[T])
|
||||
where
|
||||
T: Clone + PartialEq,
|
||||
{
|
||||
for value in additions {
|
||||
if !base.contains(value) {
|
||||
base.push(value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn interpolate_optional_string(
|
||||
value: Option<&str>,
|
||||
vars: &BTreeMap<String, String>,
|
||||
) -> Result<Option<String>> {
|
||||
value
|
||||
.map(|value| interpolate_required_string(value, vars))
|
||||
.transpose()
|
||||
.map(|value| {
|
||||
value.and_then(|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn interpolate_required_string(value: &str, vars: &BTreeMap<String, String>) -> Result<String> {
|
||||
let placeholder = Regex::new(r"\{\{\s*([A-Za-z0-9_-]+)\s*\}\}")
|
||||
.expect("orchestration template placeholder regex");
|
||||
let mut missing = Vec::new();
|
||||
let rendered = placeholder.replace_all(value, |captures: ®ex::Captures<'_>| {
|
||||
let key = captures
|
||||
.get(1)
|
||||
.map(|capture| capture.as_str())
|
||||
.unwrap_or_default();
|
||||
match vars.get(key) {
|
||||
Some(value) => value.to_string(),
|
||||
None => {
|
||||
missing.push(key.to_string());
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if !missing.is_empty() {
|
||||
missing.sort();
|
||||
missing.dedup();
|
||||
anyhow::bail!(
|
||||
"missing orchestration template variable(s): {}",
|
||||
missing.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(rendered.into_owned())
|
||||
}
|
||||
|
||||
impl BudgetAlertThresholds {
|
||||
pub fn sanitized(self) -> Self {
|
||||
let values = [self.advisory, self.warning, self.critical];
|
||||
@@ -363,8 +736,13 @@ impl BudgetAlertThresholds {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BudgetAlertThresholds, Config, PaneLayout};
|
||||
use super::{
|
||||
BudgetAlertThresholds, Config, ConflictResolutionConfig, ConflictResolutionStrategy,
|
||||
PaneLayout,
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
@@ -407,6 +785,7 @@ theme = "Dark"
|
||||
config.budget_alert_thresholds,
|
||||
defaults.budget_alert_thresholds
|
||||
);
|
||||
assert_eq!(config.conflict_resolution, defaults.conflict_resolution);
|
||||
assert_eq!(config.pane_layout, defaults.pane_layout);
|
||||
assert_eq!(config.pane_navigation, defaults.pane_navigation);
|
||||
assert_eq!(
|
||||
@@ -431,6 +810,8 @@ theme = "Dark"
|
||||
config.auto_merge_ready_worktrees,
|
||||
defaults.auto_merge_ready_worktrees
|
||||
);
|
||||
assert_eq!(config.desktop_notifications, defaults.desktop_notifications);
|
||||
assert_eq!(config.webhook_notifications, defaults.webhook_notifications);
|
||||
assert_eq!(
|
||||
config.auto_terminate_stale_sessions,
|
||||
defaults.auto_terminate_stale_sessions
|
||||
@@ -465,20 +846,94 @@ theme = "Dark"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_worktree_limit_override_replaces_global_limit() {
|
||||
fn layered_config_merges_global_and_project_overrides() {
|
||||
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");
|
||||
let legacy_global_path = tempdir.join("legacy-global.toml");
|
||||
let global_path = tempdir.join("config.toml");
|
||||
let project_path = tempdir.join("ecc2.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();
|
||||
std::fs::write(
|
||||
&legacy_global_path,
|
||||
r#"
|
||||
max_parallel_worktrees = 6
|
||||
auto_create_worktrees = false
|
||||
|
||||
let config = Config::load_from_paths(&global_path, Some(&project_path)).unwrap();
|
||||
[desktop_notifications]
|
||||
enabled = true
|
||||
session_completed = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&global_path,
|
||||
r#"
|
||||
auto_merge_ready_worktrees = true
|
||||
|
||||
[pane_navigation]
|
||||
focus_sessions = "q"
|
||||
move_right = "d"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&project_path,
|
||||
r#"
|
||||
max_parallel_worktrees = 2
|
||||
auto_dispatch_limit_per_session = 9
|
||||
|
||||
[desktop_notifications]
|
||||
approval_requests = false
|
||||
|
||||
[pane_navigation]
|
||||
focus_metrics = "e"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let config =
|
||||
Config::load_from_paths(&[legacy_global_path, global_path], &[project_path]).unwrap();
|
||||
assert_eq!(config.max_parallel_worktrees, 2);
|
||||
assert!(!config.auto_create_worktrees);
|
||||
assert!(config.auto_merge_ready_worktrees);
|
||||
assert_eq!(config.auto_dispatch_limit_per_session, 9);
|
||||
assert!(config.desktop_notifications.enabled);
|
||||
assert!(!config.desktop_notifications.session_completed);
|
||||
assert!(!config.desktop_notifications.approval_requests);
|
||||
assert_eq!(config.pane_navigation.focus_sessions, "q");
|
||||
assert_eq!(config.pane_navigation.focus_metrics, "e");
|
||||
assert_eq!(config.pane_navigation.move_right, "d");
|
||||
|
||||
let _ = std::fs::remove_dir_all(tempdir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_config_discovery_prefers_nearest_directory_and_new_path() {
|
||||
let tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4()));
|
||||
let project_root = tempdir.join("project");
|
||||
let nested_dir = project_root.join("src").join("module");
|
||||
std::fs::create_dir_all(project_root.join(".claude")).unwrap();
|
||||
std::fs::create_dir_all(&nested_dir).unwrap();
|
||||
std::fs::write(project_root.join(".claude").join("ecc2.toml"), "").unwrap();
|
||||
std::fs::write(project_root.join("ecc2.toml"), "").unwrap();
|
||||
|
||||
let paths = Config::project_config_paths_from(&nested_dir);
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
project_root.join(".claude").join("ecc2.toml"),
|
||||
project_root.join("ecc2.toml")
|
||||
]
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(tempdir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_config_path_uses_xdg_style_location() {
|
||||
let path = Config::config_path();
|
||||
assert!(path.ends_with("ecc2/config.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_navigation_deserializes_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
@@ -582,6 +1037,254 @@ critical = 0.85
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_notifications_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[desktop_notifications]
|
||||
enabled = true
|
||||
session_completed = false
|
||||
session_failed = true
|
||||
budget_alerts = true
|
||||
approval_requests = false
|
||||
|
||||
[desktop_notifications.quiet_hours]
|
||||
enabled = true
|
||||
start_hour = 21
|
||||
end_hour = 7
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(config.desktop_notifications.enabled);
|
||||
assert!(!config.desktop_notifications.session_completed);
|
||||
assert!(config.desktop_notifications.session_failed);
|
||||
assert!(config.desktop_notifications.budget_alerts);
|
||||
assert!(!config.desktop_notifications.approval_requests);
|
||||
assert!(config.desktop_notifications.quiet_hours.enabled);
|
||||
assert_eq!(config.desktop_notifications.quiet_hours.start_hour, 21);
|
||||
assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_resolution_deserializes_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[conflict_resolution]
|
||||
enabled = true
|
||||
strategy = "last_write_wins"
|
||||
notify_lead = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
config.conflict_resolution,
|
||||
ConflictResolutionConfig {
|
||||
enabled: true,
|
||||
strategy: ConflictResolutionStrategy::LastWriteWins,
|
||||
notify_lead: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_profiles_resolve_inheritance_and_defaults() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
default_agent_profile = "reviewer"
|
||||
|
||||
[agent_profiles.base]
|
||||
model = "sonnet"
|
||||
allowed_tools = ["Read"]
|
||||
permission_mode = "plan"
|
||||
add_dirs = ["docs"]
|
||||
append_system_prompt = "Be careful."
|
||||
|
||||
[agent_profiles.reviewer]
|
||||
inherits = "base"
|
||||
allowed_tools = ["Edit"]
|
||||
disallowed_tools = ["Bash"]
|
||||
token_budget = 1200
|
||||
append_system_prompt = "Review thoroughly."
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let profile = config.resolve_agent_profile("reviewer").unwrap();
|
||||
assert_eq!(config.default_agent_profile.as_deref(), Some("reviewer"));
|
||||
assert_eq!(profile.profile_name, "reviewer");
|
||||
assert_eq!(profile.model.as_deref(), Some("sonnet"));
|
||||
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
|
||||
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
|
||||
assert_eq!(profile.permission_mode.as_deref(), Some("plan"));
|
||||
assert_eq!(profile.add_dirs, vec![PathBuf::from("docs")]);
|
||||
assert_eq!(profile.token_budget, Some(1200));
|
||||
assert_eq!(
|
||||
profile.append_system_prompt.as_deref(),
|
||||
Some("Be careful.\n\nReview thoroughly.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_profile_resolution_rejects_inheritance_cycles() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[agent_profiles.a]
|
||||
inherits = "b"
|
||||
|
||||
[agent_profiles.b]
|
||||
inherits = "a"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = config
|
||||
.resolve_agent_profile("a")
|
||||
.expect_err("profile inheritance cycles must fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("agent profile inheritance cycle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_templates_resolve_steps_and_interpolate_variables() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
default_agent = "claude"
|
||||
default_agent_profile = "reviewer"
|
||||
|
||||
[agent_profiles.reviewer]
|
||||
model = "sonnet"
|
||||
|
||||
[orchestration_templates.feature_development]
|
||||
description = "Ship {{task}}"
|
||||
project = "{{project}}"
|
||||
task_group = "{{task_group}}"
|
||||
profile = "reviewer"
|
||||
worktree = true
|
||||
|
||||
[[orchestration_templates.feature_development.steps]]
|
||||
name = "planner"
|
||||
task = "Plan {{task}}"
|
||||
agent = "claude"
|
||||
|
||||
[[orchestration_templates.feature_development.steps]]
|
||||
name = "reviewer"
|
||||
task = "Review {{task}} in {{component}}"
|
||||
profile = "reviewer"
|
||||
worktree = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let vars = BTreeMap::from([
|
||||
("task".to_string(), "stabilize auth callback".to_string()),
|
||||
("project".to_string(), "ecc-core".to_string()),
|
||||
("task_group".to_string(), "auth callback".to_string()),
|
||||
("component".to_string(), "billing".to_string()),
|
||||
]);
|
||||
let template = config
|
||||
.resolve_orchestration_template("feature_development", &vars)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(template.template_name, "feature_development");
|
||||
assert_eq!(
|
||||
template.description.as_deref(),
|
||||
Some("Ship stabilize auth callback")
|
||||
);
|
||||
assert_eq!(template.project.as_deref(), Some("ecc-core"));
|
||||
assert_eq!(template.task_group.as_deref(), Some("auth callback"));
|
||||
assert_eq!(template.steps.len(), 2);
|
||||
assert_eq!(template.steps[0].name, "planner");
|
||||
assert_eq!(template.steps[0].task, "Plan stabilize auth callback");
|
||||
assert_eq!(template.steps[0].agent.as_deref(), Some("claude"));
|
||||
assert_eq!(template.steps[0].profile.as_deref(), Some("reviewer"));
|
||||
assert!(template.steps[0].worktree);
|
||||
assert_eq!(
|
||||
template.steps[1].task,
|
||||
"Review stabilize auth callback in billing"
|
||||
);
|
||||
assert!(!template.steps[1].worktree);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestration_templates_fail_when_required_variables_are_missing() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[orchestration_templates.feature_development]
|
||||
[[orchestration_templates.feature_development.steps]]
|
||||
task = "Plan {{task}} for {{component}}"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = config
|
||||
.resolve_orchestration_template(
|
||||
"feature_development",
|
||||
&BTreeMap::from([("task".to_string(), "fix retry".to_string())]),
|
||||
)
|
||||
.expect_err("missing template variables must fail");
|
||||
let error_text = format!("{error:#}");
|
||||
assert!(error_text
|
||||
.contains("resolve task for orchestration template feature_development step 1"));
|
||||
assert!(error_text.contains("missing orchestration template variable(s): component"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
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]
|
||||
fn invalid_budget_alert_thresholds_fall_back_to_defaults() {
|
||||
let config: Config = toml::from_str(
|
||||
@@ -608,12 +1311,25 @@ critical = 1.10
|
||||
config.auto_dispatch_limit_per_session = 9;
|
||||
config.auto_create_worktrees = false;
|
||||
config.auto_merge_ready_worktrees = true;
|
||||
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 =
|
||||
crate::notifications::CompletionSummaryDelivery::TuiPopup;
|
||||
config.desktop_notifications.quiet_hours.enabled = true;
|
||||
config.desktop_notifications.quiet_hours.start_hour = 21;
|
||||
config.desktop_notifications.quiet_hours.end_hour = 7;
|
||||
config.worktree_branch_prefix = "bots/ecc".to_string();
|
||||
config.budget_alert_thresholds = BudgetAlertThresholds {
|
||||
advisory: 0.45,
|
||||
warning: 0.70,
|
||||
critical: 0.88,
|
||||
};
|
||||
config.conflict_resolution.strategy = ConflictResolutionStrategy::Merge;
|
||||
config.conflict_resolution.notify_lead = false;
|
||||
config.pane_navigation.focus_metrics = "e".to_string();
|
||||
config.pane_navigation.move_right = "d".to_string();
|
||||
config.linear_pane_size_percent = 42;
|
||||
@@ -627,6 +1343,20 @@ critical = 1.10
|
||||
assert_eq!(loaded.auto_dispatch_limit_per_session, 9);
|
||||
assert!(!loaded.auto_create_worktrees);
|
||||
assert!(loaded.auto_merge_ready_worktrees);
|
||||
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!(
|
||||
loaded.completion_summary_notifications.delivery,
|
||||
crate::notifications::CompletionSummaryDelivery::TuiPopup
|
||||
);
|
||||
assert!(loaded.desktop_notifications.quiet_hours.enabled);
|
||||
assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21);
|
||||
assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7);
|
||||
assert_eq!(loaded.worktree_branch_prefix, "bots/ecc");
|
||||
assert_eq!(
|
||||
loaded.budget_alert_thresholds,
|
||||
@@ -636,6 +1366,11 @@ critical = 1.10
|
||||
critical: 0.88,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
loaded.conflict_resolution.strategy,
|
||||
ConflictResolutionStrategy::Merge
|
||||
);
|
||||
assert!(!loaded.conflict_resolution.notify_lead);
|
||||
assert_eq!(loaded.pane_navigation.focus_metrics, "e");
|
||||
assert_eq!(loaded.pane_navigation.move_right, "d");
|
||||
assert_eq!(loaded.linear_pane_size_percent, 42);
|
||||
|
||||
+1063
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,635 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Local, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use anyhow::Context;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotificationEvent {
|
||||
SessionStarted,
|
||||
SessionCompleted,
|
||||
SessionFailed,
|
||||
BudgetAlert,
|
||||
ApprovalRequest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct QuietHoursConfig {
|
||||
pub enabled: bool,
|
||||
pub start_hour: u8,
|
||||
pub end_hour: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DesktopNotificationConfig {
|
||||
pub enabled: bool,
|
||||
pub session_started: bool,
|
||||
pub session_completed: bool,
|
||||
pub session_failed: bool,
|
||||
pub budget_alerts: bool,
|
||||
pub approval_requests: bool,
|
||||
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, 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)]
|
||||
pub struct DesktopNotifier {
|
||||
config: DesktopNotificationConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebhookNotifier {
|
||||
config: WebhookNotificationConfig,
|
||||
}
|
||||
|
||||
impl Default for QuietHoursConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
start_hour: 22,
|
||||
end_hour: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QuietHoursConfig {
|
||||
pub fn sanitized(self) -> Self {
|
||||
let valid = self.start_hour <= 23 && self.end_hour <= 23;
|
||||
if valid {
|
||||
self
|
||||
} else {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self, now: DateTime<Local>) -> bool {
|
||||
if !self.enabled {
|
||||
return false;
|
||||
}
|
||||
|
||||
let quiet = self.clone().sanitized();
|
||||
if quiet.start_hour == quiet.end_hour {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hour = now.hour() as u8;
|
||||
if quiet.start_hour < quiet.end_hour {
|
||||
hour >= quiet.start_hour && hour < quiet.end_hour
|
||||
} else {
|
||||
hour >= quiet.start_hour || hour < quiet.end_hour
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DesktopNotificationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
session_started: false,
|
||||
session_completed: true,
|
||||
session_failed: true,
|
||||
budget_alerts: true,
|
||||
approval_requests: true,
|
||||
quiet_hours: QuietHoursConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopNotificationConfig {
|
||||
pub fn sanitized(self) -> Self {
|
||||
Self {
|
||||
quiet_hours: self.quiet_hours.sanitized(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allows(&self, event: NotificationEvent, now: DateTime<Local>) -> bool {
|
||||
let config = self.clone().sanitized();
|
||||
if !config.enabled || config.quiet_hours.is_active(now) {
|
||||
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 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 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 {
|
||||
pub fn new(config: DesktopNotificationConfig) -> Self {
|
||||
Self {
|
||||
config: config.sanitized(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn notify(&self, event: NotificationEvent, title: &str, body: &str) -> bool {
|
||||
match self.try_notify(event, title, body, Local::now()) {
|
||||
Ok(sent) => sent,
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to send desktop notification: {error}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_notify(
|
||||
&self,
|
||||
event: NotificationEvent,
|
||||
title: &str,
|
||||
body: &str,
|
||||
now: DateTime<Local>,
|
||||
) -> Result<bool> {
|
||||
if !self.config.allows(event, now) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some((program, args)) = notification_command(std::env::consts::OS, title, body) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
run_notification_command(&program, &args)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
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>)> {
|
||||
match platform {
|
||||
"macos" => Some((
|
||||
"osascript".to_string(),
|
||||
vec![
|
||||
"-e".to_string(),
|
||||
format!(
|
||||
"display notification \"{}\" with title \"{}\"",
|
||||
sanitize_osascript(body),
|
||||
sanitize_osascript(title)
|
||||
),
|
||||
],
|
||||
)),
|
||||
"linux" => Some((
|
||||
"notify-send".to_string(),
|
||||
vec![
|
||||
"--app-name".to_string(),
|
||||
"ECC 2.0".to_string(),
|
||||
title.trim().to_string(),
|
||||
body.trim().to_string(),
|
||||
],
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
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))]
|
||||
fn run_notification_command(program: &str, args: &[String]) -> Result<()> {
|
||||
let status = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("launch {program}"))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("{program} exited with {status}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> {
|
||||
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 {
|
||||
value
|
||||
.replace('\\', "")
|
||||
.replace('"', "\u{201C}")
|
||||
.replace('\n', " ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
notification_command, webhook_payload, CompletionSummaryDelivery,
|
||||
DesktopNotificationConfig, DesktopNotifier, NotificationEvent, QuietHoursConfig,
|
||||
WebhookNotificationConfig, WebhookNotifier, WebhookProvider, WebhookTarget,
|
||||
};
|
||||
use chrono::{Local, TimeZone};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn quiet_hours_support_cross_midnight_ranges() {
|
||||
let quiet_hours = QuietHoursConfig {
|
||||
enabled: true,
|
||||
start_hour: 22,
|
||||
end_hour: 8,
|
||||
};
|
||||
|
||||
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap()));
|
||||
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 7, 0, 0).unwrap()));
|
||||
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 14, 0, 0).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_hours_support_same_day_ranges() {
|
||||
let quiet_hours = QuietHoursConfig {
|
||||
enabled: true,
|
||||
start_hour: 9,
|
||||
end_hour: 17,
|
||||
};
|
||||
|
||||
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 10, 0, 0).unwrap()));
|
||||
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 18, 0, 0).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_preferences_respect_event_flags() {
|
||||
let mut config = DesktopNotificationConfig::default();
|
||||
config.session_completed = false;
|
||||
let now = Local.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap();
|
||||
|
||||
assert!(!config.allows(NotificationEvent::SessionCompleted, now));
|
||||
assert!(config.allows(NotificationEvent::BudgetAlert, now));
|
||||
assert!(!config.allows(NotificationEvent::SessionStarted, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifier_skips_delivery_during_quiet_hours() {
|
||||
let mut config = DesktopNotificationConfig::default();
|
||||
config.quiet_hours = QuietHoursConfig {
|
||||
enabled: true,
|
||||
start_hour: 22,
|
||||
end_hour: 8,
|
||||
};
|
||||
let notifier = DesktopNotifier::new(config);
|
||||
|
||||
assert!(!notifier
|
||||
.try_notify(
|
||||
NotificationEvent::ApprovalRequest,
|
||||
"ECC 2.0: Approval needed",
|
||||
"worker-123 needs review",
|
||||
Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap(),
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_notifications_use_osascript() {
|
||||
let (program, args) =
|
||||
notification_command("macos", "ECC 2.0: Completed", "Task finished").unwrap();
|
||||
|
||||
assert_eq!(program, "osascript");
|
||||
assert_eq!(args[0], "-e");
|
||||
assert!(args[1].contains("display notification"));
|
||||
assert!(args[1].contains("ECC 2.0: Completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_notifications_use_notify_send() {
|
||||
let (program, args) =
|
||||
notification_command("linux", "ECC 2.0: Approval needed", "worker-123").unwrap();
|
||||
|
||||
assert_eq!(program, "notify-send");
|
||||
assert_eq!(args[0], "--app-name");
|
||||
assert_eq!(args[1], "ECC 2.0");
|
||||
assert_eq!(args[2], "ECC 2.0: Approval needed");
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1202,9 +1202,11 @@ mod tests {
|
||||
invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(manager::WorktreeBulkMergeOutcome {
|
||||
merged: Vec::new(),
|
||||
rebased: Vec::new(),
|
||||
active_with_worktree_ids: Vec::new(),
|
||||
conflicted_session_ids: Vec::new(),
|
||||
dirty_worktree_ids: Vec::new(),
|
||||
blocked_by_queue_session_ids: Vec::new(),
|
||||
failures: Vec::new(),
|
||||
})
|
||||
}
|
||||
@@ -1239,9 +1241,16 @@ mod tests {
|
||||
cleaned_worktree: true,
|
||||
},
|
||||
],
|
||||
rebased: vec![manager::WorktreeRebaseOutcome {
|
||||
session_id: "worker-r".to_string(),
|
||||
branch: "ecc/worker-r".to_string(),
|
||||
base_branch: "main".to_string(),
|
||||
already_up_to_date: false,
|
||||
}],
|
||||
active_with_worktree_ids: vec!["worker-c".to_string()],
|
||||
conflicted_session_ids: vec!["worker-d".to_string()],
|
||||
dirty_worktree_ids: vec!["worker-e".to_string()],
|
||||
blocked_by_queue_session_ids: vec!["worker-f".to_string()],
|
||||
failures: Vec::new(),
|
||||
})
|
||||
})
|
||||
|
||||
+1369
-29
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,13 @@ pub mod store;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub type SessionAgentProfile = crate::config::ResolvedAgentProfile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
@@ -142,6 +145,59 @@ pub struct FileActivityEntry {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DecisionLogEntry {
|
||||
pub id: i64,
|
||||
pub session_id: String,
|
||||
pub decision: String,
|
||||
pub alternatives: Vec<String>,
|
||||
pub reasoning: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphEntity {
|
||||
pub id: i64,
|
||||
pub session_id: Option<String>,
|
||||
pub entity_type: String,
|
||||
pub name: String,
|
||||
pub path: Option<String>,
|
||||
pub summary: String,
|
||||
pub metadata: BTreeMap<String, String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphRelation {
|
||||
pub id: i64,
|
||||
pub session_id: Option<String>,
|
||||
pub from_entity_id: i64,
|
||||
pub from_entity_type: String,
|
||||
pub from_entity_name: String,
|
||||
pub to_entity_id: i64,
|
||||
pub to_entity_type: String,
|
||||
pub to_entity_name: String,
|
||||
pub relation_type: String,
|
||||
pub summary: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphEntityDetail {
|
||||
pub entity: ContextGraphEntity,
|
||||
pub outgoing: Vec<ContextGraphRelation>,
|
||||
pub incoming: Vec<ContextGraphRelation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ContextGraphSyncStats {
|
||||
pub sessions_scanned: usize,
|
||||
pub decisions_processed: usize,
|
||||
pub file_events_processed: usize,
|
||||
pub messages_processed: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FileActivityAction {
|
||||
|
||||
+1739
-6
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,18 @@ 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.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() {
|
||||
match (key.modifiers, key.code) {
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
||||
@@ -86,9 +98,13 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
(_, 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('K')) => dashboard.toggle_context_graph_mode(),
|
||||
(_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(),
|
||||
(_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(),
|
||||
(_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(),
|
||||
(_, KeyCode::Char('E')) if dashboard.is_context_graph_mode() => {
|
||||
dashboard.cycle_graph_entity_filter()
|
||||
}
|
||||
(_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(),
|
||||
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
|
||||
(_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(),
|
||||
@@ -97,6 +113,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
(_, KeyCode::Char('U')) => dashboard.unstage_selected_git_status(),
|
||||
(_, KeyCode::Char('R')) => dashboard.reset_selected_git_status(),
|
||||
(_, KeyCode::Char('C')) => dashboard.begin_commit_prompt(),
|
||||
(_, KeyCode::Char('P')) => dashboard.begin_pr_prompt(),
|
||||
(_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(),
|
||||
(_, KeyCode::Char('}')) => dashboard.next_diff_hunk(),
|
||||
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
|
||||
|
||||
+3225
-176
File diff suppressed because it is too large
Load Diff
+1044
-24
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user