diff --git a/src-tauri/migrations/003_add_agents.sql b/src-tauri/migrations/003_add_agents.sql new file mode 100644 index 0000000..9a98822 --- /dev/null +++ b/src-tauri/migrations/003_add_agents.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + role TEXT NOT NULL, + tool TEXT NOT NULL, + custom_prompt TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +ALTER TABLE watched_trackers ADD COLUMN analyst_agent_id TEXT; +ALTER TABLE watched_trackers ADD COLUMN developer_agent_id TEXT; +ALTER TABLE watched_trackers ADD COLUMN status TEXT NOT NULL DEFAULT 'valid'; + +UPDATE watched_trackers +SET status = 'invalid' +WHERE analyst_agent_id IS NULL OR developer_agent_id IS NULL; diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs new file mode 100644 index 0000000..91f8d76 --- /dev/null +++ b/src-tauri/src/commands/agent.rs @@ -0,0 +1,98 @@ +use crate::error::AppError; +use crate::models::agent::{Agent, AgentRole, AgentTool}; +use crate::AppState; +use rusqlite::params; +use tauri::State; + +#[tauri::command] +pub fn create_agent( + state: State<'_, AppState>, + name: String, + role: AgentRole, + tool: AgentTool, + custom_prompt: String, +) -> Result { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let agent = Agent::insert(&db, &name, role, tool, &custom_prompt)?; + Ok(agent) +} + +#[tauri::command] +pub fn list_agents(state: State<'_, AppState>) -> Result, AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let agents = Agent::list(&db)?; + Ok(agents) +} + +#[tauri::command] +pub fn get_agent(state: State<'_, AppState>, id: String) -> Result { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let agent = Agent::get_by_id(&db, &id)?; + Ok(agent) +} + +#[tauri::command] +pub fn update_agent( + state: State<'_, AppState>, + id: String, + name: String, + role: AgentRole, + tool: AgentTool, + custom_prompt: String, +) -> Result<(), AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let previous = Agent::get_by_id(&db, &id)?; + Agent::update(&db, &id, &name, role.clone(), tool, &custom_prompt)?; + + if previous.role != role { + match role { + AgentRole::Analyst => { + db.execute( + "UPDATE watched_trackers + SET developer_agent_id = NULL, + status = 'invalid' + WHERE developer_agent_id = ?1", + params![id], + )?; + } + AgentRole::Developer => { + db.execute( + "UPDATE watched_trackers + SET analyst_agent_id = NULL, + status = 'invalid' + WHERE analyst_agent_id = ?1", + params![id], + )?; + } + } + } + + Ok(()) +} + +#[tauri::command] +pub fn delete_agent(state: State<'_, AppState>, id: String) -> Result<(), AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + Agent::delete(&db, &id)?; + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 62c2e6c..f141882 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod credential; pub mod notification; pub mod orchestrator; diff --git a/src-tauri/src/commands/poller.rs b/src-tauri/src/commands/poller.rs index ee07b9e..6f453d6 100644 --- a/src-tauri/src/commands/poller.rs +++ b/src-tauri/src/commands/poller.rs @@ -20,6 +20,11 @@ pub async fn manual_poll( .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; let tracker = WatchedTracker::get_by_id(&db, &tracker_id)?; + if tracker.status != "valid" { + return Err(AppError::from( + "Tracker is invalid. Reconfigure analyst/developer agents first.".to_string(), + )); + } let cred = TuleapCredentials::get(&db)? .ok_or_else(|| AppError::from("No Tuleap credentials configured".to_string()))?; diff --git a/src-tauri/src/commands/tracker.rs b/src-tauri/src/commands/tracker.rs index a1352fd..b553373 100644 --- a/src-tauri/src/commands/tracker.rs +++ b/src-tauri/src/commands/tracker.rs @@ -1,7 +1,8 @@ use crate::error::AppError; +use crate::models::agent::{Agent, AgentRole}; use crate::models::credential::TuleapCredentials; use crate::models::ticket::ProcessedTicket; -use crate::models::tracker::{AgentConfig, FilterGroup, TrackerUpdate, WatchedTracker}; +use crate::models::tracker::{FilterGroup, TrackerUpdate, WatchedTracker}; use crate::services::crypto; use crate::services::tuleap_client::TuleapClient; use crate::AppState; @@ -27,6 +28,19 @@ fn build_tuleap_client(state: &State) -> Result Result<(), AppError> { + let agent = Agent::get_by_id(db, agent_id)?; + if agent.role != expected { + return Err(AppError::from(format!( + "Agent '{}' has role '{}', expected '{}'", + agent.name, + agent.role.as_str(), + expected.as_str() + ))); + } + Ok(()) +} + #[tauri::command] pub fn add_tracker( state: State<'_, AppState>, @@ -34,7 +48,8 @@ pub fn add_tracker( tracker_id: i32, tracker_label: String, polling_interval: i32, - agent_config: AgentConfig, + analyst_agent_id: String, + developer_agent_id: String, filters: Vec, ) -> Result { let db = state @@ -42,13 +57,17 @@ pub fn add_tracker( .lock() .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + ensure_agent_role(&db, &analyst_agent_id, AgentRole::Analyst)?; + ensure_agent_role(&db, &developer_agent_id, AgentRole::Developer)?; + let tracker = WatchedTracker::insert( &db, &project_id, tracker_id, &tracker_label, polling_interval, - agent_config, + &analyst_agent_id, + &developer_agent_id, filters, )?; @@ -80,6 +99,9 @@ pub fn update_tracker( .lock() .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + ensure_agent_role(&db, &update.analyst_agent_id, AgentRole::Analyst)?; + ensure_agent_role(&db, &update.developer_agent_id, AgentRole::Developer)?; + WatchedTracker::update(&db, &id, update)?; Ok(()) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 8ceb7f7..68a1224 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -3,6 +3,7 @@ use std::path::Path; const MIGRATION_001: &str = include_str!("../migrations/001_init.sql"); const MIGRATION_002: &str = include_str!("../migrations/002_add_last_polled.sql"); +const MIGRATION_003: &str = include_str!("../migrations/003_add_agents.sql"); pub fn init(db_path: &Path) -> Result { let conn = Connection::open(db_path)?; @@ -36,6 +37,10 @@ fn migrate(conn: &Connection) -> Result<()> { conn.execute_batch(MIGRATION_002)?; conn.pragma_update(None, "user_version", 2)?; } + if version < 3 { + conn.execute_batch(MIGRATION_003)?; + conn.pragma_update(None, "user_version", 3)?; + } Ok(()) } @@ -48,7 +53,7 @@ mod tests { fn test_init_in_memory_creates_tables() { let conn = init_in_memory().expect("should initialize"); - // Verify all 6 tables exist + // Verify all 7 tables exist let tables: Vec = conn .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") .unwrap() @@ -60,6 +65,7 @@ mod tests { assert_eq!( tables, vec![ + "agents", "notifications", "processed_tickets", "projects", @@ -86,6 +92,6 @@ mod tests { let version: i32 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .unwrap(); - assert_eq!(version, 2); + assert_eq!(version, 3); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2717cd0..a0752c8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -54,6 +54,11 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + commands::agent::create_agent, + commands::agent::list_agents, + commands::agent::get_agent, + commands::agent::update_agent, + commands::agent::delete_agent, commands::project::create_project, commands::project::list_projects, commands::project::get_project, diff --git a/src-tauri/src/models/agent.rs b/src-tauri/src/models/agent.rs new file mode 100644 index 0000000..7a44192 --- /dev/null +++ b/src-tauri/src/models/agent.rs @@ -0,0 +1,280 @@ +use rusqlite::{params, Connection, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentRole { + Analyst, + Developer, +} + +impl AgentRole { + pub fn as_str(&self) -> &'static str { + match self { + AgentRole::Analyst => "analyst", + AgentRole::Developer => "developer", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "analyst" => Ok(AgentRole::Analyst), + "developer" => Ok(AgentRole::Developer), + _ => Err(rusqlite::Error::InvalidParameterName(format!( + "Invalid agent role: {}", + value + ))), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AgentTool { + Codex, + ClaudeCode, +} + +impl AgentTool { + pub fn as_str(&self) -> &'static str { + match self { + AgentTool::Codex => "codex", + AgentTool::ClaudeCode => "claude_code", + } + } + + pub fn from_str(value: &str) -> Result { + match value { + "codex" => Ok(AgentTool::Codex), + "claude_code" => Ok(AgentTool::ClaudeCode), + _ => Err(rusqlite::Error::InvalidParameterName(format!( + "Invalid agent tool: {}", + value + ))), + } + } + + pub fn to_command(&self) -> &'static str { + match self { + AgentTool::Codex => "codex", + AgentTool::ClaudeCode => "claude", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Agent { + pub id: String, + pub name: String, + pub role: AgentRole, + pub tool: AgentTool, + pub custom_prompt: String, + pub created_at: String, + pub updated_at: String, +} + +fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + let role_raw: String = row.get(2)?; + let tool_raw: String = row.get(3)?; + + Ok(Agent { + id: row.get(0)?, + name: row.get(1)?, + role: AgentRole::from_str(&role_raw)?, + tool: AgentTool::from_str(&tool_raw)?, + custom_prompt: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) +} + +impl Agent { + pub fn insert( + conn: &Connection, + name: &str, + role: AgentRole, + tool: AgentTool, + custom_prompt: &str, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO agents (id, name, role, tool, custom_prompt, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![id, name, role.as_str(), tool.as_str(), custom_prompt, now, now], + )?; + + Ok(Agent { + id, + name: name.to_string(), + role, + tool, + custom_prompt: custom_prompt.to_string(), + created_at: now.clone(), + updated_at: now, + }) + } + + pub fn list(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, name, role, tool, custom_prompt, created_at, updated_at FROM agents ORDER BY role ASC, created_at DESC", + )?; + let rows = stmt.query_map([], from_row)?; + rows.collect() + } + + pub fn get_by_id(conn: &Connection, id: &str) -> Result { + conn.query_row( + "SELECT id, name, role, tool, custom_prompt, created_at, updated_at FROM agents WHERE id = ?1", + params![id], + from_row, + ) + } + + pub fn update( + conn: &Connection, + id: &str, + name: &str, + role: AgentRole, + tool: AgentTool, + custom_prompt: &str, + ) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let affected = conn.execute( + "UPDATE agents SET name = ?1, role = ?2, tool = ?3, custom_prompt = ?4, updated_at = ?5 WHERE id = ?6", + params![name, role.as_str(), tool.as_str(), custom_prompt, now, id], + )?; + + if affected == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + + Ok(()) + } + + pub fn delete(conn: &Connection, id: &str) -> Result<()> { + conn.execute( + "UPDATE watched_trackers + SET analyst_agent_id = CASE WHEN analyst_agent_id = ?1 THEN NULL ELSE analyst_agent_id END, + developer_agent_id = CASE WHEN developer_agent_id = ?1 THEN NULL ELSE developer_agent_id END, + status = CASE + WHEN analyst_agent_id = ?1 OR developer_agent_id = ?1 THEN 'invalid' + ELSE status + END + WHERE analyst_agent_id = ?1 OR developer_agent_id = ?1", + params![id], + )?; + + let affected = conn.execute("DELETE FROM agents WHERE id = ?1", params![id])?; + if affected == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::models::project::Project; + use crate::models::tracker::WatchedTracker; + + fn setup() -> Connection { + db::init_in_memory().expect("db init should succeed") + } + + #[test] + fn test_insert_and_get_agent() { + let conn = setup(); + + let created = Agent::insert( + &conn, + "Analyst Codex", + AgentRole::Analyst, + AgentTool::Codex, + "Focus on root cause.", + ) + .expect("insert should succeed"); + + let found = Agent::get_by_id(&conn, &created.id).expect("get_by_id should succeed"); + + assert_eq!(found.name, "Analyst Codex"); + assert_eq!(found.role, AgentRole::Analyst); + assert_eq!(found.tool, AgentTool::Codex); + assert_eq!(found.custom_prompt, "Focus on root cause."); + } + + #[test] + fn test_update_agent() { + let conn = setup(); + + let created = Agent::insert( + &conn, + "Dev Claude", + AgentRole::Developer, + AgentTool::ClaudeCode, + "", + ) + .unwrap(); + + Agent::update( + &conn, + &created.id, + "Dev Codex", + AgentRole::Developer, + AgentTool::Codex, + "Write tests.", + ) + .unwrap(); + + let updated = Agent::get_by_id(&conn, &created.id).unwrap(); + assert_eq!(updated.name, "Dev Codex"); + assert_eq!(updated.tool, AgentTool::Codex); + assert_eq!(updated.custom_prompt, "Write tests."); + } + + #[test] + fn test_delete_agent_invalidates_trackers() { + let conn = setup(); + let project = Project::insert(&conn, "P", "/tmp/p", None, "main").unwrap(); + + let analyst = Agent::insert( + &conn, + "Analyst", + AgentRole::Analyst, + AgentTool::Codex, + "", + ) + .unwrap(); + + let developer = Agent::insert( + &conn, + "Developer", + AgentRole::Developer, + AgentTool::ClaudeCode, + "", + ) + .unwrap(); + + let tracker = WatchedTracker::insert( + &conn, + &project.id, + 100, + "Bugs", + 10, + &analyst.id, + &developer.id, + vec![], + ) + .unwrap(); + + Agent::delete(&conn, &analyst.id).unwrap(); + + let reloaded = WatchedTracker::get_by_id(&conn, &tracker.id).unwrap(); + assert_eq!(reloaded.status, "invalid"); + assert!(reloaded.analyst_agent_id.is_none()); + assert_eq!(reloaded.developer_agent_id.as_deref(), Some(developer.id.as_str())); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index d7e4ff0..32c4ee8 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,3 +1,4 @@ +pub mod agent; pub mod credential; pub mod notification; pub mod project; diff --git a/src-tauri/src/models/ticket.rs b/src-tauri/src/models/ticket.rs index d9f3f92..3db7367 100644 --- a/src-tauri/src/models/ticket.rs +++ b/src-tauri/src/models/ticket.rs @@ -181,21 +181,27 @@ impl ProcessedTicket { mod tests { use super::*; use crate::db; + use crate::models::agent::{Agent, AgentRole, AgentTool}; use crate::models::project::Project; - use crate::models::tracker::{AgentConfig, WatchedTracker}; + use crate::models::tracker::WatchedTracker; fn setup() -> (Connection, String) { let conn = db::init_in_memory().expect("db init should succeed"); let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap(); - let agent_config = AgentConfig { - analyst_command: "claude".into(), - analyst_args: vec![], - developer_command: "claude".into(), - developer_args: vec![], - }; - let tracker = - WatchedTracker::insert(&conn, &project.id, 456, "Bugs", 10, agent_config, vec![]) - .unwrap(); + let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); + let developer = + Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); + let tracker = WatchedTracker::insert( + &conn, + &project.id, + 456, + "Bugs", + 10, + &analyst.id, + &developer.id, + vec![], + ) + .unwrap(); (conn, tracker.id) } diff --git a/src-tauri/src/models/tracker.rs b/src-tauri/src/models/tracker.rs index fbe3216..059d10d 100644 --- a/src-tauri/src/models/tracker.rs +++ b/src-tauri/src/models/tracker.rs @@ -2,14 +2,6 @@ use rusqlite::{params, Connection, Result}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentConfig { - pub analyst_command: String, - pub analyst_args: Vec, - pub developer_command: String, - pub developer_args: Vec, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FilterGroup { pub conditions: Vec, @@ -29,9 +21,11 @@ pub struct WatchedTracker { pub tracker_id: i32, pub tracker_label: String, pub polling_interval: i32, - pub agent_config: AgentConfig, + pub analyst_agent_id: Option, + pub developer_agent_id: Option, pub filters: Vec, pub enabled: bool, + pub status: String, pub last_polled_at: Option, pub created_at: String, } @@ -41,20 +35,35 @@ pub struct TrackerUpdate { pub tracker_id: i32, pub tracker_label: String, pub polling_interval: i32, - pub agent_config: AgentConfig, + pub analyst_agent_id: String, + pub developer_agent_id: String, pub filters: Vec, pub enabled: bool, } -fn from_row(row: &rusqlite::Row) -> rusqlite::Result { - let agent_config_json: String = row.get(5)?; - let filters_json: String = row.get(6)?; - let enabled_int: i32 = row.get(7)?; +fn normalize_agent_id(agent_id: &str) -> Option { + let trimmed = agent_id.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn compute_status(analyst_agent_id: &Option, developer_agent_id: &Option) -> String { + if analyst_agent_id.is_some() && developer_agent_id.is_some() { + "valid".to_string() + } else { + "invalid".to_string() + } +} + +fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + let filters_json: String = row.get(7)?; + let enabled_int: i32 = row.get(8)?; - let agent_config: AgentConfig = serde_json::from_str(&agent_config_json) - .map_err(|e| rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, Box::new(e)))?; let filters: Vec = serde_json::from_str(&filters_json) - .map_err(|e| rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(e)))?; + .map_err(|e| rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e)))?; Ok(WatchedTracker { id: row.get(0)?, @@ -62,11 +71,13 @@ fn from_row(row: &rusqlite::Row) -> rusqlite::Result { tracker_id: row.get(2)?, tracker_label: row.get(3)?, polling_interval: row.get(4)?, - agent_config, + analyst_agent_id: row.get(5)?, + developer_agent_id: row.get(6)?, filters, enabled: enabled_int != 0, - last_polled_at: row.get(8)?, - created_at: row.get(9)?, + status: row.get(9)?, + last_polled_at: row.get(10)?, + created_at: row.get(11)?, }) } @@ -77,21 +88,36 @@ impl WatchedTracker { tracker_id: i32, tracker_label: &str, polling_interval: i32, - agent_config: AgentConfig, + analyst_agent_id: &str, + developer_agent_id: &str, filters: Vec, ) -> Result { let id = Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); - let agent_config_json = serde_json::to_string(&agent_config) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; let filters_json = serde_json::to_string(&filters) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + let analyst_agent_id = normalize_agent_id(analyst_agent_id); + let developer_agent_id = normalize_agent_id(developer_agent_id); + let status = compute_status(&analyst_agent_id, &developer_agent_id); + conn.execute( - "INSERT INTO watched_trackers (id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, created_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", - params![id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, now], + "INSERT INTO watched_trackers (id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, analyst_agent_id, developer_agent_id, status, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + params![ + id, + project_id, + tracker_id, + tracker_label, + polling_interval, + "{}", + filters_json, + analyst_agent_id, + developer_agent_id, + status, + now, + ], )?; Ok(WatchedTracker { @@ -100,9 +126,11 @@ impl WatchedTracker { tracker_id, tracker_label: tracker_label.to_string(), polling_interval, - agent_config, + analyst_agent_id, + developer_agent_id, filters, enabled: true, + status, last_polled_at: None, created_at: now, }) @@ -110,7 +138,7 @@ impl WatchedTracker { pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { let mut stmt = conn.prepare( - "SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \ + "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, filters_json, enabled, status, last_polled_at, created_at \ FROM watched_trackers WHERE project_id = ?1 ORDER BY created_at DESC", )?; let rows = stmt.query_map(params![project_id], from_row)?; @@ -119,8 +147,8 @@ impl WatchedTracker { pub fn list_all_enabled(conn: &Connection) -> Result> { let mut stmt = conn.prepare( - "SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \ - FROM watched_trackers WHERE enabled = 1 ORDER BY created_at DESC", + "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, filters_json, enabled, status, last_polled_at, created_at \ + FROM watched_trackers WHERE enabled = 1 AND status = 'valid' AND analyst_agent_id IS NOT NULL AND developer_agent_id IS NOT NULL ORDER BY created_at DESC", )?; let rows = stmt.query_map([], from_row)?; rows.collect() @@ -128,7 +156,7 @@ impl WatchedTracker { pub fn get_by_id(conn: &Connection, id: &str) -> Result { conn.query_row( - "SELECT id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, enabled, last_polled_at, created_at \ + "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, filters_json, enabled, status, last_polled_at, created_at \ FROM watched_trackers WHERE id = ?1", params![id], from_row, @@ -136,20 +164,24 @@ impl WatchedTracker { } pub fn update(conn: &Connection, id: &str, update: TrackerUpdate) -> Result<()> { - let agent_config_json = serde_json::to_string(&update.agent_config) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; let filters_json = serde_json::to_string(&update.filters) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; let enabled_int = if update.enabled { 1i32 } else { 0i32 }; + let analyst_agent_id = normalize_agent_id(&update.analyst_agent_id); + let developer_agent_id = normalize_agent_id(&update.developer_agent_id); + let status = compute_status(&analyst_agent_id, &developer_agent_id); + let affected = conn.execute( - "UPDATE watched_trackers SET tracker_id = ?1, tracker_label = ?2, polling_interval = ?3, agent_config_json = ?4, filters_json = ?5, enabled = ?6 WHERE id = ?7", + "UPDATE watched_trackers SET tracker_id = ?1, tracker_label = ?2, polling_interval = ?3, filters_json = ?4, analyst_agent_id = ?5, developer_agent_id = ?6, status = ?7, enabled = ?8 WHERE id = ?9", params![ update.tracker_id, update.tracker_label, update.polling_interval, - agent_config_json, filters_json, + analyst_agent_id, + developer_agent_id, + status, enabled_int, id ], @@ -186,6 +218,7 @@ impl WatchedTracker { mod tests { use super::*; use crate::db; + use crate::models::agent::{Agent, AgentRole, AgentTool}; use crate::models::project::Project; fn setup() -> Connection { @@ -198,13 +231,26 @@ mod tests { Project::list(conn).unwrap().into_iter().next().unwrap().id } - fn sample_agent_config() -> AgentConfig { - AgentConfig { - analyst_command: "analyst".to_string(), - analyst_args: vec!["--mode".to_string(), "analyze".to_string()], - developer_command: "developer".to_string(), - developer_args: vec!["--fix".to_string()], - } + fn create_agents(conn: &Connection) -> (String, String) { + let analyst = Agent::insert( + conn, + "Analyst", + AgentRole::Analyst, + AgentTool::Codex, + "", + ) + .unwrap(); + + let developer = Agent::insert( + conn, + "Developer", + AgentRole::Developer, + AgentTool::ClaudeCode, + "", + ) + .unwrap(); + + (analyst.id, developer.id) } fn sample_filters() -> Vec { @@ -221,6 +267,7 @@ mod tests { fn test_insert_tracker() { let conn = setup(); let pid = project_id(&conn); + let (analyst_id, developer_id) = create_agents(&conn); let tracker = WatchedTracker::insert( &conn, @@ -228,7 +275,8 @@ mod tests { 42, "Bug Tracker", 15, - sample_agent_config(), + &analyst_id, + &developer_id, sample_filters(), ) .expect("insert should succeed"); @@ -239,9 +287,11 @@ mod tests { assert_eq!(tracker.tracker_label, "Bug Tracker"); assert_eq!(tracker.polling_interval, 15); assert!(tracker.enabled); + assert_eq!(tracker.status, "valid"); + assert_eq!(tracker.analyst_agent_id.as_deref(), Some(analyst_id.as_str())); + assert_eq!(tracker.developer_agent_id.as_deref(), Some(developer_id.as_str())); assert!(tracker.last_polled_at.is_none()); assert!(!tracker.created_at.is_empty()); - assert_eq!(tracker.agent_config.analyst_command, "analyst"); assert_eq!(tracker.filters.len(), 1); } @@ -249,46 +299,34 @@ mod tests { fn test_list_by_project() { let conn = setup(); let pid = project_id(&conn); + let (analyst_id, developer_id) = create_agents(&conn); - WatchedTracker::insert(&conn, &pid, 1, "Tracker A", 10, sample_agent_config(), vec![]).unwrap(); - WatchedTracker::insert(&conn, &pid, 2, "Tracker B", 20, sample_agent_config(), vec![]).unwrap(); + WatchedTracker::insert(&conn, &pid, 1, "Tracker A", 10, &analyst_id, &developer_id, vec![]).unwrap(); + WatchedTracker::insert(&conn, &pid, 2, "Tracker B", 20, &analyst_id, &developer_id, vec![]).unwrap(); let trackers = WatchedTracker::list_by_project(&conn, &pid).expect("list should succeed"); assert_eq!(trackers.len(), 2); } #[test] - fn test_list_all_enabled() { + fn test_list_all_enabled_ignores_invalid() { let conn = setup(); let pid = project_id(&conn); + let (analyst_id, developer_id) = create_agents(&conn); - let t1 = WatchedTracker::insert(&conn, &pid, 1, "Enabled", 10, sample_agent_config(), vec![]).unwrap(); - let t2 = WatchedTracker::insert(&conn, &pid, 2, "Disabled", 10, sample_agent_config(), vec![]).unwrap(); - - // Disable t2 - WatchedTracker::update( - &conn, - &t2.id, - TrackerUpdate { - tracker_id: t2.tracker_id, - tracker_label: t2.tracker_label.clone(), - polling_interval: t2.polling_interval, - agent_config: sample_agent_config(), - filters: vec![], - enabled: false, - }, - ) - .unwrap(); + let valid = WatchedTracker::insert(&conn, &pid, 1, "Valid", 10, &analyst_id, &developer_id, vec![]).unwrap(); + WatchedTracker::insert(&conn, &pid, 2, "Invalid", 10, "", &developer_id, vec![]).unwrap(); let enabled = WatchedTracker::list_all_enabled(&conn).expect("list_all_enabled should succeed"); assert_eq!(enabled.len(), 1); - assert_eq!(enabled[0].id, t1.id); + assert_eq!(enabled[0].id, valid.id); } #[test] fn test_get_by_id() { let conn = setup(); let pid = project_id(&conn); + let (analyst_id, developer_id) = create_agents(&conn); let created = WatchedTracker::insert( &conn, @@ -296,7 +334,8 @@ mod tests { 99, "My Tracker", 30, - sample_agent_config(), + &analyst_id, + &developer_id, sample_filters(), ) .unwrap(); @@ -313,6 +352,7 @@ mod tests { fn test_update_tracker() { let conn = setup(); let pid = project_id(&conn); + let (analyst_id, developer_id) = create_agents(&conn); let created = WatchedTracker::insert( &conn, @@ -320,7 +360,8 @@ mod tests { 10, "Original", 5, - sample_agent_config(), + &analyst_id, + &developer_id, sample_filters(), ) .unwrap(); @@ -340,7 +381,8 @@ mod tests { tracker_id: 11, tracker_label: "Updated tracker".to_string(), polling_interval: 60, - agent_config: sample_agent_config(), + analyst_agent_id: analyst_id, + developer_agent_id: developer_id, filters: new_filters, enabled: false, }, @@ -352,6 +394,7 @@ mod tests { assert_eq!(updated.tracker_label, "Updated tracker"); assert_eq!(updated.polling_interval, 60); assert!(!updated.enabled); + assert_eq!(updated.status, "valid"); assert_eq!(updated.filters[0].conditions[0].field, "priority"); } @@ -359,9 +402,19 @@ mod tests { fn test_update_last_polled() { let conn = setup(); let pid = project_id(&conn); + let (analyst_id, developer_id) = create_agents(&conn); - let created = - WatchedTracker::insert(&conn, &pid, 5, "Poller", 10, sample_agent_config(), vec![]).unwrap(); + let created = WatchedTracker::insert( + &conn, + &pid, + 5, + "Poller", + 10, + &analyst_id, + &developer_id, + vec![], + ) + .unwrap(); assert!(created.last_polled_at.is_none()); @@ -375,9 +428,19 @@ mod tests { fn test_delete_tracker() { let conn = setup(); let pid = project_id(&conn); + let (analyst_id, developer_id) = create_agents(&conn); - let created = - WatchedTracker::insert(&conn, &pid, 7, "ToDelete", 10, sample_agent_config(), vec![]).unwrap(); + let created = WatchedTracker::insert( + &conn, + &pid, + 7, + "ToDelete", + 10, + &analyst_id, + &developer_id, + vec![], + ) + .unwrap(); WatchedTracker::delete(&conn, &created.id).expect("delete should succeed"); diff --git a/src-tauri/src/models/worktree.rs b/src-tauri/src/models/worktree.rs index ae09afc..4e5179b 100644 --- a/src-tauri/src/models/worktree.rs +++ b/src-tauri/src/models/worktree.rs @@ -100,21 +100,28 @@ impl Worktree { mod tests { use super::*; use crate::db; + use crate::models::agent::{Agent, AgentRole, AgentTool}; use crate::models::project::Project; use crate::models::ticket::ProcessedTicket; - use crate::models::tracker::{AgentConfig, WatchedTracker}; + use crate::models::tracker::WatchedTracker; fn setup() -> (Connection, String) { let conn = db::init_in_memory().expect("db init"); let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap(); - let agent_config = AgentConfig { - analyst_command: "echo".into(), - analyst_args: vec![], - developer_command: "echo".into(), - developer_args: vec![], - }; - let tracker = WatchedTracker::insert(&conn, &project.id, 100, "Bugs", 10, agent_config, vec![]) - .unwrap(); + let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); + let developer = + Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); + let tracker = WatchedTracker::insert( + &conn, + &project.id, + 100, + "Bugs", + 10, + &analyst.id, + &developer.id, + vec![], + ) + .unwrap(); let ticket = ProcessedTicket::insert_if_new(&conn, &tracker.id, 42, "Bug 42", "{}") .unwrap() .unwrap(); @@ -153,14 +160,20 @@ mod tests { fn test_list_by_project() { let conn = db::init_in_memory().expect("db init"); let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap(); - let agent_config = AgentConfig { - analyst_command: "echo".into(), - analyst_args: vec![], - developer_command: "echo".into(), - developer_args: vec![], - }; - let tracker = WatchedTracker::insert(&conn, &project.id, 100, "Bugs", 10, agent_config, vec![]) - .unwrap(); + let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); + let developer = + Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); + let tracker = WatchedTracker::insert( + &conn, + &project.id, + 100, + "Bugs", + 10, + &analyst.id, + &developer.id, + vec![], + ) + .unwrap(); let t1 = ProcessedTicket::insert_if_new(&conn, &tracker.id, 1, "T1", "{}") .unwrap() .unwrap(); diff --git a/src-tauri/src/services/notifier.rs b/src-tauri/src/services/notifier.rs index fab2f8b..bb612fd 100644 --- a/src-tauri/src/services/notifier.rs +++ b/src-tauri/src/services/notifier.rs @@ -139,9 +139,10 @@ pub fn notify_error( mod tests { use super::*; use crate::db; + use crate::models::agent::{Agent, AgentRole, AgentTool}; use crate::models::project::Project; use crate::models::ticket::ProcessedTicket; - use crate::models::tracker::{AgentConfig, WatchedTracker}; + use crate::models::tracker::WatchedTracker; fn setup() -> (Arc>, String) { let conn = db::init_in_memory().expect("db init should succeed"); @@ -152,6 +153,9 @@ mod tests { fn setup_ticket(db: &Arc>, project_id: &str) -> String { let conn = db.lock().expect("db lock should succeed"); + let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap(); + let developer = + Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap(); let tracker = WatchedTracker::insert( &conn, @@ -159,12 +163,8 @@ mod tests { 101, "Bugs", 10, - AgentConfig { - analyst_command: "echo".to_string(), - analyst_args: vec![], - developer_command: "echo".to_string(), - developer_args: vec![], - }, + &analyst.id, + &developer.id, vec![], ) .expect("tracker insert should succeed"); diff --git a/src-tauri/src/services/orchestrator.rs b/src-tauri/src/services/orchestrator.rs index 0080e6c..02e8597 100644 --- a/src-tauri/src/services/orchestrator.rs +++ b/src-tauri/src/services/orchestrator.rs @@ -1,3 +1,4 @@ +use crate::models::agent::{Agent, AgentRole}; use crate::models::project::Project; use crate::models::ticket::ProcessedTicket; use crate::models::tracker::WatchedTracker; @@ -82,6 +83,43 @@ pub fn build_developer_prompt( ) } +fn append_custom_prompt(base_prompt: String, custom_prompt: &str) -> String { + let extra = custom_prompt.trim(); + if extra.is_empty() { + return base_prompt; + } + + format!( + "{base_prompt}\n\n## Instructions supplementaires (agent)\n{extra}", + base_prompt = base_prompt, + extra = extra + ) +} + +fn record_ticket_error( + db: &Arc>, + app_handle: &AppHandle, + project_id: &str, + ticket_id: &str, + artifact_id: i32, + error: &str, +) { + if let Ok(conn) = db.lock() { + let _ = ProcessedTicket::set_error(&conn, ticket_id, error); + } + + notifier::notify_error(db, app_handle, project_id, ticket_id, artifact_id, error); + let _ = app_handle.emit( + "ticket-processing-error", + serde_json::json!({ + "project_id": project_id, + "ticket_id": ticket_id, + "artifact_id": artifact_id, + "error": error + }), + ); +} + pub fn parse_verdict(report: &str) -> Verdict { for line in report.lines().rev() { let trimmed = line.trim(); @@ -174,12 +212,120 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> let project = Project::get_by_id(&conn, &tracker.project_id) .map_err(|e| format!("get project failed: {}", e))?; - ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing") - .map_err(|e| format!("update_status failed: {}", e))?; - (ticket, tracker, project) }; + let (analyst_agent, developer_agent) = { + if tracker.status != "valid" { + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Tracker is invalid. Configure analyst and developer agents.", + ); + return Ok(true); + } + + let analyst_id = match tracker.analyst_agent_id.as_deref() { + Some(id) => id, + None => { + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Tracker has no analyst agent configured.", + ); + return Ok(true); + } + }; + + let developer_id = match tracker.developer_agent_id.as_deref() { + Some(id) => id, + None => { + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Tracker has no developer agent configured.", + ); + return Ok(true); + } + }; + + let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; + let analyst_agent = match Agent::get_by_id(&conn, analyst_id) { + Ok(agent) => agent, + Err(_) => { + drop(conn); + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Configured analyst agent was not found.", + ); + return Ok(true); + } + }; + + let developer_agent = match Agent::get_by_id(&conn, developer_id) { + Ok(agent) => agent, + Err(_) => { + drop(conn); + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Configured developer agent was not found.", + ); + return Ok(true); + } + }; + + if analyst_agent.role != AgentRole::Analyst { + drop(conn); + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Configured analyst agent has an invalid role.", + ); + return Ok(true); + } + + if developer_agent.role != AgentRole::Developer { + drop(conn); + record_ticket_error( + db, + app_handle, + &project.id, + &ticket.id, + ticket.artifact_id, + "Configured developer agent has an invalid role.", + ); + return Ok(true); + } + + (analyst_agent, developer_agent) + }; + + { + let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; + ProcessedTicket::update_status(&conn, &ticket.id, "Analyzing") + .map_err(|e| format!("update_status failed: {}", e))?; + } + let _ = app_handle.emit( "ticket-processing-started", serde_json::json!({ @@ -190,10 +336,14 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> }), ); - let analyst_prompt = build_analyst_prompt(&ticket, &project); + let analyst_prompt = append_custom_prompt( + build_analyst_prompt(&ticket, &project), + &analyst_agent.custom_prompt, + ); + let analyst_args: Vec = Vec::new(); let analyst_result = run_cli_command( - &tracker.agent_config.analyst_command, - &tracker.agent_config.analyst_args, + analyst_agent.tool.to_command(), + &analyst_args, &analyst_prompt, &project.path, 600, @@ -205,10 +355,7 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> let analyst_report = match analyst_result { Ok(report) => report, Err(e) => { - let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; - let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e); - drop(conn); - notifier::notify_error( + record_ticket_error( db, app_handle, &project.id, @@ -216,15 +363,6 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> ticket.artifact_id, &e, ); - let _ = app_handle.emit( - "ticket-processing-error", - serde_json::json!({ - "project_id": &project.id, - "ticket_id": &ticket.id, - "artifact_id": ticket.artifact_id, - "error": e - }), - ); return Ok(true); } }; @@ -270,10 +408,7 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id); if let Err(e) = &worktree_result { - if let Ok(conn) = db.lock() { - let _ = ProcessedTicket::set_error(&conn, &ticket.id, e); - } - notifier::notify_error( + record_ticket_error( db, app_handle, &project.id, @@ -281,15 +416,6 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> ticket.artifact_id, e, ); - let _ = app_handle.emit( - "ticket-processing-error", - serde_json::json!({ - "project_id": &project.id, - "ticket_id": &ticket.id, - "artifact_id": ticket.artifact_id, - "error": e - }), - ); } let (wt_path, branch_name) = worktree_result?; @@ -314,10 +440,14 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> }), ); - let developer_prompt = build_developer_prompt(&ticket, &project, &analyst_report, &wt_path); + let developer_prompt = append_custom_prompt( + build_developer_prompt(&ticket, &project, &analyst_report, &wt_path), + &developer_agent.custom_prompt, + ); + let developer_args: Vec = Vec::new(); let developer_result = run_cli_command( - &tracker.agent_config.developer_command, - &tracker.agent_config.developer_args, + developer_agent.tool.to_command(), + &developer_args, &developer_prompt, &wt_path, 600, @@ -329,10 +459,7 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> let developer_report = match developer_result { Ok(report) => report, Err(e) => { - let conn = db.lock().map_err(|e2| format!("DB lock: {}", e2))?; - let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e); - drop(conn); - notifier::notify_error( + record_ticket_error( db, app_handle, &project.id, @@ -340,15 +467,6 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> ticket.artifact_id, &e, ); - let _ = app_handle.emit( - "ticket-processing-error", - serde_json::json!({ - "project_id": &project.id, - "ticket_id": &ticket.id, - "artifact_id": ticket.artifact_id, - "error": e - }), - ); return Ok(true); } }; diff --git a/src/App.tsx b/src/App.tsx index 6bda0de..a589b2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,5 @@ +import AgentForm from "./components/agents/AgentForm"; +import AgentList from "./components/agents/AgentList"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import AppLayout from "./components/layout/AppLayout"; import ProjectForm from "./components/projects/ProjectForm"; @@ -27,6 +29,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/agents/AgentForm.tsx b/src/components/agents/AgentForm.tsx new file mode 100644 index 0000000..6a12ec2 --- /dev/null +++ b/src/components/agents/AgentForm.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { createAgent, getAgent, updateAgent } from "../../lib/api"; +import { getErrorMessage } from "../../lib/errors"; +import type { AgentRole, AgentTool } from "../../lib/types"; + +export default function AgentForm() { + const navigate = useNavigate(); + const { agentId } = useParams<{ agentId: string }>(); + const isEditing = Boolean(agentId); + + const [name, setName] = useState(""); + const [role, setRole] = useState("analyst"); + const [tool, setTool] = useState("codex"); + const [customPrompt, setCustomPrompt] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [initializing, setInitializing] = useState(false); + + useEffect(() => { + async function loadAgent() { + if (!agentId) return; + setInitializing(true); + setError(null); + try { + const agent = await getAgent(agentId); + setName(agent.name); + setRole(agent.role); + setTool(agent.tool); + setCustomPrompt(agent.custom_prompt); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setInitializing(false); + } + } + + void loadAgent(); + }, [agentId]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + setError(null); + + try { + if (isEditing && agentId) { + await updateAgent(agentId, name, role, tool, customPrompt); + } else { + await createAgent(name, role, tool, customPrompt); + } + navigate("/agents"); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + } + + return ( +
+

{isEditing ? "Edit agent" : "New agent"}

+ +
+ {initializing &&
Loading agent...
} + +
+ + setName(e.target.value)} + required + className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ +