feat: add global agent management and tracker agent assignment
This commit is contained in:
parent
54fdfc7053
commit
b7fc4123a6
22 changed files with 1255 additions and 202 deletions
17
src-tauri/migrations/003_add_agents.sql
Normal file
17
src-tauri/migrations/003_add_agents.sql
Normal 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;
|
||||
98
src-tauri/src/commands/agent.rs
Normal file
98
src-tauri/src/commands/agent.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod agent;
|
||||
pub mod credential;
|
||||
pub mod notification;
|
||||
pub mod orchestrator;
|
||||
|
|
|
|||
|
|
@ -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()))?;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
280
src-tauri/src/models/agent.rs
Normal file
280
src-tauri/src/models/agent.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod agent;
|
||||
pub mod credential;
|
||||
pub mod notification;
|
||||
pub mod project;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
140
src/components/agents/AgentForm.tsx
Normal file
140
src/components/agents/AgentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
src/components/agents/AgentList.tsx
Normal file
135
src/components/agents/AgentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue