pub mod daemon; pub mod manager; pub mod output; pub mod runtime; 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, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(rename_all = "snake_case")] pub enum HarnessKind { #[default] Unknown, Claude, Codex, OpenCode, Gemini, Cursor, Kiro, Trae, Zed, FactoryDroid, Windsurf, } impl HarnessKind { pub fn from_agent_type(agent_type: &str) -> Self { match agent_type.trim().to_ascii_lowercase().as_str() { "claude" | "claude-code" => Self::Claude, "codex" => Self::Codex, "opencode" => Self::OpenCode, "gemini" | "gemini-cli" => Self::Gemini, "cursor" => Self::Cursor, "kiro" => Self::Kiro, "trae" => Self::Trae, "zed" => Self::Zed, "factory-droid" | "factory_droid" | "factorydroid" => Self::FactoryDroid, "windsurf" => Self::Windsurf, _ => Self::Unknown, } } pub fn from_db_value(value: &str) -> Self { match value.trim().to_ascii_lowercase().as_str() { "claude" => Self::Claude, "codex" => Self::Codex, "opencode" => Self::OpenCode, "gemini" => Self::Gemini, "cursor" => Self::Cursor, "kiro" => Self::Kiro, "trae" => Self::Trae, "zed" => Self::Zed, "factory_droid" => Self::FactoryDroid, "windsurf" => Self::Windsurf, _ => Self::Unknown, } } pub fn as_str(self) -> &'static str { match self { Self::Unknown => "unknown", Self::Claude => "claude", Self::Codex => "codex", Self::OpenCode => "opencode", Self::Gemini => "gemini", Self::Cursor => "cursor", Self::Kiro => "kiro", Self::Trae => "trae", Self::Zed => "zed", Self::FactoryDroid => "factory_droid", Self::Windsurf => "windsurf", } } pub fn canonical_agent_type(agent_type: &str) -> String { match Self::from_agent_type(agent_type) { Self::Unknown => agent_type.trim().to_ascii_lowercase(), harness => harness.as_str().to_string(), } } fn project_markers(self) -> &'static [&'static str] { match self { Self::Claude => &[".claude"], Self::Codex => &[".codex", ".codex-plugin"], Self::OpenCode => &[".opencode"], Self::Gemini => &[".gemini"], Self::Cursor => &[".cursor"], Self::Kiro => &[".kiro"], Self::Trae => &[".trae"], Self::Unknown | Self::Zed | Self::FactoryDroid | Self::Windsurf => &[], } } } impl fmt::Display for HarnessKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct SessionHarnessInfo { pub primary: HarnessKind, pub primary_label: String, pub detected: Vec, pub detected_labels: Vec, } impl SessionHarnessInfo { fn detected_labels_for(detected: &[HarnessKind]) -> Vec { detected.iter().map(|harness| harness.to_string()).collect() } fn configured_detected_labels(cfg: &crate::config::Config, working_dir: &Path) -> Vec { let mut labels = Vec::new(); for (name, runner) in &cfg.harness_runners { if runner.project_markers.is_empty() { continue; } if runner .project_markers .iter() .any(|marker| working_dir.join(marker).exists()) { let label = Self::runner_key(name); if !label.is_empty() && !labels.contains(&label) { labels.push(label); } } } labels } pub fn runner_key(agent_type: &str) -> String { let canonical = HarnessKind::canonical_agent_type(agent_type); match HarnessKind::from_agent_type(&canonical) { HarnessKind::Unknown if canonical.is_empty() => { HarnessKind::Unknown.as_str().to_string() } HarnessKind::Unknown => canonical, harness => harness.as_str().to_string(), } } fn primary_label_for(agent_type: &str, primary: HarnessKind) -> String { match primary { HarnessKind::Unknown => { let label = Self::runner_key(agent_type); if label.is_empty() { HarnessKind::Unknown.as_str().to_string() } else { label } } harness => harness.as_str().to_string(), } } pub fn detect(agent_type: &str, working_dir: &Path) -> Self { let runner_key = Self::runner_key(agent_type); let detected = [ HarnessKind::Claude, HarnessKind::Codex, HarnessKind::OpenCode, HarnessKind::Gemini, HarnessKind::Cursor, HarnessKind::Kiro, HarnessKind::Trae, ] .into_iter() .filter(|harness| { harness .project_markers() .iter() .any(|marker| working_dir.join(marker).exists()) }) .collect::>(); let primary = match HarnessKind::from_agent_type(&runner_key) { HarnessKind::Unknown if runner_key == HarnessKind::Unknown.as_str() => { detected.first().copied().unwrap_or(HarnessKind::Unknown) } HarnessKind::Unknown => HarnessKind::Unknown, harness => harness, }; let detected_labels = Self::detected_labels_for(&detected); Self { primary, primary_label: Self::primary_label_for(agent_type, primary), detected, detected_labels, } } pub fn from_persisted( harness_label: &str, agent_type: &str, working_dir: &Path, detected: Vec, ) -> Self { let primary = HarnessKind::from_db_value(harness_label); if primary == HarnessKind::Unknown && detected.is_empty() && harness_label.trim().is_empty() { return Self::detect(agent_type, working_dir); } let normalized_label = harness_label.trim().to_ascii_lowercase(); let detected_labels = Self::detected_labels_for(&detected); Self { primary, primary_label: if normalized_label.is_empty() { Self::primary_label_for(agent_type, primary) } else { normalized_label }, detected, detected_labels, } } pub fn with_config_detection( mut self, cfg: &crate::config::Config, working_dir: &Path, ) -> Self { for label in Self::configured_detected_labels(cfg, working_dir) { if !self.detected_labels.contains(&label) { self.detected_labels.push(label); } } if self.primary == HarnessKind::Unknown && self.primary_label == HarnessKind::Unknown.as_str() && !self.detected_labels.is_empty() { self.primary_label = self.detected_labels[0].clone(); } self } pub fn detected_summary(&self) -> String { if self.detected_labels.is_empty() { "none detected".to_string() } else { self.detected_labels.join(", ") } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: String, pub task: String, pub project: String, pub task_group: String, pub agent_type: String, pub working_dir: PathBuf, pub state: SessionState, pub pid: Option, pub worktree: Option, pub created_at: DateTime, pub updated_at: DateTime, pub last_heartbeat_at: DateTime, pub metrics: SessionMetrics, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum SessionState { Pending, Running, Idle, Stale, Completed, Failed, Stopped, } impl fmt::Display for SessionState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { SessionState::Pending => write!(f, "pending"), SessionState::Running => write!(f, "running"), SessionState::Idle => write!(f, "idle"), SessionState::Stale => write!(f, "stale"), SessionState::Completed => write!(f, "completed"), SessionState::Failed => write!(f, "failed"), SessionState::Stopped => write!(f, "stopped"), } } } impl SessionState { pub fn can_transition_to(&self, next: &Self) -> bool { if self == next { return true; } matches!( (self, next), ( SessionState::Pending, SessionState::Running | SessionState::Failed | SessionState::Stopped ) | ( SessionState::Running, SessionState::Idle | SessionState::Stale | SessionState::Completed | SessionState::Failed | SessionState::Stopped ) | ( SessionState::Idle, SessionState::Running | SessionState::Stale | SessionState::Completed | SessionState::Failed | SessionState::Stopped ) | ( SessionState::Stale, SessionState::Running | SessionState::Idle | SessionState::Completed | SessionState::Failed | SessionState::Stopped ) | (SessionState::Completed, SessionState::Stopped) | (SessionState::Failed, SessionState::Stopped) ) } pub fn from_db_value(value: &str) -> Self { match value { "running" => SessionState::Running, "idle" => SessionState::Idle, "stale" => SessionState::Stale, "completed" => SessionState::Completed, "failed" => SessionState::Failed, "stopped" => SessionState::Stopped, _ => SessionState::Pending, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorktreeInfo { pub path: PathBuf, pub branch: String, pub base_branch: String, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SessionMetrics { pub input_tokens: u64, pub output_tokens: u64, pub tokens_used: u64, pub tool_calls: u64, pub files_changed: u32, pub duration_secs: u64, pub cost_usd: f64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionMessage { pub id: i64, pub from_session: String, pub to_session: String, pub content: String, pub msg_type: String, pub read: bool, pub timestamp: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ScheduledTask { pub id: i64, pub cron_expr: String, pub task: String, pub agent_type: String, pub profile_name: Option, pub working_dir: PathBuf, pub project: String, pub task_group: String, pub use_worktree: bool, pub last_run_at: Option>, pub next_run_at: DateTime, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RemoteDispatchRequest { pub id: i64, pub request_kind: RemoteDispatchKind, pub target_session_id: Option, pub task: String, pub target_url: Option, pub priority: crate::comms::TaskPriority, pub agent_type: String, pub profile_name: Option, pub working_dir: PathBuf, pub project: String, pub task_group: String, pub use_worktree: bool, pub source: String, pub requester: Option, pub status: RemoteDispatchStatus, pub result_session_id: Option, pub result_action: Option, pub error: Option, pub created_at: DateTime, pub updated_at: DateTime, pub dispatched_at: Option>, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RemoteDispatchKind { Standard, ComputerUse, } impl fmt::Display for RemoteDispatchKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Standard => write!(f, "standard"), Self::ComputerUse => write!(f, "computer_use"), } } } impl RemoteDispatchKind { pub fn from_db_value(value: &str) -> Self { match value { "computer_use" => Self::ComputerUse, _ => Self::Standard, } } } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RemoteDispatchStatus { Pending, Dispatched, Failed, } impl fmt::Display for RemoteDispatchStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Pending => write!(f, "pending"), Self::Dispatched => write!(f, "dispatched"), Self::Failed => write!(f, "failed"), } } } impl RemoteDispatchStatus { pub fn from_db_value(value: &str) -> Self { match value { "dispatched" => Self::Dispatched, "failed" => Self::Failed, _ => Self::Pending, } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileActivityEntry { pub session_id: String, pub action: FileActivityAction, pub path: String, pub summary: String, pub diff_preview: Option, pub patch_preview: Option, pub timestamp: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DecisionLogEntry { pub id: i64, pub session_id: String, pub decision: String, pub alternatives: Vec, pub reasoning: String, pub timestamp: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ContextGraphEntity { pub id: i64, pub session_id: Option, pub entity_type: String, pub name: String, pub path: Option, pub summary: String, pub metadata: BTreeMap, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ContextGraphRelation { pub id: i64, pub session_id: Option, 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, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ContextGraphEntityDetail { pub entity: ContextGraphEntity, pub outgoing: Vec, pub incoming: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ContextGraphObservation { pub id: i64, pub session_id: Option, pub entity_id: i64, pub entity_type: String, pub entity_name: String, pub observation_type: String, pub priority: ContextObservationPriority, pub pinned: bool, pub summary: String, pub details: BTreeMap, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ContextGraphRecallEntry { pub entity: ContextGraphEntity, pub score: u64, pub matched_terms: Vec, pub relation_count: usize, pub observation_count: usize, pub max_observation_priority: ContextObservationPriority, pub has_pinned_observation: bool, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] pub enum ContextObservationPriority { Low, Normal, High, Critical, } impl Default for ContextObservationPriority { fn default() -> Self { Self::Normal } } impl fmt::Display for ContextObservationPriority { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Low => write!(f, "low"), Self::Normal => write!(f, "normal"), Self::High => write!(f, "high"), Self::Critical => write!(f, "critical"), } } } impl ContextObservationPriority { pub fn from_db_value(value: i64) -> Self { match value { 0 => Self::Low, 2 => Self::High, 3 => Self::Critical, _ => Self::Normal, } } pub fn as_db_value(self) -> i64 { match self { Self::Low => 0, Self::Normal => 1, Self::High => 2, Self::Critical => 3, } } } #[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, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct ContextGraphCompactionStats { pub entities_scanned: usize, pub duplicate_observations_deleted: usize, pub overflow_observations_deleted: usize, pub observations_retained: usize, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FileActivityAction { Read, Create, Modify, Move, Delete, Touch, } pub fn normalize_group_label(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } } pub fn default_project_label(working_dir: &Path) -> String { working_dir .file_name() .and_then(|value| value.to_str()) .and_then(normalize_group_label) .unwrap_or_else(|| "workspace".to_string()) } pub fn default_task_group_label(task: &str) -> String { normalize_group_label(task).unwrap_or_else(|| "general".to_string()) } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct SessionGrouping { pub project: Option, pub task_group: Option, } #[cfg(test)] mod tests { use super::*; use std::fs; struct TestDir { path: PathBuf, } impl TestDir { fn new(label: &str) -> Result> { let path = std::env::temp_dir().join(format!("ecc2-{}-{}", label, uuid::Uuid::new_v4())); fs::create_dir_all(&path)?; Ok(Self { path }) } fn path(&self) -> &Path { &self.path } } impl Drop for TestDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.path); } } #[test] fn detect_session_harness_prefers_agent_type_and_collects_project_markers( ) -> Result<(), Box> { let repo = TestDir::new("session-harness-detect")?; fs::create_dir_all(repo.path().join(".codex"))?; fs::create_dir_all(repo.path().join(".claude"))?; let harness = SessionHarnessInfo::detect("claude", repo.path()); assert_eq!(harness.primary, HarnessKind::Claude); assert_eq!(harness.primary_label, "claude"); assert_eq!( harness.detected, vec![HarnessKind::Claude, HarnessKind::Codex] ); assert_eq!(harness.detected_labels, vec!["claude", "codex"]); assert_eq!(harness.detected_summary(), "claude, codex"); Ok(()) } #[test] fn detect_session_harness_falls_back_to_project_markers_when_agent_unspecified( ) -> Result<(), Box> { let repo = TestDir::new("session-harness-markers")?; fs::create_dir_all(repo.path().join(".gemini"))?; let harness = SessionHarnessInfo::detect("", repo.path()); assert_eq!(harness.primary, HarnessKind::Gemini); assert_eq!(harness.primary_label, "gemini"); assert_eq!(harness.detected, vec![HarnessKind::Gemini]); assert_eq!(harness.detected_labels, vec!["gemini"]); Ok(()) } #[test] fn canonical_agent_type_normalizes_known_aliases() { assert_eq!(HarnessKind::canonical_agent_type("claude-code"), "claude"); assert_eq!(HarnessKind::canonical_agent_type("gemini-cli"), "gemini"); assert_eq!( HarnessKind::canonical_agent_type("factory-droid"), "factory_droid" ); assert_eq!( HarnessKind::canonical_agent_type(" custom-runner "), "custom-runner" ); } #[test] fn detect_session_harness_preserves_custom_agent_label_without_markers() { let harness = SessionHarnessInfo::detect(" custom-runner ", Path::new(".")); assert_eq!(harness.primary, HarnessKind::Unknown); assert_eq!(harness.primary_label, "custom-runner"); assert!(harness.detected.is_empty()); assert!(harness.detected_labels.is_empty()); } #[test] fn detect_session_harness_preserves_custom_agent_label_with_project_markers( ) -> Result<(), Box> { let repo = TestDir::new("session-harness-custom-markers")?; fs::create_dir_all(repo.path().join(".claude"))?; fs::create_dir_all(repo.path().join(".codex"))?; let harness = SessionHarnessInfo::detect("custom-runner", repo.path()); assert_eq!(harness.primary, HarnessKind::Unknown); assert_eq!(harness.primary_label, "custom-runner"); assert_eq!( harness.detected, vec![HarnessKind::Claude, HarnessKind::Codex] ); assert_eq!(harness.detected_labels, vec!["claude", "codex"]); Ok(()) } #[test] fn config_detection_adds_custom_markers_to_detected_summary( ) -> Result<(), Box> { let repo = TestDir::new("session-harness-custom-config")?; fs::create_dir_all(repo.path().join(".acme"))?; let mut cfg = crate::config::Config::default(); cfg.harness_runners.insert( "acme-runner".to_string(), crate::config::HarnessRunnerConfig { project_markers: vec![PathBuf::from(".acme")], ..Default::default() }, ); let harness = SessionHarnessInfo::detect("", repo.path()).with_config_detection(&cfg, repo.path()); assert_eq!(harness.primary, HarnessKind::Unknown); assert_eq!(harness.primary_label, "acme-runner"); assert_eq!(harness.detected_labels, vec!["acme-runner"]); assert_eq!(harness.detected_summary(), "acme-runner"); Ok(()) } #[test] fn config_detection_preserves_custom_primary_label_and_appends_marker_matches( ) -> Result<(), Box> { let repo = TestDir::new("session-harness-config-append")?; fs::create_dir_all(repo.path().join(".acme"))?; fs::create_dir_all(repo.path().join(".codex"))?; let mut cfg = crate::config::Config::default(); cfg.harness_runners.insert( "acme-runner".to_string(), crate::config::HarnessRunnerConfig { project_markers: vec![PathBuf::from(".acme")], ..Default::default() }, ); let harness = SessionHarnessInfo::detect("acme-runner", repo.path()) .with_config_detection(&cfg, repo.path()); assert_eq!(harness.primary, HarnessKind::Unknown); assert_eq!(harness.primary_label, "acme-runner"); assert_eq!(harness.detected_labels, vec!["codex", "acme-runner"]); assert_eq!(harness.detected_summary(), "codex, acme-runner"); Ok(()) } #[test] fn runner_key_uses_canonical_label_for_unknown_harnesses() { assert_eq!( SessionHarnessInfo::runner_key(" custom-runner "), "custom-runner" ); assert_eq!(SessionHarnessInfo::runner_key("claude-code"), "claude"); } }