diff --git a/src-tauri/migrations/004_default_agents.sql b/src-tauri/migrations/004_default_agents.sql new file mode 100644 index 0000000..0a2807e --- /dev/null +++ b/src-tauri/migrations/004_default_agents.sql @@ -0,0 +1,33 @@ +ALTER TABLE agents ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_default_per_role +ON agents(role) +WHERE is_default = 1; + +INSERT INTO agents (id, name, role, tool, custom_prompt, is_default, created_at, updated_at) +SELECT + 'default-analyst-agent', + 'Default Analyst', + 'analyst', + 'codex', + '', + 1, + datetime('now'), + datetime('now') +WHERE NOT EXISTS ( + SELECT 1 FROM agents WHERE role = 'analyst' AND is_default = 1 +); + +INSERT INTO agents (id, name, role, tool, custom_prompt, is_default, created_at, updated_at) +SELECT + 'default-developer-agent', + 'Default Developer', + 'developer', + 'claude_code', + '', + 1, + datetime('now'), + datetime('now') +WHERE NOT EXISTS ( + SELECT 1 FROM agents WHERE role = 'developer' AND is_default = 1 +); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 68a1224..dac524e 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -4,6 +4,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"); +const MIGRATION_004: &str = include_str!("../migrations/004_default_agents.sql"); pub fn init(db_path: &Path) -> Result { let conn = Connection::open(db_path)?; @@ -41,6 +42,10 @@ fn migrate(conn: &Connection) -> Result<()> { conn.execute_batch(MIGRATION_003)?; conn.pragma_update(None, "user_version", 3)?; } + if version < 4 { + conn.execute_batch(MIGRATION_004)?; + conn.pragma_update(None, "user_version", 4)?; + } Ok(()) } @@ -92,6 +97,29 @@ mod tests { let version: i32 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .unwrap(); - assert_eq!(version, 3); + assert_eq!(version, 4); + } + + #[test] + fn test_default_agents_are_seeded() { + let conn = init_in_memory().expect("should initialize"); + + let analyst_defaults: i32 = conn + .query_row( + "SELECT COUNT(*) FROM agents WHERE role = 'analyst' AND is_default = 1", + [], + |row| row.get(0), + ) + .unwrap(); + let developer_defaults: i32 = conn + .query_row( + "SELECT COUNT(*) FROM agents WHERE role = 'developer' AND is_default = 1", + [], + |row| row.get(0), + ) + .unwrap(); + + assert_eq!(analyst_defaults, 1); + assert_eq!(developer_defaults, 1); } } diff --git a/src-tauri/src/models/agent.rs b/src-tauri/src/models/agent.rs index 7a44192..87be266 100644 --- a/src-tauri/src/models/agent.rs +++ b/src-tauri/src/models/agent.rs @@ -1,7 +1,10 @@ -use rusqlite::{params, Connection, Result}; +use rusqlite::{params, Connection, OptionalExtension, Result}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +pub const DEFAULT_ANALYST_AGENT_ID: &str = "default-analyst-agent"; +pub const DEFAULT_DEVELOPER_AGENT_ID: &str = "default-developer-agent"; + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AgentRole { @@ -70,6 +73,7 @@ pub struct Agent { pub role: AgentRole, pub tool: AgentTool, pub custom_prompt: String, + pub is_default: bool, pub created_at: String, pub updated_at: String, } @@ -77,6 +81,7 @@ pub struct Agent { fn from_row(row: &rusqlite::Row) -> rusqlite::Result { let role_raw: String = row.get(2)?; let tool_raw: String = row.get(3)?; + let is_default_int: i32 = row.get(5)?; Ok(Agent { id: row.get(0)?, @@ -84,8 +89,9 @@ fn from_row(row: &rusqlite::Row) -> rusqlite::Result { 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)?, + is_default: is_default_int != 0, + created_at: row.get(6)?, + updated_at: row.get(7)?, }) } @@ -101,7 +107,7 @@ impl Agent { 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)", + "INSERT INTO agents (id, name, role, tool, custom_prompt, is_default, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7)", params![id, name, role.as_str(), tool.as_str(), custom_prompt, now, now], )?; @@ -111,6 +117,7 @@ impl Agent { role, tool, custom_prompt: custom_prompt.to_string(), + is_default: false, created_at: now.clone(), updated_at: now, }) @@ -118,7 +125,9 @@ impl Agent { 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", + "SELECT id, name, role, tool, custom_prompt, is_default, created_at, updated_at + FROM agents + ORDER BY role ASC, is_default DESC, created_at DESC", )?; let rows = stmt.query_map([], from_row)?; rows.collect() @@ -126,12 +135,23 @@ impl Agent { 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", + "SELECT id, name, role, tool, custom_prompt, is_default, created_at, updated_at FROM agents WHERE id = ?1", params![id], from_row, ) } + pub fn get_default_by_role(conn: &Connection, role: AgentRole) -> Result { + conn.query_row( + "SELECT id, name, role, tool, custom_prompt, is_default, created_at, updated_at + FROM agents + WHERE role = ?1 AND is_default = 1 + LIMIT 1", + params![role.as_str()], + from_row, + ) + } + pub fn update( conn: &Connection, id: &str, @@ -140,7 +160,23 @@ impl Agent { tool: AgentTool, custom_prompt: &str, ) -> Result<()> { + let existing = Self::get_by_id(conn, id)?; let now = chrono::Utc::now().to_rfc3339(); + + if existing.is_default { + if existing.name != name || existing.role != role || existing.tool != tool { + return Err(rusqlite::Error::InvalidParameterName( + "Default agents cannot change name, role, or tool".to_string(), + )); + } + + conn.execute( + "UPDATE agents SET custom_prompt = ?1, updated_at = ?2 WHERE id = ?3", + params![custom_prompt, now, id], + )?; + return Ok(()); + } + 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], @@ -154,16 +190,50 @@ impl Agent { } pub fn delete(conn: &Connection, id: &str) -> Result<()> { + let agent = Self::get_by_id(conn, id)?; + + if agent.is_default { + return Err(rusqlite::Error::InvalidParameterName( + "Default agents cannot be deleted".to_string(), + )); + } + + let default_agent_id: String = conn + .query_row( + "SELECT id FROM agents WHERE role = ?1 AND is_default = 1 LIMIT 1", + params![agent.role.as_str()], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| { + rusqlite::Error::InvalidParameterName(format!( + "No default agent found for role '{}'", + agent.role.as_str() + )) + })?; + + match agent.role { + AgentRole::Analyst => { + conn.execute( + "UPDATE watched_trackers SET analyst_agent_id = ?1 WHERE analyst_agent_id = ?2", + params![default_agent_id, id], + )?; + } + AgentRole::Developer => { + conn.execute( + "UPDATE watched_trackers SET developer_agent_id = ?1 WHERE developer_agent_id = ?2", + params![default_agent_id, id], + )?; + } + } + 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], + SET status = CASE + WHEN analyst_agent_id IS NULL OR developer_agent_id IS NULL THEN 'invalid' + ELSE 'valid' + END", + [], )?; let affected = conn.execute("DELETE FROM agents WHERE id = ?1", params![id])?; @@ -185,6 +255,19 @@ mod tests { db::init_in_memory().expect("db init should succeed") } + #[test] + fn test_defaults_exist() { + let conn = setup(); + + let analyst = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap(); + let developer = Agent::get_default_by_role(&conn, AgentRole::Developer).unwrap(); + + assert_eq!(analyst.id, DEFAULT_ANALYST_AGENT_ID); + assert_eq!(developer.id, DEFAULT_DEVELOPER_AGENT_ID); + assert!(analyst.is_default); + assert!(developer.is_default); + } + #[test] fn test_insert_and_get_agent() { let conn = setup(); @@ -204,59 +287,61 @@ mod tests { assert_eq!(found.role, AgentRole::Analyst); assert_eq!(found.tool, AgentTool::Codex); assert_eq!(found.custom_prompt, "Focus on root cause."); + assert!(!found.is_default); } #[test] - fn test_update_agent() { + fn test_update_default_agent_allows_prompt_only() { let conn = setup(); + let analyst = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap(); - let created = Agent::insert( + let err = Agent::update( &conn, - "Dev Claude", + &analyst.id, + "Renamed", AgentRole::Developer, AgentTool::ClaudeCode, - "", + "new script", ) - .unwrap(); + .unwrap_err(); + assert!( + err.to_string() + .contains("Default agents cannot change name, role, or tool") + ); Agent::update( &conn, - &created.id, - "Dev Codex", - AgentRole::Developer, - AgentTool::Codex, - "Write tests.", + &analyst.id, + &analyst.name, + analyst.role.clone(), + analyst.tool.clone(), + "Prompt override", ) .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."); + let updated = Agent::get_by_id(&conn, &analyst.id).unwrap(); + assert_eq!(updated.custom_prompt, "Prompt override"); } #[test] - fn test_delete_agent_invalidates_trackers() { + fn test_delete_default_agent_is_rejected() { + let conn = setup(); + let analyst = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap(); + + let err = Agent::delete(&conn, &analyst.id).unwrap_err(); + assert!(err.to_string().contains("Default agents cannot be deleted")); + } + + #[test] + fn test_delete_agent_reassigns_trackers_to_default() { 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 analyst_default = Agent::get_default_by_role(&conn, AgentRole::Analyst).unwrap(); + let developer_default = Agent::get_default_by_role(&conn, AgentRole::Developer).unwrap(); - let developer = Agent::insert( - &conn, - "Developer", - AgentRole::Developer, - AgentTool::ClaudeCode, - "", - ) - .unwrap(); + let analyst = Agent::insert(&conn, "Analyst", AgentRole::Analyst, AgentTool::Codex, "") + .unwrap(); let tracker = WatchedTracker::insert( &conn, @@ -265,7 +350,7 @@ mod tests { "Bugs", 10, &analyst.id, - &developer.id, + &developer_default.id, vec![], ) .unwrap(); @@ -273,8 +358,14 @@ mod tests { 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())); + assert_eq!(reloaded.status, "valid"); + assert_eq!( + reloaded.analyst_agent_id.as_deref(), + Some(analyst_default.id.as_str()) + ); + assert_eq!( + reloaded.developer_agent_id.as_deref(), + Some(developer_default.id.as_str()) + ); } } diff --git a/src/components/agents/AgentForm.tsx b/src/components/agents/AgentForm.tsx index 6a12ec2..d40cb2e 100644 --- a/src/components/agents/AgentForm.tsx +++ b/src/components/agents/AgentForm.tsx @@ -13,6 +13,7 @@ export default function AgentForm() { const [role, setRole] = useState("analyst"); const [tool, setTool] = useState("codex"); const [customPrompt, setCustomPrompt] = useState(""); + const [isDefaultAgent, setIsDefaultAgent] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [initializing, setInitializing] = useState(false); @@ -28,6 +29,7 @@ export default function AgentForm() { setRole(agent.role); setTool(agent.tool); setCustomPrompt(agent.custom_prompt); + setIsDefaultAgent(agent.is_default); } catch (err: unknown) { setError(getErrorMessage(err)); } finally { @@ -63,6 +65,11 @@ export default function AgentForm() {
{initializing &&
Loading agent...
} + {isEditing && isDefaultAgent && ( +
+ This is a default agent. Only its script/prompt can be modified. +
+ )}
@@ -70,6 +77,7 @@ export default function AgentForm() { type="text" value={name} onChange={(e) => setName(e.target.value)} + disabled={isEditing && isDefaultAgent} 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" /> @@ -80,6 +88,7 @@ export default function AgentForm() { setTool(e.target.value as AgentTool)} + disabled={isEditing && isDefaultAgent} className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" > @@ -101,7 +111,7 @@ export default function AgentForm() {