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 credential;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ pub async fn manual_poll(
|
||||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
||||||
|
|
||||||
let tracker = WatchedTracker::get_by_id(&db, &tracker_id)?;
|
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)?
|
let cred = TuleapCredentials::get(&db)?
|
||||||
.ok_or_else(|| AppError::from("No Tuleap credentials configured".to_string()))?;
|
.ok_or_else(|| AppError::from("No Tuleap credentials configured".to_string()))?;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::models::agent::{Agent, AgentRole};
|
||||||
use crate::models::credential::TuleapCredentials;
|
use crate::models::credential::TuleapCredentials;
|
||||||
use crate::models::ticket::ProcessedTicket;
|
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::crypto;
|
||||||
use crate::services::tuleap_client::TuleapClient;
|
use crate::services::tuleap_client::TuleapClient;
|
||||||
use crate::AppState;
|
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]
|
#[tauri::command]
|
||||||
pub fn add_tracker(
|
pub fn add_tracker(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
|
@ -34,7 +48,8 @@ pub fn add_tracker(
|
||||||
tracker_id: i32,
|
tracker_id: i32,
|
||||||
tracker_label: String,
|
tracker_label: String,
|
||||||
polling_interval: i32,
|
polling_interval: i32,
|
||||||
agent_config: AgentConfig,
|
analyst_agent_id: String,
|
||||||
|
developer_agent_id: String,
|
||||||
filters: Vec<FilterGroup>,
|
filters: Vec<FilterGroup>,
|
||||||
) -> Result<WatchedTracker, AppError> {
|
) -> Result<WatchedTracker, AppError> {
|
||||||
let db = state
|
let db = state
|
||||||
|
|
@ -42,13 +57,17 @@ pub fn add_tracker(
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
.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(
|
let tracker = WatchedTracker::insert(
|
||||||
&db,
|
&db,
|
||||||
&project_id,
|
&project_id,
|
||||||
tracker_id,
|
tracker_id,
|
||||||
&tracker_label,
|
&tracker_label,
|
||||||
polling_interval,
|
polling_interval,
|
||||||
agent_config,
|
&analyst_agent_id,
|
||||||
|
&developer_agent_id,
|
||||||
filters,
|
filters,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
|
@ -80,6 +99,9 @@ pub fn update_tracker(
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
|
.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)?;
|
WatchedTracker::update(&db, &id, update)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use std::path::Path;
|
||||||
|
|
||||||
const MIGRATION_001: &str = include_str!("../migrations/001_init.sql");
|
const MIGRATION_001: &str = include_str!("../migrations/001_init.sql");
|
||||||
const MIGRATION_002: &str = include_str!("../migrations/002_add_last_polled.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> {
|
pub fn init(db_path: &Path) -> Result<Connection> {
|
||||||
let conn = Connection::open(db_path)?;
|
let conn = Connection::open(db_path)?;
|
||||||
|
|
@ -36,6 +37,10 @@ fn migrate(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(MIGRATION_002)?;
|
conn.execute_batch(MIGRATION_002)?;
|
||||||
conn.pragma_update(None, "user_version", 2)?;
|
conn.pragma_update(None, "user_version", 2)?;
|
||||||
}
|
}
|
||||||
|
if version < 3 {
|
||||||
|
conn.execute_batch(MIGRATION_003)?;
|
||||||
|
conn.pragma_update(None, "user_version", 3)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +53,7 @@ mod tests {
|
||||||
fn test_init_in_memory_creates_tables() {
|
fn test_init_in_memory_creates_tables() {
|
||||||
let conn = init_in_memory().expect("should initialize");
|
let conn = init_in_memory().expect("should initialize");
|
||||||
|
|
||||||
// Verify all 6 tables exist
|
// Verify all 7 tables exist
|
||||||
let tables: Vec<String> = conn
|
let tables: Vec<String> = conn
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
@ -60,6 +65,7 @@ mod tests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tables,
|
tables,
|
||||||
vec![
|
vec![
|
||||||
|
"agents",
|
||||||
"notifications",
|
"notifications",
|
||||||
"processed_tickets",
|
"processed_tickets",
|
||||||
"projects",
|
"projects",
|
||||||
|
|
@ -86,6 +92,6 @@ mod tests {
|
||||||
let version: i32 = conn
|
let version: i32 = conn
|
||||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(version, 2);
|
assert_eq!(version, 3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ pub fn run() {
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.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::create_project,
|
||||||
commands::project::list_projects,
|
commands::project::list_projects,
|
||||||
commands::project::get_project,
|
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 credential;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
|
|
|
||||||
|
|
@ -181,20 +181,26 @@ impl ProcessedTicket {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::models::agent::{Agent, AgentRole, AgentTool};
|
||||||
use crate::models::project::Project;
|
use crate::models::project::Project;
|
||||||
use crate::models::tracker::{AgentConfig, WatchedTracker};
|
use crate::models::tracker::WatchedTracker;
|
||||||
|
|
||||||
fn setup() -> (Connection, String) {
|
fn setup() -> (Connection, String) {
|
||||||
let conn = db::init_in_memory().expect("db init should succeed");
|
let conn = db::init_in_memory().expect("db init should succeed");
|
||||||
let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap();
|
let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap();
|
||||||
let agent_config = AgentConfig {
|
let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap();
|
||||||
analyst_command: "claude".into(),
|
let developer =
|
||||||
analyst_args: vec![],
|
Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap();
|
||||||
developer_command: "claude".into(),
|
let tracker = WatchedTracker::insert(
|
||||||
developer_args: vec![],
|
&conn,
|
||||||
};
|
&project.id,
|
||||||
let tracker =
|
456,
|
||||||
WatchedTracker::insert(&conn, &project.id, 456, "Bugs", 10, agent_config, vec![])
|
"Bugs",
|
||||||
|
10,
|
||||||
|
&analyst.id,
|
||||||
|
&developer.id,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
(conn, tracker.id)
|
(conn, tracker.id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,6 @@ use rusqlite::{params, Connection, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct FilterGroup {
|
pub struct FilterGroup {
|
||||||
pub conditions: Vec<Filter>,
|
pub conditions: Vec<Filter>,
|
||||||
|
|
@ -29,9 +21,11 @@ pub struct WatchedTracker {
|
||||||
pub tracker_id: i32,
|
pub tracker_id: i32,
|
||||||
pub tracker_label: String,
|
pub tracker_label: String,
|
||||||
pub polling_interval: i32,
|
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 filters: Vec<FilterGroup>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
pub status: String,
|
||||||
pub last_polled_at: Option<String>,
|
pub last_polled_at: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
@ -41,20 +35,35 @@ pub struct TrackerUpdate {
|
||||||
pub tracker_id: i32,
|
pub tracker_id: i32,
|
||||||
pub tracker_label: String,
|
pub tracker_label: String,
|
||||||
pub polling_interval: i32,
|
pub polling_interval: i32,
|
||||||
pub agent_config: AgentConfig,
|
pub analyst_agent_id: String,
|
||||||
|
pub developer_agent_id: String,
|
||||||
pub filters: Vec<FilterGroup>,
|
pub filters: Vec<FilterGroup>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<WatchedTracker> {
|
fn normalize_agent_id(agent_id: &str) -> Option<String> {
|
||||||
let agent_config_json: String = row.get(5)?;
|
let trimmed = agent_id.trim();
|
||||||
let filters_json: String = row.get(6)?;
|
if trimmed.is_empty() {
|
||||||
let enabled_int: i32 = row.get(7)?;
|
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)
|
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 {
|
Ok(WatchedTracker {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
|
|
@ -62,11 +71,13 @@ fn from_row(row: &rusqlite::Row) -> rusqlite::Result<WatchedTracker> {
|
||||||
tracker_id: row.get(2)?,
|
tracker_id: row.get(2)?,
|
||||||
tracker_label: row.get(3)?,
|
tracker_label: row.get(3)?,
|
||||||
polling_interval: row.get(4)?,
|
polling_interval: row.get(4)?,
|
||||||
agent_config,
|
analyst_agent_id: row.get(5)?,
|
||||||
|
developer_agent_id: row.get(6)?,
|
||||||
filters,
|
filters,
|
||||||
enabled: enabled_int != 0,
|
enabled: enabled_int != 0,
|
||||||
last_polled_at: row.get(8)?,
|
status: row.get(9)?,
|
||||||
created_at: row.get(9)?,
|
last_polled_at: row.get(10)?,
|
||||||
|
created_at: row.get(11)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,21 +88,36 @@ impl WatchedTracker {
|
||||||
tracker_id: i32,
|
tracker_id: i32,
|
||||||
tracker_label: &str,
|
tracker_label: &str,
|
||||||
polling_interval: i32,
|
polling_interval: i32,
|
||||||
agent_config: AgentConfig,
|
analyst_agent_id: &str,
|
||||||
|
developer_agent_id: &str,
|
||||||
filters: Vec<FilterGroup>,
|
filters: Vec<FilterGroup>,
|
||||||
) -> Result<WatchedTracker> {
|
) -> Result<WatchedTracker> {
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
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)
|
let filters_json = serde_json::to_string(&filters)
|
||||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
.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(
|
conn.execute(
|
||||||
"INSERT INTO watched_trackers (id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, created_at) \
|
"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)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
params![id, project_id, tracker_id, tracker_label, polling_interval, agent_config_json, filters_json, now],
|
params![
|
||||||
|
id,
|
||||||
|
project_id,
|
||||||
|
tracker_id,
|
||||||
|
tracker_label,
|
||||||
|
polling_interval,
|
||||||
|
"{}",
|
||||||
|
filters_json,
|
||||||
|
analyst_agent_id,
|
||||||
|
developer_agent_id,
|
||||||
|
status,
|
||||||
|
now,
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(WatchedTracker {
|
Ok(WatchedTracker {
|
||||||
|
|
@ -100,9 +126,11 @@ impl WatchedTracker {
|
||||||
tracker_id,
|
tracker_id,
|
||||||
tracker_label: tracker_label.to_string(),
|
tracker_label: tracker_label.to_string(),
|
||||||
polling_interval,
|
polling_interval,
|
||||||
agent_config,
|
analyst_agent_id,
|
||||||
|
developer_agent_id,
|
||||||
filters,
|
filters,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
status,
|
||||||
last_polled_at: None,
|
last_polled_at: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
})
|
})
|
||||||
|
|
@ -110,7 +138,7 @@ impl WatchedTracker {
|
||||||
|
|
||||||
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<WatchedTracker>> {
|
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<WatchedTracker>> {
|
||||||
let mut stmt = conn.prepare(
|
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",
|
FROM watched_trackers WHERE project_id = ?1 ORDER BY created_at DESC",
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![project_id], from_row)?;
|
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>> {
|
pub fn list_all_enabled(conn: &Connection) -> Result<Vec<WatchedTracker>> {
|
||||||
let mut stmt = conn.prepare(
|
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 enabled = 1 ORDER BY created_at DESC",
|
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)?;
|
let rows = stmt.query_map([], from_row)?;
|
||||||
rows.collect()
|
rows.collect()
|
||||||
|
|
@ -128,7 +156,7 @@ impl WatchedTracker {
|
||||||
|
|
||||||
pub fn get_by_id(conn: &Connection, id: &str) -> Result<WatchedTracker> {
|
pub fn get_by_id(conn: &Connection, id: &str) -> Result<WatchedTracker> {
|
||||||
conn.query_row(
|
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",
|
FROM watched_trackers WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
from_row,
|
from_row,
|
||||||
|
|
@ -136,20 +164,24 @@ impl WatchedTracker {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(conn: &Connection, id: &str, update: TrackerUpdate) -> Result<()> {
|
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)
|
let filters_json = serde_json::to_string(&update.filters)
|
||||||
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
|
||||||
let enabled_int = if update.enabled { 1i32 } else { 0i32 };
|
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(
|
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![
|
params![
|
||||||
update.tracker_id,
|
update.tracker_id,
|
||||||
update.tracker_label,
|
update.tracker_label,
|
||||||
update.polling_interval,
|
update.polling_interval,
|
||||||
agent_config_json,
|
|
||||||
filters_json,
|
filters_json,
|
||||||
|
analyst_agent_id,
|
||||||
|
developer_agent_id,
|
||||||
|
status,
|
||||||
enabled_int,
|
enabled_int,
|
||||||
id
|
id
|
||||||
],
|
],
|
||||||
|
|
@ -186,6 +218,7 @@ impl WatchedTracker {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::models::agent::{Agent, AgentRole, AgentTool};
|
||||||
use crate::models::project::Project;
|
use crate::models::project::Project;
|
||||||
|
|
||||||
fn setup() -> Connection {
|
fn setup() -> Connection {
|
||||||
|
|
@ -198,13 +231,26 @@ mod tests {
|
||||||
Project::list(conn).unwrap().into_iter().next().unwrap().id
|
Project::list(conn).unwrap().into_iter().next().unwrap().id
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_agent_config() -> AgentConfig {
|
fn create_agents(conn: &Connection) -> (String, String) {
|
||||||
AgentConfig {
|
let analyst = Agent::insert(
|
||||||
analyst_command: "analyst".to_string(),
|
conn,
|
||||||
analyst_args: vec!["--mode".to_string(), "analyze".to_string()],
|
"Analyst",
|
||||||
developer_command: "developer".to_string(),
|
AgentRole::Analyst,
|
||||||
developer_args: vec!["--fix".to_string()],
|
AgentTool::Codex,
|
||||||
}
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let developer = Agent::insert(
|
||||||
|
conn,
|
||||||
|
"Developer",
|
||||||
|
AgentRole::Developer,
|
||||||
|
AgentTool::ClaudeCode,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
(analyst.id, developer.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_filters() -> Vec<FilterGroup> {
|
fn sample_filters() -> Vec<FilterGroup> {
|
||||||
|
|
@ -221,6 +267,7 @@ mod tests {
|
||||||
fn test_insert_tracker() {
|
fn test_insert_tracker() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let pid = project_id(&conn);
|
let pid = project_id(&conn);
|
||||||
|
let (analyst_id, developer_id) = create_agents(&conn);
|
||||||
|
|
||||||
let tracker = WatchedTracker::insert(
|
let tracker = WatchedTracker::insert(
|
||||||
&conn,
|
&conn,
|
||||||
|
|
@ -228,7 +275,8 @@ mod tests {
|
||||||
42,
|
42,
|
||||||
"Bug Tracker",
|
"Bug Tracker",
|
||||||
15,
|
15,
|
||||||
sample_agent_config(),
|
&analyst_id,
|
||||||
|
&developer_id,
|
||||||
sample_filters(),
|
sample_filters(),
|
||||||
)
|
)
|
||||||
.expect("insert should succeed");
|
.expect("insert should succeed");
|
||||||
|
|
@ -239,9 +287,11 @@ mod tests {
|
||||||
assert_eq!(tracker.tracker_label, "Bug Tracker");
|
assert_eq!(tracker.tracker_label, "Bug Tracker");
|
||||||
assert_eq!(tracker.polling_interval, 15);
|
assert_eq!(tracker.polling_interval, 15);
|
||||||
assert!(tracker.enabled);
|
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.last_polled_at.is_none());
|
||||||
assert!(!tracker.created_at.is_empty());
|
assert!(!tracker.created_at.is_empty());
|
||||||
assert_eq!(tracker.agent_config.analyst_command, "analyst");
|
|
||||||
assert_eq!(tracker.filters.len(), 1);
|
assert_eq!(tracker.filters.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -249,46 +299,34 @@ mod tests {
|
||||||
fn test_list_by_project() {
|
fn test_list_by_project() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let pid = project_id(&conn);
|
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, 1, "Tracker A", 10, &analyst_id, &developer_id, vec![]).unwrap();
|
||||||
WatchedTracker::insert(&conn, &pid, 2, "Tracker B", 20, sample_agent_config(), 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");
|
let trackers = WatchedTracker::list_by_project(&conn, &pid).expect("list should succeed");
|
||||||
assert_eq!(trackers.len(), 2);
|
assert_eq!(trackers.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_all_enabled() {
|
fn test_list_all_enabled_ignores_invalid() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let pid = project_id(&conn);
|
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 valid = WatchedTracker::insert(&conn, &pid, 1, "Valid", 10, &analyst_id, &developer_id, vec![]).unwrap();
|
||||||
let t2 = WatchedTracker::insert(&conn, &pid, 2, "Disabled", 10, sample_agent_config(), vec![]).unwrap();
|
WatchedTracker::insert(&conn, &pid, 2, "Invalid", 10, "", &developer_id, 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 enabled = WatchedTracker::list_all_enabled(&conn).expect("list_all_enabled should succeed");
|
let enabled = WatchedTracker::list_all_enabled(&conn).expect("list_all_enabled should succeed");
|
||||||
assert_eq!(enabled.len(), 1);
|
assert_eq!(enabled.len(), 1);
|
||||||
assert_eq!(enabled[0].id, t1.id);
|
assert_eq!(enabled[0].id, valid.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_by_id() {
|
fn test_get_by_id() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let pid = project_id(&conn);
|
let pid = project_id(&conn);
|
||||||
|
let (analyst_id, developer_id) = create_agents(&conn);
|
||||||
|
|
||||||
let created = WatchedTracker::insert(
|
let created = WatchedTracker::insert(
|
||||||
&conn,
|
&conn,
|
||||||
|
|
@ -296,7 +334,8 @@ mod tests {
|
||||||
99,
|
99,
|
||||||
"My Tracker",
|
"My Tracker",
|
||||||
30,
|
30,
|
||||||
sample_agent_config(),
|
&analyst_id,
|
||||||
|
&developer_id,
|
||||||
sample_filters(),
|
sample_filters(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -313,6 +352,7 @@ mod tests {
|
||||||
fn test_update_tracker() {
|
fn test_update_tracker() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let pid = project_id(&conn);
|
let pid = project_id(&conn);
|
||||||
|
let (analyst_id, developer_id) = create_agents(&conn);
|
||||||
|
|
||||||
let created = WatchedTracker::insert(
|
let created = WatchedTracker::insert(
|
||||||
&conn,
|
&conn,
|
||||||
|
|
@ -320,7 +360,8 @@ mod tests {
|
||||||
10,
|
10,
|
||||||
"Original",
|
"Original",
|
||||||
5,
|
5,
|
||||||
sample_agent_config(),
|
&analyst_id,
|
||||||
|
&developer_id,
|
||||||
sample_filters(),
|
sample_filters(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -340,7 +381,8 @@ mod tests {
|
||||||
tracker_id: 11,
|
tracker_id: 11,
|
||||||
tracker_label: "Updated tracker".to_string(),
|
tracker_label: "Updated tracker".to_string(),
|
||||||
polling_interval: 60,
|
polling_interval: 60,
|
||||||
agent_config: sample_agent_config(),
|
analyst_agent_id: analyst_id,
|
||||||
|
developer_agent_id: developer_id,
|
||||||
filters: new_filters,
|
filters: new_filters,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
},
|
},
|
||||||
|
|
@ -352,6 +394,7 @@ mod tests {
|
||||||
assert_eq!(updated.tracker_label, "Updated tracker");
|
assert_eq!(updated.tracker_label, "Updated tracker");
|
||||||
assert_eq!(updated.polling_interval, 60);
|
assert_eq!(updated.polling_interval, 60);
|
||||||
assert!(!updated.enabled);
|
assert!(!updated.enabled);
|
||||||
|
assert_eq!(updated.status, "valid");
|
||||||
assert_eq!(updated.filters[0].conditions[0].field, "priority");
|
assert_eq!(updated.filters[0].conditions[0].field, "priority");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,9 +402,19 @@ mod tests {
|
||||||
fn test_update_last_polled() {
|
fn test_update_last_polled() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let pid = project_id(&conn);
|
let pid = project_id(&conn);
|
||||||
|
let (analyst_id, developer_id) = create_agents(&conn);
|
||||||
|
|
||||||
let created =
|
let created = WatchedTracker::insert(
|
||||||
WatchedTracker::insert(&conn, &pid, 5, "Poller", 10, sample_agent_config(), vec![]).unwrap();
|
&conn,
|
||||||
|
&pid,
|
||||||
|
5,
|
||||||
|
"Poller",
|
||||||
|
10,
|
||||||
|
&analyst_id,
|
||||||
|
&developer_id,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(created.last_polled_at.is_none());
|
assert!(created.last_polled_at.is_none());
|
||||||
|
|
||||||
|
|
@ -375,9 +428,19 @@ mod tests {
|
||||||
fn test_delete_tracker() {
|
fn test_delete_tracker() {
|
||||||
let conn = setup();
|
let conn = setup();
|
||||||
let pid = project_id(&conn);
|
let pid = project_id(&conn);
|
||||||
|
let (analyst_id, developer_id) = create_agents(&conn);
|
||||||
|
|
||||||
let created =
|
let created = WatchedTracker::insert(
|
||||||
WatchedTracker::insert(&conn, &pid, 7, "ToDelete", 10, sample_agent_config(), vec![]).unwrap();
|
&conn,
|
||||||
|
&pid,
|
||||||
|
7,
|
||||||
|
"ToDelete",
|
||||||
|
10,
|
||||||
|
&analyst_id,
|
||||||
|
&developer_id,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
WatchedTracker::delete(&conn, &created.id).expect("delete should succeed");
|
WatchedTracker::delete(&conn, &created.id).expect("delete should succeed");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,20 +100,27 @@ impl Worktree {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::models::agent::{Agent, AgentRole, AgentTool};
|
||||||
use crate::models::project::Project;
|
use crate::models::project::Project;
|
||||||
use crate::models::ticket::ProcessedTicket;
|
use crate::models::ticket::ProcessedTicket;
|
||||||
use crate::models::tracker::{AgentConfig, WatchedTracker};
|
use crate::models::tracker::WatchedTracker;
|
||||||
|
|
||||||
fn setup() -> (Connection, String) {
|
fn setup() -> (Connection, String) {
|
||||||
let conn = db::init_in_memory().expect("db init");
|
let conn = db::init_in_memory().expect("db init");
|
||||||
let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap();
|
let project = Project::insert(&conn, "Test", "/path", None, "main").unwrap();
|
||||||
let agent_config = AgentConfig {
|
let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap();
|
||||||
analyst_command: "echo".into(),
|
let developer =
|
||||||
analyst_args: vec![],
|
Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap();
|
||||||
developer_command: "echo".into(),
|
let tracker = WatchedTracker::insert(
|
||||||
developer_args: vec![],
|
&conn,
|
||||||
};
|
&project.id,
|
||||||
let tracker = WatchedTracker::insert(&conn, &project.id, 100, "Bugs", 10, agent_config, vec![])
|
100,
|
||||||
|
"Bugs",
|
||||||
|
10,
|
||||||
|
&analyst.id,
|
||||||
|
&developer.id,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker.id, 42, "Bug 42", "{}")
|
let ticket = ProcessedTicket::insert_if_new(&conn, &tracker.id, 42, "Bug 42", "{}")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
@ -153,13 +160,19 @@ mod tests {
|
||||||
fn test_list_by_project() {
|
fn test_list_by_project() {
|
||||||
let conn = db::init_in_memory().expect("db init");
|
let conn = db::init_in_memory().expect("db init");
|
||||||
let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap();
|
let project = Project::insert(&conn, "P1", "/path", None, "main").unwrap();
|
||||||
let agent_config = AgentConfig {
|
let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap();
|
||||||
analyst_command: "echo".into(),
|
let developer =
|
||||||
analyst_args: vec![],
|
Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap();
|
||||||
developer_command: "echo".into(),
|
let tracker = WatchedTracker::insert(
|
||||||
developer_args: vec![],
|
&conn,
|
||||||
};
|
&project.id,
|
||||||
let tracker = WatchedTracker::insert(&conn, &project.id, 100, "Bugs", 10, agent_config, vec![])
|
100,
|
||||||
|
"Bugs",
|
||||||
|
10,
|
||||||
|
&analyst.id,
|
||||||
|
&developer.id,
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let t1 = ProcessedTicket::insert_if_new(&conn, &tracker.id, 1, "T1", "{}")
|
let t1 = ProcessedTicket::insert_if_new(&conn, &tracker.id, 1, "T1", "{}")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
|
||||||
|
|
@ -139,9 +139,10 @@ pub fn notify_error(
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
use crate::models::agent::{Agent, AgentRole, AgentTool};
|
||||||
use crate::models::project::Project;
|
use crate::models::project::Project;
|
||||||
use crate::models::ticket::ProcessedTicket;
|
use crate::models::ticket::ProcessedTicket;
|
||||||
use crate::models::tracker::{AgentConfig, WatchedTracker};
|
use crate::models::tracker::WatchedTracker;
|
||||||
|
|
||||||
fn setup() -> (Arc<Mutex<Connection>>, String) {
|
fn setup() -> (Arc<Mutex<Connection>>, String) {
|
||||||
let conn = db::init_in_memory().expect("db init should succeed");
|
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 {
|
fn setup_ticket(db: &Arc<Mutex<Connection>>, project_id: &str) -> String {
|
||||||
let conn = db.lock().expect("db lock should succeed");
|
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(
|
let tracker = WatchedTracker::insert(
|
||||||
&conn,
|
&conn,
|
||||||
|
|
@ -159,12 +163,8 @@ mod tests {
|
||||||
101,
|
101,
|
||||||
"Bugs",
|
"Bugs",
|
||||||
10,
|
10,
|
||||||
AgentConfig {
|
&analyst.id,
|
||||||
analyst_command: "echo".to_string(),
|
&developer.id,
|
||||||
analyst_args: vec![],
|
|
||||||
developer_command: "echo".to_string(),
|
|
||||||
developer_args: vec![],
|
|
||||||
},
|
|
||||||
vec![],
|
vec![],
|
||||||
)
|
)
|
||||||
.expect("tracker insert should succeed");
|
.expect("tracker insert should succeed");
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use crate::models::agent::{Agent, AgentRole};
|
||||||
use crate::models::project::Project;
|
use crate::models::project::Project;
|
||||||
use crate::models::ticket::ProcessedTicket;
|
use crate::models::ticket::ProcessedTicket;
|
||||||
use crate::models::tracker::WatchedTracker;
|
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 {
|
pub fn parse_verdict(report: &str) -> Verdict {
|
||||||
for line in report.lines().rev() {
|
for line in report.lines().rev() {
|
||||||
let trimmed = line.trim();
|
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)
|
let project = Project::get_by_id(&conn, &tracker.project_id)
|
||||||
.map_err(|e| format!("get project failed: {}", e))?;
|
.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)
|
(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(
|
let _ = app_handle.emit(
|
||||||
"ticket-processing-started",
|
"ticket-processing-started",
|
||||||
serde_json::json!({
|
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(
|
let analyst_result = run_cli_command(
|
||||||
&tracker.agent_config.analyst_command,
|
analyst_agent.tool.to_command(),
|
||||||
&tracker.agent_config.analyst_args,
|
&analyst_args,
|
||||||
&analyst_prompt,
|
&analyst_prompt,
|
||||||
&project.path,
|
&project.path,
|
||||||
600,
|
600,
|
||||||
|
|
@ -205,10 +355,7 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
|
||||||
let analyst_report = match analyst_result {
|
let analyst_report = match analyst_result {
|
||||||
Ok(report) => report,
|
Ok(report) => report,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?;
|
record_ticket_error(
|
||||||
let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e);
|
|
||||||
drop(conn);
|
|
||||||
notifier::notify_error(
|
|
||||||
db,
|
db,
|
||||||
app_handle,
|
app_handle,
|
||||||
&project.id,
|
&project.id,
|
||||||
|
|
@ -216,15 +363,6 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
|
||||||
ticket.artifact_id,
|
ticket.artifact_id,
|
||||||
&e,
|
&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);
|
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);
|
worktree_manager::create_worktree(&project.path, &project.base_branch, ticket.artifact_id);
|
||||||
|
|
||||||
if let Err(e) = &worktree_result {
|
if let Err(e) = &worktree_result {
|
||||||
if let Ok(conn) = db.lock() {
|
record_ticket_error(
|
||||||
let _ = ProcessedTicket::set_error(&conn, &ticket.id, e);
|
|
||||||
}
|
|
||||||
notifier::notify_error(
|
|
||||||
db,
|
db,
|
||||||
app_handle,
|
app_handle,
|
||||||
&project.id,
|
&project.id,
|
||||||
|
|
@ -281,15 +416,6 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
|
||||||
ticket.artifact_id,
|
ticket.artifact_id,
|
||||||
e,
|
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?;
|
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(
|
let developer_result = run_cli_command(
|
||||||
&tracker.agent_config.developer_command,
|
developer_agent.tool.to_command(),
|
||||||
&tracker.agent_config.developer_args,
|
&developer_args,
|
||||||
&developer_prompt,
|
&developer_prompt,
|
||||||
&wt_path,
|
&wt_path,
|
||||||
600,
|
600,
|
||||||
|
|
@ -329,10 +459,7 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
|
||||||
let developer_report = match developer_result {
|
let developer_report = match developer_result {
|
||||||
Ok(report) => report,
|
Ok(report) => report,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let conn = db.lock().map_err(|e2| format!("DB lock: {}", e2))?;
|
record_ticket_error(
|
||||||
let _ = ProcessedTicket::set_error(&conn, &ticket.id, &e);
|
|
||||||
drop(conn);
|
|
||||||
notifier::notify_error(
|
|
||||||
db,
|
db,
|
||||||
app_handle,
|
app_handle,
|
||||||
&project.id,
|
&project.id,
|
||||||
|
|
@ -340,15 +467,6 @@ async fn process_ticket(db: &Arc<Mutex<Connection>>, app_handle: &AppHandle) ->
|
||||||
ticket.artifact_id,
|
ticket.artifact_id,
|
||||||
&e,
|
&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);
|
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 { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import AppLayout from "./components/layout/AppLayout";
|
import AppLayout from "./components/layout/AppLayout";
|
||||||
import ProjectForm from "./components/projects/ProjectForm";
|
import ProjectForm from "./components/projects/ProjectForm";
|
||||||
|
|
@ -27,6 +29,9 @@ function App() {
|
||||||
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
|
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
|
||||||
<Route path="/projects/:projectId/trackers/new" element={<TrackerConfig />} />
|
<Route path="/projects/:projectId/trackers/new" element={<TrackerConfig />} />
|
||||||
<Route path="/projects/:projectId/trackers/:trackerConfigId/edit" 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="/tickets/:ticketId" element={<TicketDetail />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<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 { 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 appLogo from "../../assets/app-logo.jpg";
|
||||||
import { listProjects } from "../../lib/api";
|
import { listProjects } from "../../lib/api";
|
||||||
import type { Project } from "../../lib/types";
|
import type { Project } from "../../lib/types";
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Project } from "../../lib/types";
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const { projectId } = useParams();
|
const { projectId } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listProjects().then(setProjects);
|
listProjects().then(setProjects);
|
||||||
|
|
@ -65,6 +66,19 @@ export default function Sidebar() {
|
||||||
{projects.length === 0 && (
|
{projects.length === 0 && (
|
||||||
<p className="px-3 py-2 text-sm text-gray-500">No projects yet</p>
|
<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>
|
</nav>
|
||||||
|
|
||||||
<div className="p-2 border-t border-gray-700">
|
<div className="p-2 border-t border-gray-700">
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useEffect } from "react";
|
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 { getErrorMessage } from "../../lib/errors";
|
||||||
import type { FilterGroup, TrackerField, AgentConfig } from "../../lib/types";
|
import type { FilterGroup, TrackerField, Agent } from "../../lib/types";
|
||||||
import FilterBuilder from "./FilterBuilder";
|
import FilterBuilder from "./FilterBuilder";
|
||||||
|
|
||||||
export default function TrackerConfig() {
|
export default function TrackerConfig() {
|
||||||
|
|
@ -18,9 +24,11 @@ export default function TrackerConfig() {
|
||||||
const [fieldsLoaded, setFieldsLoaded] = useState(false);
|
const [fieldsLoaded, setFieldsLoaded] = useState(false);
|
||||||
const [fieldsLoading, setFieldsLoading] = useState(false);
|
const [fieldsLoading, setFieldsLoading] = useState(false);
|
||||||
const [filters, setFilters] = useState<FilterGroup[]>([]);
|
const [filters, setFilters] = useState<FilterGroup[]>([]);
|
||||||
const [analystCommand, setAnalystCommand] = useState("claude");
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [developerCommand, setDeveloperCommand] = useState("claude");
|
const [analystAgentId, setAnalystAgentId] = useState("");
|
||||||
|
const [developerAgentId, setDeveloperAgentId] = useState("");
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [trackerStatus, setTrackerStatus] = useState<"valid" | "invalid">("valid");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [initializing, setInitializing] = 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(() => {
|
useEffect(() => {
|
||||||
async function loadTrackerForEdit() {
|
async function loadTrackerForEdit() {
|
||||||
if (!projectId || !trackerConfigId) return;
|
if (!projectId || !trackerConfigId) return;
|
||||||
|
|
@ -54,9 +88,10 @@ export default function TrackerConfig() {
|
||||||
setTrackerLabel(tracker.tracker_label);
|
setTrackerLabel(tracker.tracker_label);
|
||||||
setPollingInterval(tracker.polling_interval);
|
setPollingInterval(tracker.polling_interval);
|
||||||
setFilters(tracker.filters);
|
setFilters(tracker.filters);
|
||||||
setAnalystCommand(tracker.agent_config.analyst_command);
|
setAnalystAgentId(tracker.analyst_agent_id ?? "");
|
||||||
setDeveloperCommand(tracker.agent_config.developer_command);
|
setDeveloperAgentId(tracker.developer_agent_id ?? "");
|
||||||
setEnabled(tracker.enabled);
|
setEnabled(tracker.enabled);
|
||||||
|
setTrackerStatus(tracker.status === "invalid" ? "invalid" : "valid");
|
||||||
|
|
||||||
const trackerFields = await getTrackerFields(tracker.tracker_id);
|
const trackerFields = await getTrackerFields(tracker.tracker_id);
|
||||||
setFields(sortTrackerFields(trackerFields));
|
setFields(sortTrackerFields(trackerFields));
|
||||||
|
|
@ -89,16 +124,14 @@ export default function TrackerConfig() {
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!projectId || trackerId === "") return;
|
if (!projectId || trackerId === "") return;
|
||||||
|
if (!analystAgentId || !developerAgentId) {
|
||||||
|
setError("Please select one analyst agent and one developer agent.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const agentConfig: AgentConfig = {
|
|
||||||
analyst_command: analystCommand,
|
|
||||||
analyst_args: [],
|
|
||||||
developer_command: developerCommand,
|
|
||||||
developer_args: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEditing && trackerConfigId) {
|
if (isEditing && trackerConfigId) {
|
||||||
await updateTracker(
|
await updateTracker(
|
||||||
|
|
@ -106,7 +139,8 @@ export default function TrackerConfig() {
|
||||||
Number(trackerId),
|
Number(trackerId),
|
||||||
trackerLabel,
|
trackerLabel,
|
||||||
pollingInterval,
|
pollingInterval,
|
||||||
agentConfig,
|
analystAgentId,
|
||||||
|
developerAgentId,
|
||||||
filters,
|
filters,
|
||||||
enabled
|
enabled
|
||||||
);
|
);
|
||||||
|
|
@ -116,7 +150,8 @@ export default function TrackerConfig() {
|
||||||
Number(trackerId),
|
Number(trackerId),
|
||||||
trackerLabel,
|
trackerLabel,
|
||||||
pollingInterval,
|
pollingInterval,
|
||||||
agentConfig,
|
analystAgentId,
|
||||||
|
developerAgentId,
|
||||||
filters
|
filters
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +174,18 @@ export default function TrackerConfig() {
|
||||||
<div className="text-sm text-gray-500">Loading tracker...</div>
|
<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 */}
|
{/* Basic fields */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
|
<div className="bg-white rounded-lg border border-gray-200 p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -227,25 +274,39 @@ export default function TrackerConfig() {
|
||||||
<h3 className="text-sm font-semibold text-gray-700">Agent configuration</h3>
|
<h3 className="text-sm font-semibold text-gray-700">Agent configuration</h3>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Analyst command
|
Analyst agent
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
value={analystAgentId}
|
||||||
value={analystCommand}
|
onChange={(e) => setAnalystAgentId(e.target.value)}
|
||||||
onChange={(e) => setAnalystCommand(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"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Developer command
|
Developer agent
|
||||||
</label>
|
</label>
|
||||||
<input
|
<select
|
||||||
type="text"
|
value={developerAgentId}
|
||||||
value={developerCommand}
|
onChange={(e) => setDeveloperAgentId(e.target.value)}
|
||||||
onChange={(e) => setDeveloperCommand(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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
|
||||||
const [trackerToRemove, setTrackerToRemove] = useState<WatchedTracker | null>(null);
|
const [trackerToRemove, setTrackerToRemove] = useState<WatchedTracker | null>(null);
|
||||||
|
|
||||||
async function handlePollNow(tracker: WatchedTracker) {
|
async function handlePollNow(tracker: WatchedTracker) {
|
||||||
|
if (tracker.status !== "valid") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setPollingIds((prev) => [...prev, tracker.id]);
|
setPollingIds((prev) => [...prev, tracker.id]);
|
||||||
await manualPoll(tracker.id);
|
await manualPoll(tracker.id);
|
||||||
|
|
@ -33,7 +36,8 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
|
||||||
tracker.tracker_id,
|
tracker.tracker_id,
|
||||||
tracker.tracker_label,
|
tracker.tracker_label,
|
||||||
tracker.polling_interval,
|
tracker.polling_interval,
|
||||||
tracker.agent_config,
|
tracker.analyst_agent_id ?? "",
|
||||||
|
tracker.developer_agent_id ?? "",
|
||||||
tracker.filters,
|
tracker.filters,
|
||||||
!tracker.enabled
|
!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 text-gray-400">#{tracker.tracker_id}</span>
|
||||||
<span
|
<span
|
||||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
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-green-100 text-green-700"
|
||||||
: "bg-gray-100 text-gray-500"
|
: "bg-gray-100 text-gray-500"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tracker.enabled ? "Active" : "Paused"}
|
{tracker.status === "invalid" ? "Invalid" : tracker.enabled ? "Active" : "Paused"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400 mt-1">
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
|
@ -91,7 +97,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePollNow(tracker)}
|
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"
|
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"}
|
{pollingIds.includes(tracker.id) ? "Polling..." : "Poll now"}
|
||||||
|
|
@ -105,6 +111,7 @@ export default function TrackerList({ trackers, projectId, onRefresh }: Props) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToggleEnabled(tracker)}
|
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"
|
className="px-3 py-1 bg-gray-200 text-gray-700 rounded text-xs hover:bg-gray-300"
|
||||||
>
|
>
|
||||||
{tracker.enabled ? "Pause" : "Resume"}
|
{tracker.enabled ? "Pause" : "Resume"}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { invoke } from "@tauri-apps/api/core";
|
||||||
import type {
|
import type {
|
||||||
Project,
|
Project,
|
||||||
TuleapCredentialsSafe,
|
TuleapCredentialsSafe,
|
||||||
AgentConfig,
|
Agent,
|
||||||
|
AgentRole,
|
||||||
|
AgentTool,
|
||||||
FilterGroup,
|
FilterGroup,
|
||||||
WatchedTracker,
|
WatchedTracker,
|
||||||
TrackerField,
|
TrackerField,
|
||||||
|
|
@ -44,6 +46,34 @@ export async function deleteProject(id: string): Promise<void> {
|
||||||
return invoke("delete_project", { id });
|
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
|
// Credentials
|
||||||
export async function setTuleapCredentials(tuleapUrl: string, username: string, password: string): Promise<TuleapCredentialsSafe> {
|
export async function setTuleapCredentials(tuleapUrl: string, username: string, password: string): Promise<TuleapCredentialsSafe> {
|
||||||
return invoke("set_tuleap_credentials", { tuleapUrl, username, password });
|
return invoke("set_tuleap_credentials", { tuleapUrl, username, password });
|
||||||
|
|
@ -59,8 +89,24 @@ export async function testTuleapConnection(): Promise<string> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trackers
|
// Trackers
|
||||||
export async function addTracker(projectId: string, trackerId: number, trackerLabel: string, pollingInterval: number, agentConfig: AgentConfig, filters: FilterGroup[]): Promise<WatchedTracker> {
|
export async function addTracker(
|
||||||
return invoke("add_tracker", { projectId, trackerId, trackerLabel, pollingInterval, agentConfig, filters });
|
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[]> {
|
export async function listTrackers(projectId: string): Promise<WatchedTracker[]> {
|
||||||
return invoke("list_trackers", { projectId });
|
return invoke("list_trackers", { projectId });
|
||||||
|
|
@ -70,7 +116,8 @@ export async function updateTracker(
|
||||||
trackerId: number,
|
trackerId: number,
|
||||||
trackerLabel: string,
|
trackerLabel: string,
|
||||||
pollingInterval: number,
|
pollingInterval: number,
|
||||||
agentConfig: AgentConfig,
|
analystAgentId: string,
|
||||||
|
developerAgentId: string,
|
||||||
filters: FilterGroup[],
|
filters: FilterGroup[],
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -80,7 +127,8 @@ export async function updateTracker(
|
||||||
tracker_id: trackerId,
|
tracker_id: trackerId,
|
||||||
tracker_label: trackerLabel,
|
tracker_label: trackerLabel,
|
||||||
polling_interval: pollingInterval,
|
polling_interval: pollingInterval,
|
||||||
agent_config: agentConfig,
|
analyst_agent_id: analystAgentId,
|
||||||
|
developer_agent_id: developerAgentId,
|
||||||
filters,
|
filters,
|
||||||
enabled,
|
enabled,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,17 @@ export interface TuleapCredentialsSafe {
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentConfig {
|
export type AgentRole = "analyst" | "developer";
|
||||||
analyst_command: string;
|
export type AgentTool = "codex" | "claude_code";
|
||||||
analyst_args: string[];
|
|
||||||
developer_command: string;
|
export interface Agent {
|
||||||
developer_args: string[];
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: AgentRole;
|
||||||
|
tool: AgentTool;
|
||||||
|
custom_prompt: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
|
|
@ -48,9 +54,11 @@ export interface WatchedTracker {
|
||||||
tracker_id: number;
|
tracker_id: number;
|
||||||
tracker_label: string;
|
tracker_label: string;
|
||||||
polling_interval: number;
|
polling_interval: number;
|
||||||
agent_config: AgentConfig;
|
analyst_agent_id: string | null;
|
||||||
|
developer_agent_id: string | null;
|
||||||
filters: FilterGroup[];
|
filters: FilterGroup[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
status: string;
|
||||||
last_polled_at: string | null;
|
last_polled_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue