feat: add global agent management and tracker agent assignment

This commit is contained in:
thibaud-leclere 2026-04-14 15:59:23 +02:00
parent 54fdfc7053
commit b7fc4123a6
22 changed files with 1255 additions and 202 deletions

View file

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

View file

@ -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<Agent, AppError> {
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<Vec<Agent>, 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<Agent, AppError> {
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(())
}

View file

@ -1,3 +1,4 @@
pub mod agent;
pub mod credential;
pub mod notification;
pub mod orchestrator;

View file

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

View file

@ -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<AppState>) -> Result<TuleapClient, AppError
))
}
fn ensure_agent_role(db: &rusqlite::Connection, agent_id: &str, expected: AgentRole) -> 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<FilterGroup>,
) -> Result<WatchedTracker, AppError> {
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(())
}

View file

@ -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<Connection> {
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<String> = 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);
}
}

View file

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

View file

@ -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<Self> {
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<Self> {
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<Agent> {
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<Agent> {
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<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",
)?;
let rows = stmt.query_map([], from_row)?;
rows.collect()
}
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",
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()));
}
}

View file

@ -1,3 +1,4 @@
pub mod agent;
pub mod credential;
pub mod notification;
pub mod project;

View file

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

View file

@ -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<String>,
pub developer_command: String,
pub developer_args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FilterGroup {
pub conditions: Vec<Filter>,
@ -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<String>,
pub developer_agent_id: Option<String>,
pub filters: Vec<FilterGroup>,
pub enabled: bool,
pub status: String,
pub last_polled_at: Option<String>,
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<FilterGroup>,
pub enabled: bool,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<WatchedTracker> {
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<String> {
let trimmed = agent_id.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn compute_status(analyst_agent_id: &Option<String>, developer_agent_id: &Option<String>) -> 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<WatchedTracker> {
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<FilterGroup> = 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<WatchedTracker> {
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<FilterGroup>,
) -> Result<WatchedTracker> {
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<Vec<WatchedTracker>> {
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<Vec<WatchedTracker>> {
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<WatchedTracker> {
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<FilterGroup> {
@ -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");

View file

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

View file

@ -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<Mutex<Connection>>, String) {
let conn = db::init_in_memory().expect("db init should succeed");
@ -152,6 +153,9 @@ mod tests {
fn setup_ticket(db: &Arc<Mutex<Connection>>, 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");

View file

@ -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<Mutex<Connection>>,
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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<String> = 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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<String> = 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<Mutex<Connection>>, 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<Mutex<Connection>>, 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);
}
};

View file

@ -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() {
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
<Route path="/projects/:projectId/trackers/new" element={<TrackerConfig />} />
<Route path="/projects/:projectId/trackers/:trackerConfigId/edit" element={<TrackerConfig />} />
<Route path="/agents" element={<AgentList />} />
<Route path="/agents/new" element={<AgentForm />} />
<Route path="/agents/:agentId/edit" element={<AgentForm />} />
<Route path="/tickets/:ticketId" element={<TicketDetail />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -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<AgentRole>("analyst");
const [tool, setTool] = useState<AgentTool>("codex");
const [customPrompt, setCustomPrompt] = useState("");
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto max-w-2xl p-8">
<h2 className="mb-6 text-xl font-bold">{isEditing ? "Edit agent" : "New agent"}</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{initializing && <div className="text-sm text-gray-500">Loading agent...</div>}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Name</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value as AgentRole)}
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>
<option value="developer">Developer</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Tool</label>
<select
value={tool}
onChange={(e) => setTool(e.target.value as AgentTool)}
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>
<option value="claude_code">Claude Code</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Custom prompt (appended to built-in prompt)
</label>
<textarea
rows={12}
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Extra instructions for this agent..."
/>
</div>
{error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
{error}
</div>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={loading || initializing}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Saving..." : isEditing ? "Save" : "Create"}
</button>
<button
type="button"
onClick={() => navigate(-1)}
className="rounded bg-gray-200 px-4 py-2 text-sm hover:bg-gray-300"
>
Cancel
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,135 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { deleteAgent, listAgents } from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import type { Agent } from "../../lib/types";
import ConfirmModal from "../ui/ConfirmModal";
export default function AgentList() {
const [agents, setAgents] = useState<Agent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [agentToDelete, setAgentToDelete] = useState<Agent | null>(null);
const [deleting, setDeleting] = useState(false);
async function refresh() {
setError(null);
try {
const result = await listAgents();
setAgents(result);
} catch (err: unknown) {
setError(getErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
void refresh();
}, []);
async function handleDelete() {
if (!agentToDelete) return;
setDeleting(true);
setError(null);
try {
await deleteAgent(agentToDelete.id);
setAgentToDelete(null);
await refresh();
} catch (err: unknown) {
setError(getErrorMessage(err));
} finally {
setDeleting(false);
}
}
function roleLabel(role: Agent["role"]): string {
return role === "analyst" ? "Analyst" : "Developer";
}
function toolLabel(tool: Agent["tool"]): string {
return tool === "codex" ? "Codex" : "Claude Code";
}
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between gap-3">
<h2 className="text-xl font-bold">Agents</h2>
<Link
to="/agents/new"
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
New agent
</Link>
</div>
{loading && <div className="text-sm text-gray-400">Loading...</div>}
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
{error}
</div>
)}
{!loading && agents.length === 0 && (
<div className="text-sm text-gray-400">No agents configured.</div>
)}
<div className="space-y-3">
{agents.map((agent) => (
<div
key={agent.id}
className="flex items-start justify-between gap-4 rounded-lg border border-gray-200 bg-white p-4"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{agent.name}</span>
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{roleLabel(agent.role)}
</span>
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
{toolLabel(agent.tool)}
</span>
</div>
{agent.custom_prompt.trim() ? (
<p className="mt-2 line-clamp-3 text-xs text-gray-500">
{agent.custom_prompt}
</p>
) : (
<p className="mt-2 text-xs text-gray-400">No custom prompt.</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
to={`/agents/${agent.id}/edit`}
className="rounded bg-gray-200 px-3 py-1 text-xs text-gray-700 hover:bg-gray-300"
>
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>
</div>
</div>
))}
</div>
<ConfirmModal
isOpen={agentToDelete !== null}
title="Delete agent"
message="Delete this agent? Trackers using it will become invalid until reconfigured."
confirmLabel={deleting ? "Deleting..." : "Delete"}
confirmDisabled={deleting}
onCancel={() => {
if (!deleting) setAgentToDelete(null);
}}
onConfirm={() => void handleDelete()}
/>
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Link, useLocation, useParams } from "react-router-dom";
import appLogo from "../../assets/app-logo.jpg";
import { listProjects } from "../../lib/api";
import type { Project } from "../../lib/types";
@ -7,6 +7,7 @@ import type { Project } from "../../lib/types";
export default function Sidebar() {
const [projects, setProjects] = useState<Project[]>([]);
const { projectId } = useParams();
const location = useLocation();
useEffect(() => {
listProjects().then(setProjects);
@ -65,6 +66,19 @@ export default function Sidebar() {
{projects.length === 0 && (
<p className="px-3 py-2 text-sm text-gray-500">No projects yet</p>
)}
<div className="mt-3 border-t border-gray-800 pt-3">
<Link
to="/agents"
className={`block px-3 py-2 rounded text-sm ${
location.pathname.startsWith("/agents")
? "bg-gray-700 text-white"
: "text-gray-300 hover:bg-gray-800 hover:text-white"
}`}
>
Agents
</Link>
</div>
</nav>
<div className="p-2 border-t border-gray-700">

View file

@ -1,9 +1,15 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useEffect } from "react";
import { addTracker, getTrackerFields, listTrackers, updateTracker } from "../../lib/api";
import {
addTracker,
getTrackerFields,
listAgents,
listTrackers,
updateTracker,
} from "../../lib/api";
import { getErrorMessage } from "../../lib/errors";
import type { FilterGroup, TrackerField, AgentConfig } from "../../lib/types";
import type { FilterGroup, TrackerField, Agent } from "../../lib/types";
import FilterBuilder from "./FilterBuilder";
export default function TrackerConfig() {
@ -18,9 +24,11 @@ export default function TrackerConfig() {
const [fieldsLoaded, setFieldsLoaded] = useState(false);
const [fieldsLoading, setFieldsLoading] = useState(false);
const [filters, setFilters] = useState<FilterGroup[]>([]);
const [analystCommand, setAnalystCommand] = useState("claude");
const [developerCommand, setDeveloperCommand] = useState("claude");
const [agents, setAgents] = useState<Agent[]>([]);
const [analystAgentId, setAnalystAgentId] = useState("");
const [developerAgentId, setDeveloperAgentId] = useState("");
const [enabled, setEnabled] = useState(true);
const [trackerStatus, setTrackerStatus] = useState<"valid" | "invalid">("valid");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [initializing, setInitializing] = useState(false);
@ -38,6 +46,32 @@ export default function TrackerConfig() {
);
}
const analystAgents = agents.filter((agent) => agent.role === "analyst");
const developerAgents = agents.filter((agent) => agent.role === "developer");
useEffect(() => {
async function loadAgents() {
try {
const all = await listAgents();
setAgents(all);
} catch (err: unknown) {
setError(getErrorMessage(err));
}
}
void loadAgents();
}, []);
useEffect(() => {
if (isEditing) return;
if (!analystAgentId && analystAgents.length > 0) {
setAnalystAgentId(analystAgents[0].id);
}
if (!developerAgentId && developerAgents.length > 0) {
setDeveloperAgentId(developerAgents[0].id);
}
}, [isEditing, analystAgentId, analystAgents, developerAgentId, developerAgents]);
useEffect(() => {
async function loadTrackerForEdit() {
if (!projectId || !trackerConfigId) return;
@ -54,9 +88,10 @@ export default function TrackerConfig() {
setTrackerLabel(tracker.tracker_label);
setPollingInterval(tracker.polling_interval);
setFilters(tracker.filters);
setAnalystCommand(tracker.agent_config.analyst_command);
setDeveloperCommand(tracker.agent_config.developer_command);
setAnalystAgentId(tracker.analyst_agent_id ?? "");
setDeveloperAgentId(tracker.developer_agent_id ?? "");
setEnabled(tracker.enabled);
setTrackerStatus(tracker.status === "invalid" ? "invalid" : "valid");
const trackerFields = await getTrackerFields(tracker.tracker_id);
setFields(sortTrackerFields(trackerFields));
@ -89,16 +124,14 @@ export default function TrackerConfig() {
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!projectId || trackerId === "") return;
if (!analystAgentId || !developerAgentId) {
setError("Please select one analyst agent and one developer agent.");
return;
}
setError(null);
setLoading(true);
const agentConfig: AgentConfig = {
analyst_command: analystCommand,
analyst_args: [],
developer_command: developerCommand,
developer_args: [],
};
try {
if (isEditing && trackerConfigId) {
await updateTracker(
@ -106,7 +139,8 @@ export default function TrackerConfig() {
Number(trackerId),
trackerLabel,
pollingInterval,
agentConfig,
analystAgentId,
developerAgentId,
filters,
enabled
);
@ -116,7 +150,8 @@ export default function TrackerConfig() {
Number(trackerId),
trackerLabel,
pollingInterval,
agentConfig,
analystAgentId,
developerAgentId,
filters
);
}
@ -139,6 +174,18 @@ export default function TrackerConfig() {
<div className="text-sm text-gray-500">Loading tracker...</div>
)}
{analystAgents.length === 0 || developerAgents.length === 0 ? (
<div className="rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
You need at least one analyst agent and one developer agent before creating a tracker.
</div>
) : null}
{isEditing && trackerStatus === "invalid" && (
<div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">
This tracker is invalid. Select valid agents and save to reactivate it.
</div>
)}
{/* Basic fields */}
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
<div>
@ -227,25 +274,39 @@ export default function TrackerConfig() {
<h3 className="text-sm font-semibold text-gray-700">Agent configuration</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Analyst command
Analyst agent
</label>
<input
type="text"
value={analystCommand}
onChange={(e) => setAnalystCommand(e.target.value)}
<select
value={analystAgentId}
onChange={(e) => setAnalystAgentId(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
>
<option value="">Select an analyst agent</option>
{analystAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name} ({agent.tool === "codex" ? "Codex" : "Claude Code"})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Developer command
Developer agent
</label>
<input
type="text"
value={developerCommand}
onChange={(e) => setDeveloperCommand(e.target.value)}
<select
value={developerAgentId}
onChange={(e) => setDeveloperAgentId(e.target.value)}
required
className="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
>
<option value="">Select a developer agent</option>
{developerAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name} ({agent.tool === "codex" ? "Codex" : "Claude Code"})
</option>
))}
</select>
</div>
</div>

View file

@ -15,6 +15,9 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
const [trackerToRemove, setTrackerToRemove] = useState<WatchedTracker | null>(null);
async function handlePollNow(tracker: WatchedTracker) {
if (tracker.status !== "valid") {
return;
}
try {
setPollingIds((prev) => [...prev, tracker.id]);
await manualPoll(tracker.id);
@ -33,7 +36,8 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
tracker.tracker_id,
tracker.tracker_label,
tracker.polling_interval,
tracker.agent_config,
tracker.analyst_agent_id ?? "",
tracker.developer_agent_id ?? "",
tracker.filters,
!tracker.enabled
);
@ -72,12 +76,14 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
<span className="text-xs text-gray-400">#{tracker.tracker_id}</span>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
tracker.enabled
tracker.status === "invalid"
? "bg-red-100 text-red-700"
: tracker.enabled
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500"
}`}
>
{tracker.enabled ? "Active" : "Paused"}
{tracker.status === "invalid" ? "Invalid" : tracker.enabled ? "Active" : "Paused"}
</span>
</div>
<div className="text-xs text-gray-400 mt-1">
@ -91,7 +97,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
<button
type="button"
onClick={() => handlePollNow(tracker)}
disabled={pollingIds.includes(tracker.id)}
disabled={pollingIds.includes(tracker.id) || tracker.status !== "valid"}
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 disabled:opacity-50"
>
{pollingIds.includes(tracker.id) ? "Polling..." : "Poll now"}
@ -105,6 +111,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
<button
type="button"
onClick={() => handleToggleEnabled(tracker)}
disabled={tracker.status !== "valid"}
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300"
>
{tracker.enabled ? "Pause" : "Resume"}

View file

@ -2,7 +2,9 @@ import { invoke } from "@tauri-apps/api/core";
import type {
Project,
TuleapCredentialsSafe,
AgentConfig,
Agent,
AgentRole,
AgentTool,
FilterGroup,
WatchedTracker,
TrackerField,
@ -44,6 +46,34 @@ export async function deleteProject(id: string): Promise<void> {
return invoke("delete_project", { id });
}
// Agents
export async function createAgent(
name: string,
role: AgentRole,
tool: AgentTool,
customPrompt: string
): Promise<Agent> {
return invoke("create_agent", { name, role, tool, customPrompt });
}
export async function listAgents(): Promise<Agent[]> {
return invoke("list_agents");
}
export async function getAgent(id: string): Promise<Agent> {
return invoke("get_agent", { id });
}
export async function updateAgent(
id: string,
name: string,
role: AgentRole,
tool: AgentTool,
customPrompt: string
): Promise<void> {
return invoke("update_agent", { id, name, role, tool, customPrompt });
}
export async function deleteAgent(id: string): Promise<void> {
return invoke("delete_agent", { id });
}
// Credentials
export async function setTuleapCredentials(tuleapUrl: string, username: string, password: string): Promise<TuleapCredentialsSafe> {
return invoke("set_tuleap_credentials", { tuleapUrl, username, password });
@ -59,8 +89,24 @@ export async function testTuleapConnection(): Promise<string> {
}
// Trackers
export async function addTracker(projectId: string, trackerId: number, trackerLabel: string, pollingInterval: number, agentConfig: AgentConfig, filters: FilterGroup[]): Promise<WatchedTracker> {
return invoke("add_tracker", { projectId, trackerId, trackerLabel, pollingInterval, agentConfig, filters });
export async function addTracker(
projectId: string,
trackerId: number,
trackerLabel: string,
pollingInterval: number,
analystAgentId: string,
developerAgentId: string,
filters: FilterGroup[]
): Promise<WatchedTracker> {
return invoke("add_tracker", {
projectId,
trackerId,
trackerLabel,
pollingInterval,
analystAgentId,
developerAgentId,
filters,
});
}
export async function listTrackers(projectId: string): Promise<WatchedTracker[]> {
return invoke("list_trackers", { projectId });
@ -70,7 +116,8 @@ export async function updateTracker(
trackerId: number,
trackerLabel: string,
pollingInterval: number,
agentConfig: AgentConfig,
analystAgentId: string,
developerAgentId: string,
filters: FilterGroup[],
enabled: boolean
): Promise<void> {
@ -80,7 +127,8 @@ export async function updateTracker(
tracker_id: trackerId,
tracker_label: trackerLabel,
polling_interval: pollingInterval,
agent_config: agentConfig,
analyst_agent_id: analystAgentId,
developer_agent_id: developerAgentId,
filters,
enabled,
},

View file

@ -13,11 +13,17 @@ export interface TuleapCredentialsSafe {
username: string;
}
export interface AgentConfig {
analyst_command: string;
analyst_args: string[];
developer_command: string;
developer_args: string[];
export type AgentRole = "analyst" | "developer";
export type AgentTool = "codex" | "claude_code";
export interface Agent {
id: string;
name: string;
role: AgentRole;
tool: AgentTool;
custom_prompt: string;
created_at: string;
updated_at: string;
}
export interface Filter {
@ -48,9 +54,11 @@ export interface WatchedTracker {
tracker_id: number;
tracker_label: string;
polling_interval: number;
agent_config: AgentConfig;
analyst_agent_id: string | null;
developer_agent_id: string | null;
filters: FilterGroup[];
enabled: boolean;
status: string;
last_polled_at: string | null;
created_at: string;
}