feat: enforce default agents and auto-reassign on delete

This commit is contained in:
thibaud-leclere 2026-04-14 16:07:50 +02:00
parent b7fc4123a6
commit 3cdd880344
6 changed files with 229 additions and 59 deletions

View 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
);

View file

@ -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);
}
}

View file

@ -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())
);
}
}

View file

@ -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}

View file

@ -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={() => {

View file

@ -22,6 +22,7 @@ export interface Agent {
role: AgentRole;
tool: AgentTool;
custom_prompt: string;
is_default: boolean;
created_at: string;
updated_at: string;
}