feat: enforce default agents and auto-reassign on delete
This commit is contained in:
parent
b7fc4123a6
commit
3cdd880344
6 changed files with 229 additions and 59 deletions
33
src-tauri/migrations/004_default_agents.sql
Normal file
33
src-tauri/migrations/004_default_agents.sql
Normal file
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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<Connection> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Agent> {
|
||||
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<Agent> {
|
|||
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<Vec<Agent>> {
|
||||
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<Agent> {
|
||||
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<Agent> {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export default function AgentForm() {
|
|||
const [role, setRole] = useState<AgentRole>("analyst");
|
||||
const [tool, setTool] = useState<AgentTool>("codex");
|
||||
const [customPrompt, setCustomPrompt] = useState("");
|
||||
const [isDefaultAgent, setIsDefaultAgent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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() {
|
|||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{initializing && <div className="text-sm text-gray-500">Loading agent...</div>}
|
||||
{isEditing && isDefaultAgent && (
|
||||
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-700">
|
||||
This is a default agent. Only its script/prompt can be modified.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Name</label>
|
||||
|
|
@ -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() {
|
|||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as AgentRole)}
|
||||
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"
|
||||
>
|
||||
<option value="analyst">Analyst</option>
|
||||
|
|
@ -92,6 +101,7 @@ export default function AgentForm() {
|
|||
<select
|
||||
value={tool}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
|
|
@ -101,7 +111,7 @@ export default function AgentForm() {
|
|||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Custom prompt (appended to built-in prompt)
|
||||
Script / custom prompt (appended to built-in prompt)
|
||||
</label>
|
||||
<textarea
|
||||
rows={12}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ export default function AgentList() {
|
|||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{agent.name}</span>
|
||||
{agent.is_default && (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||
{roleLabel(agent.role)}
|
||||
</span>
|
||||
|
|
@ -107,13 +112,15 @@ export default function AgentList() {
|
|||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgentToDelete(agent)}
|
||||
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{!agent.is_default && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAgentToDelete(agent)}
|
||||
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -122,7 +129,7 @@ export default function AgentList() {
|
|||
<ConfirmModal
|
||||
isOpen={agentToDelete !== null}
|
||||
title="Delete agent"
|
||||
message="Delete this agent? Trackers using it will become invalid until reconfigured."
|
||||
message="Delete this agent? Trackers using it will be reassigned to the default agent for the same role."
|
||||
confirmLabel={deleting ? "Deleting..." : "Delete"}
|
||||
confirmDisabled={deleting}
|
||||
onCancel={() => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface Agent {
|
|||
role: AgentRole;
|
||||
tool: AgentTool;
|
||||
custom_prompt: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue