diff --git a/docs/superpowers/specs/2026-04-15-global-ai-orchestrator.md b/docs/superpowers/specs/2026-04-15-global-ai-orchestrator.md new file mode 100644 index 0000000..4e5d1ba --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-global-ai-orchestrator.md @@ -0,0 +1,44 @@ +# Orchai -- Global AI Orchestrator (Iteration 1) + +## Objectif +Transformer Orchai en orchestrateur d'IA global par projet, avec trois capacités natives : + +- module `polling Tuleap + auto-resolve` (hérité, activable/désactivable) +- discussion live avec un agent +- file de tâches traitées par un agent pré-défini + +## Modèle de modules +Chaque projet possède des modules activables (`project_modules`) : + +- `tuleap_polling_auto_resolve` +- `ai_live_chat` +- `agent_task_runner` + +Le polling planifié et le pipeline ticket ne s'exécutent que si `tuleap_polling_auto_resolve` est activé. + +## Live agent +- `project_live_sessions`: sessions de chat liées à un projet et un agent +- `project_live_messages`: messages user/agent/system +- commande Tauri `send_live_message` : + - persiste le message utilisateur + - exécute l'agent CLI dans le repo du projet + - persiste la réponse agent + - émet l'événement `live-agent-message` + +## Tâches agent +- `project_agent_tasks`: tâches asynchrones (`pending`, `running`, `done`, `error`, `cancelled`) +- service `task_runner`: + - boucle de fond + - consomme FIFO les tâches `pending` + - exécute l'agent ciblé + - enregistre résultat/erreur + - émet `agent-task-updated` + +## UI projet +Nouvelles vues : + +- `/projects/:projectId/modules` +- `/projects/:projectId/live-agent` +- `/projects/:projectId/tasks` + +Le dashboard projet expose l'accès direct à ces trois pages. diff --git a/src-tauri/migrations/005_orchestration_modules_chat_tasks.sql b/src-tauri/migrations/005_orchestration_modules_chat_tasks.sql new file mode 100644 index 0000000..3822fad --- /dev/null +++ b/src-tauri/migrations/005_orchestration_modules_chat_tasks.sql @@ -0,0 +1,122 @@ +CREATE TABLE IF NOT EXISTS project_modules ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + module_key TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + config_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(project_id, module_key) +); + +CREATE INDEX IF NOT EXISTS idx_project_modules_project_id ON project_modules(project_id); + +CREATE TABLE IF NOT EXISTS project_live_sessions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id), + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_live_sessions_project_id ON project_live_sessions(project_id); + +CREATE TABLE IF NOT EXISTS project_live_messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES project_live_sessions(id) ON DELETE CASCADE, + sender TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_live_messages_session_id ON project_live_messages(session_id); + +CREATE TABLE IF NOT EXISTS project_agent_tasks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES agents(id), + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + result TEXT, + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + finished_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_agent_tasks_project_id ON project_agent_tasks(project_id); +CREATE INDEX IF NOT EXISTS idx_agent_tasks_status_created_at ON project_agent_tasks(status, created_at); + +INSERT OR IGNORE INTO project_modules ( + id, + project_id, + module_key, + name, + description, + enabled, + config_json, + created_at, + updated_at +) +SELECT + lower(hex(randomblob(16))), + p.id, + 'tuleap_polling_auto_resolve', + 'Polling Tuleap + auto-resolve', + 'Surveille Tuleap et lance le pipeline analyste/developpeur.', + 1, + '{}', + datetime('now'), + datetime('now') +FROM projects p; + +INSERT OR IGNORE INTO project_modules ( + id, + project_id, + module_key, + name, + description, + enabled, + config_json, + created_at, + updated_at +) +SELECT + lower(hex(randomblob(16))), + p.id, + 'ai_live_chat', + 'Live chat agent', + 'Discussion live avec un agent sur le contexte du projet.', + 1, + '{}', + datetime('now'), + datetime('now') +FROM projects p; + +INSERT OR IGNORE INTO project_modules ( + id, + project_id, + module_key, + name, + description, + enabled, + config_json, + created_at, + updated_at +) +SELECT + lower(hex(randomblob(16))), + p.id, + 'agent_task_runner', + 'Agent task runner', + 'File de tâches asynchrones traitées par des agents pré-définis.', + 1, + '{}', + datetime('now'), + datetime('now') +FROM projects p; diff --git a/src-tauri/src/commands/live_agent.rs b/src-tauri/src/commands/live_agent.rs new file mode 100644 index 0000000..792c143 --- /dev/null +++ b/src-tauri/src/commands/live_agent.rs @@ -0,0 +1,177 @@ +use crate::error::AppError; +use crate::models::agent::Agent; +use crate::models::live_agent::{LiveMessage, LiveSession}; +use crate::models::module::{ProjectModule, MODULE_AI_LIVE_CHAT}; +use crate::models::project::Project; +use crate::services::agent_runtime; +use crate::AppState; +use serde::Serialize; +use tauri::{Emitter, State}; + +#[derive(Debug, Clone, Serialize)] +pub struct LiveAgentExchange { + pub user_message: LiveMessage, + pub agent_message: LiveMessage, +} + +fn build_live_prompt(project: &Project, history: &[LiveMessage], user_message: &str) -> String { + let mut prompt = format!( + "Tu es un agent assistant pour un projet logiciel.\\n\\n## Projet\\n- Nom: {}\\n- Repo: {}\\n- Branche de base: {}\\n\\n## Consignes\\n- Réponds de manière actionnable et concise.\\n- Si tu proposes du code ou des commandes, explique brièvement pourquoi.\\n- Réponds en français.\\n\\n## Historique récent\\n", + project.name, project.path, project.base_branch + ); + + for message in history { + let role = if message.sender == "agent" { + "Agent" + } else if message.sender == "system" { + "System" + } else { + "Utilisateur" + }; + + prompt.push_str(&format!("- {}: {}\\n", role, message.content)); + } + + prompt.push_str("\\n## Nouveau message\\n"); + prompt.push_str(user_message); + + prompt +} + +#[tauri::command] +pub fn create_live_session( + state: State<'_, AppState>, + project_id: String, + agent_id: String, + title: String, +) -> Result { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let enabled = ProjectModule::is_enabled(&db, &project_id, MODULE_AI_LIVE_CHAT)?; + if !enabled { + return Err(AppError::from( + "Le module de live chat est désactivé pour ce projet".to_string(), + )); + } + + Project::get_by_id(&db, &project_id)?; + Agent::get_by_id(&db, &agent_id)?; + + let normalized_title = if title.trim().is_empty() { + "Nouvelle session live".to_string() + } else { + title.trim().to_string() + }; + + let session = LiveSession::create(&db, &project_id, &agent_id, &normalized_title)?; + Ok(session) +} + +#[tauri::command] +pub fn list_live_sessions( + state: State<'_, AppState>, + project_id: String, +) -> Result, AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let sessions = LiveSession::list_by_project(&db, &project_id)?; + Ok(sessions) +} + +#[tauri::command] +pub fn list_live_messages( + state: State<'_, AppState>, + session_id: String, +) -> Result, AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let messages = LiveMessage::list_by_session(&db, &session_id)?; + Ok(messages) +} + +#[tauri::command] +pub async fn send_live_message( + state: State<'_, AppState>, + app_handle: tauri::AppHandle, + session_id: String, + message: String, +) -> Result { + let content = message.trim(); + if content.is_empty() { + return Err(AppError::from("Le message est vide".to_string())); + } + + let (session, project, agent, history, user_message) = { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let session = LiveSession::get_by_id(&db, &session_id)?; + let enabled = ProjectModule::is_enabled(&db, &session.project_id, MODULE_AI_LIVE_CHAT)?; + if !enabled { + return Err(AppError::from( + "Le module de live chat est désactivé pour ce projet".to_string(), + )); + } + + let project = Project::get_by_id(&db, &session.project_id)?; + let agent = Agent::get_by_id(&db, &session.agent_id)?; + let history = LiveMessage::list_recent_by_session(&db, &session_id, 16)?; + let user_message = LiveMessage::insert(&db, &session_id, "user", content)?; + + (session, project, agent, history, user_message) + }; + + let _ = app_handle.emit( + "live-agent-message", + serde_json::json!({ + "project_id": &session.project_id, + "session_id": &session.id, + "message": &user_message, + }), + ); + + let prompt = build_live_prompt(&project, &history, content); + let args: Vec = Vec::new(); + let response = agent_runtime::run_agent_command( + agent.tool.to_command(), + &args, + &prompt, + &project.path, + 600, + ) + .await + .map_err(AppError::from)?; + + let agent_message = { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + LiveMessage::insert(&db, &session_id, "agent", &response)? + }; + + let _ = app_handle.emit( + "live-agent-message", + serde_json::json!({ + "project_id": &session.project_id, + "session_id": &session.id, + "message": &agent_message, + }), + ); + + Ok(LiveAgentExchange { + user_message, + agent_message, + }) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f141882..24271a7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,8 +1,11 @@ pub mod agent; pub mod credential; +pub mod live_agent; +pub mod module; pub mod notification; pub mod orchestrator; pub mod poller; pub mod project; +pub mod task; pub mod tracker; pub mod worktree; diff --git a/src-tauri/src/commands/module.rs b/src-tauri/src/commands/module.rs new file mode 100644 index 0000000..167669c --- /dev/null +++ b/src-tauri/src/commands/module.rs @@ -0,0 +1,35 @@ +use crate::error::AppError; +use crate::models::module::ProjectModule; +use crate::AppState; +use tauri::State; + +#[tauri::command] +pub fn list_project_modules( + state: State<'_, AppState>, + project_id: String, +) -> Result, AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let modules = ProjectModule::list_by_project(&db, &project_id)?; + Ok(modules) +} + +#[tauri::command] +pub fn set_project_module_enabled( + state: State<'_, AppState>, + project_id: String, + module_key: String, + enabled: bool, +) -> Result<(), AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + ProjectModule::ensure_defaults_for_project(&db, &project_id)?; + ProjectModule::set_enabled(&db, &project_id, &module_key, enabled)?; + Ok(()) +} diff --git a/src-tauri/src/commands/poller.rs b/src-tauri/src/commands/poller.rs index 6f453d6..ca46066 100644 --- a/src-tauri/src/commands/poller.rs +++ b/src-tauri/src/commands/poller.rs @@ -1,9 +1,10 @@ use crate::error::AppError; use crate::models::credential::TuleapCredentials; +use crate::models::module::{ProjectModule, MODULE_TULEAP_AUTO_RESOLVE}; use crate::models::ticket::ProcessedTicket; use crate::models::tracker::WatchedTracker; -use crate::services::{crypto, filter_engine, notifier}; use crate::services::tuleap_client::TuleapClient; +use crate::services::{crypto, filter_engine, notifier}; use crate::AppState; use tauri::{Emitter, State}; @@ -20,6 +21,14 @@ pub async fn manual_poll( .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; let tracker = WatchedTracker::get_by_id(&db, &tracker_id)?; + let module_enabled = + ProjectModule::is_enabled(&db, &tracker.project_id, MODULE_TULEAP_AUTO_RESOLVE)?; + if !module_enabled { + return Err(AppError::from( + "Le module Polling Tuleap + auto-resolve est désactivé pour ce projet".to_string(), + )); + } + if tracker.status != "valid" { return Err(AppError::from( "Tracker is invalid. Reconfigure analyst/developer agents first.".to_string(), @@ -80,10 +89,7 @@ pub async fn manual_poll( .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; for artifact in &filtered { - let artifact_id = artifact - .get("id") - .and_then(|v| v.as_i64()) - .unwrap_or(0) as i32; + let artifact_id = artifact.get("id").and_then(|v| v.as_i64()).unwrap_or(0) as i32; let artifact_title = artifact .get("title") @@ -91,8 +97,8 @@ pub async fn manual_poll( .unwrap_or("") .to_string(); - let artifact_data = serde_json::to_string(artifact) - .unwrap_or_else(|_| "{}".to_string()); + let artifact_data = + serde_json::to_string(artifact).unwrap_or_else(|_| "{}".to_string()); if let Some(ticket) = ProcessedTicket::insert_if_new( &db, diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index 8e744ac..64012db 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -1,4 +1,5 @@ use crate::error::AppError; +use crate::models::module::ProjectModule; use crate::models::project::Project; use crate::AppState; use std::process::Command; @@ -16,11 +17,13 @@ pub fn create_project( || path_or_url.starts_with("git@"); let (local_path, cloned_from) = if is_url { - let home = dirs::home_dir().ok_or_else(|| AppError::from("Cannot determine home directory".to_string()))?; + let home = dirs::home_dir() + .ok_or_else(|| AppError::from("Cannot determine home directory".to_string()))?; let clone_dir = home.join("orchai-repos").join(&name); std::fs::create_dir_all(&clone_dir)?; - let clone_dir_str = clone_dir.to_str() + let clone_dir_str = clone_dir + .to_str() .ok_or_else(|| AppError::from("Clone path contains invalid characters".to_string()))?; let output = Command::new("git") @@ -36,29 +39,51 @@ pub fn create_project( } else { let path = std::path::Path::new(&path_or_url); if !path.exists() { - return Err(AppError::from(format!("Path does not exist: {}", path_or_url))); + return Err(AppError::from(format!( + "Path does not exist: {}", + path_or_url + ))); } if !path.join(".git").exists() { - return Err(AppError::from(format!("Not a git repository: {}", path_or_url))); + return Err(AppError::from(format!( + "Not a git repository: {}", + path_or_url + ))); } (path_or_url, None) }; - let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; - let project = Project::insert(&db, &name, &local_path, cloned_from.as_deref(), &base_branch)?; + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + let project = Project::insert( + &db, + &name, + &local_path, + cloned_from.as_deref(), + &base_branch, + )?; + ProjectModule::ensure_defaults_for_project(&db, &project.id)?; Ok(project) } #[tauri::command] pub fn list_projects(state: State<'_, AppState>) -> Result, AppError> { - let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; let projects = Project::list(&db)?; Ok(projects) } #[tauri::command] pub fn get_project(state: State<'_, AppState>, id: String) -> Result { - let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; let project = Project::get_by_id(&db, &id)?; Ok(project) } @@ -70,14 +95,20 @@ pub fn update_project( name: String, base_branch: String, ) -> Result<(), AppError> { - let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; Project::update(&db, &id, &name, &base_branch)?; Ok(()) } #[tauri::command] pub fn delete_project(state: State<'_, AppState>, id: String) -> Result<(), AppError> { - let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; Project::delete(&db, &id)?; Ok(()) } diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs new file mode 100644 index 0000000..c016013 --- /dev/null +++ b/src-tauri/src/commands/task.rs @@ -0,0 +1,90 @@ +use crate::error::AppError; +use crate::models::agent::Agent; +use crate::models::agent_task::AgentTask; +use crate::models::project::Project; +use crate::AppState; +use tauri::State; + +#[tauri::command] +pub fn create_agent_task( + state: State<'_, AppState>, + project_id: String, + agent_id: String, + title: String, + description: String, +) -> Result { + if title.trim().is_empty() { + return Err(AppError::from( + "Le titre de la tâche est obligatoire".to_string(), + )); + } + + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + Project::get_by_id(&db, &project_id)?; + Agent::get_by_id(&db, &agent_id)?; + + let task = AgentTask::insert( + &db, + &project_id, + &agent_id, + title.trim(), + description.trim(), + )?; + Ok(task) +} + +#[tauri::command] +pub fn list_agent_tasks( + state: State<'_, AppState>, + project_id: String, +) -> Result, AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let tasks = AgentTask::list_by_project(&db, &project_id)?; + Ok(tasks) +} + +#[tauri::command] +pub fn retry_agent_task(state: State<'_, AppState>, task_id: String) -> Result<(), AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let task = AgentTask::get_by_id(&db, &task_id)?; + if task.status != "error" && task.status != "cancelled" && task.status != "done" { + return Err(AppError::from(format!( + "Impossible de relancer une tâche avec le statut '{}'", + task.status + ))); + } + + AgentTask::retry(&db, &task_id)?; + Ok(()) +} + +#[tauri::command] +pub fn cancel_agent_task(state: State<'_, AppState>, task_id: String) -> Result<(), AppError> { + let db = state + .db + .lock() + .map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?; + + let task = AgentTask::get_by_id(&db, &task_id)?; + if task.status == "done" || task.status == "cancelled" { + return Err(AppError::from(format!( + "Impossible d'annuler une tâche avec le statut '{}'", + task.status + ))); + } + + AgentTask::cancel(&db, &task_id)?; + Ok(()) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index dac524e..fcedc49 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -5,6 +5,7 @@ const MIGRATION_001: &str = include_str!("../migrations/001_init.sql"); const MIGRATION_002: &str = include_str!("../migrations/002_add_last_polled.sql"); const MIGRATION_003: &str = include_str!("../migrations/003_add_agents.sql"); const MIGRATION_004: &str = include_str!("../migrations/004_default_agents.sql"); +const MIGRATION_005: &str = include_str!("../migrations/005_orchestration_modules_chat_tasks.sql"); pub fn init(db_path: &Path) -> Result { let conn = Connection::open(db_path)?; @@ -46,6 +47,10 @@ fn migrate(conn: &Connection) -> Result<()> { conn.execute_batch(MIGRATION_004)?; conn.pragma_update(None, "user_version", 4)?; } + if version < 5 { + conn.execute_batch(MIGRATION_005)?; + conn.pragma_update(None, "user_version", 5)?; + } Ok(()) } @@ -58,7 +63,7 @@ mod tests { fn test_init_in_memory_creates_tables() { let conn = init_in_memory().expect("should initialize"); - // Verify all 7 tables exist + // Verify all application tables exist let tables: Vec = conn .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") .unwrap() @@ -73,6 +78,10 @@ mod tests { "agents", "notifications", "processed_tickets", + "project_agent_tasks", + "project_live_messages", + "project_live_sessions", + "project_modules", "projects", "tuleap_credentials", "watched_trackers", @@ -97,7 +106,7 @@ mod tests { let version: i32 = conn .pragma_query_value(None, "user_version", |row| row.get(0)) .unwrap(); - assert_eq!(version, 4); + assert_eq!(version, 5); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0752c8..710bf66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -46,10 +46,10 @@ pub fn run() { ); // Start agent orchestrator - services::orchestrator::start( - db_arc, - app.handle().clone(), - ); + services::orchestrator::start(db_arc.clone(), app.handle().clone()); + + // Start agent task runner + services::task_runner::start(db_arc, app.handle().clone()); Ok(()) }) @@ -79,9 +79,19 @@ pub fn run() { commands::notification::list_notifications, commands::notification::mark_notification_read, commands::notification::mark_all_notifications_read, + commands::module::list_project_modules, + commands::module::set_project_module_enabled, commands::orchestrator::get_ticket_result, commands::orchestrator::retry_ticket, commands::orchestrator::cancel_ticket, + commands::live_agent::create_live_session, + commands::live_agent::list_live_sessions, + commands::live_agent::list_live_messages, + commands::live_agent::send_live_message, + commands::task::create_agent_task, + commands::task::list_agent_tasks, + commands::task::retry_agent_task, + commands::task::cancel_agent_task, commands::worktree::list_worktrees, commands::worktree::get_worktree_diff, commands::worktree::apply_fix_to_branch, diff --git a/src-tauri/src/models/agent_task.rs b/src-tauri/src/models/agent_task.rs new file mode 100644 index 0000000..3235549 --- /dev/null +++ b/src-tauri/src/models/agent_task.rs @@ -0,0 +1,167 @@ +use rusqlite::{params, Connection, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentTask { + pub id: String, + pub project_id: String, + pub agent_id: String, + pub title: String, + pub description: String, + pub status: String, + pub result: Option, + pub error: Option, + pub created_at: String, + pub started_at: Option, + pub finished_at: Option, +} + +fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(AgentTask { + id: row.get(0)?, + project_id: row.get(1)?, + agent_id: row.get(2)?, + title: row.get(3)?, + description: row.get(4)?, + status: row.get(5)?, + result: row.get(6)?, + error: row.get(7)?, + created_at: row.get(8)?, + started_at: row.get(9)?, + finished_at: row.get(10)?, + }) +} + +impl AgentTask { + pub fn insert( + conn: &Connection, + project_id: &str, + agent_id: &str, + title: &str, + description: &str, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO project_agent_tasks \ + (id, project_id, agent_id, title, description, status, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6)", + params![id, project_id, agent_id, title, description, now], + )?; + + Ok(AgentTask { + id, + project_id: project_id.to_string(), + agent_id: agent_id.to_string(), + title: title.to_string(), + description: description.to_string(), + status: "pending".to_string(), + result: None, + error: None, + created_at: now, + started_at: None, + finished_at: None, + }) + } + + pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, project_id, agent_id, title, description, status, result, error, created_at, started_at, finished_at \ + FROM project_agent_tasks \ + WHERE project_id = ?1 \ + ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![project_id], from_row)?; + rows.collect() + } + + pub fn get_by_id(conn: &Connection, id: &str) -> Result { + conn.query_row( + "SELECT id, project_id, agent_id, title, description, status, result, error, created_at, started_at, finished_at \ + FROM project_agent_tasks \ + WHERE id = ?1", + params![id], + from_row, + ) + } + + pub fn list_pending(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, project_id, agent_id, title, description, status, result, error, created_at, started_at, finished_at \ + FROM project_agent_tasks \ + WHERE status = 'pending' \ + ORDER BY created_at ASC", + )?; + let rows = stmt.query_map([], from_row)?; + rows.collect() + } + + pub fn mark_running(conn: &Connection, id: &str) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE project_agent_tasks + SET status = 'running', + started_at = ?1, + finished_at = NULL, + error = NULL + WHERE id = ?2", + params![now, id], + )?; + Ok(()) + } + + pub fn mark_done(conn: &Connection, id: &str, result: &str) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE project_agent_tasks + SET status = 'done', + result = ?1, + error = NULL, + finished_at = ?2 + WHERE id = ?3", + params![result, now, id], + )?; + Ok(()) + } + + pub fn mark_error(conn: &Connection, id: &str, error: &str) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE project_agent_tasks + SET status = 'error', + error = ?1, + finished_at = ?2 + WHERE id = ?3", + params![error, now, id], + )?; + Ok(()) + } + + pub fn retry(conn: &Connection, id: &str) -> Result<()> { + conn.execute( + "UPDATE project_agent_tasks + SET status = 'pending', + result = NULL, + error = NULL, + started_at = NULL, + finished_at = NULL + WHERE id = ?1", + params![id], + )?; + Ok(()) + } + + pub fn cancel(conn: &Connection, id: &str) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE project_agent_tasks + SET status = 'cancelled', + finished_at = ?1 + WHERE id = ?2", + params![now, id], + )?; + Ok(()) + } +} diff --git a/src-tauri/src/models/live_agent.rs b/src-tauri/src/models/live_agent.rs new file mode 100644 index 0000000..fa3ae46 --- /dev/null +++ b/src-tauri/src/models/live_agent.rs @@ -0,0 +1,161 @@ +use rusqlite::{params, Connection, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiveSession { + pub id: String, + pub project_id: String, + pub agent_id: String, + pub title: String, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiveMessage { + pub id: String, + pub session_id: String, + pub sender: String, + pub content: String, + pub created_at: String, +} + +fn session_from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(LiveSession { + id: row.get(0)?, + project_id: row.get(1)?, + agent_id: row.get(2)?, + title: row.get(3)?, + status: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + }) +} + +fn message_from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(LiveMessage { + id: row.get(0)?, + session_id: row.get(1)?, + sender: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + }) +} + +impl LiveSession { + pub fn create( + conn: &Connection, + project_id: &str, + agent_id: &str, + title: &str, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO project_live_sessions (id, project_id, agent_id, title, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?6)", + params![id, project_id, agent_id, title, now, now], + )?; + + Ok(LiveSession { + id, + project_id: project_id.to_string(), + agent_id: agent_id.to_string(), + title: title.to_string(), + status: "active".to_string(), + created_at: now.clone(), + updated_at: now, + }) + } + + pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, project_id, agent_id, title, status, created_at, updated_at + FROM project_live_sessions + WHERE project_id = ?1 + ORDER BY updated_at DESC", + )?; + let rows = stmt.query_map(params![project_id], session_from_row)?; + rows.collect() + } + + pub fn get_by_id(conn: &Connection, id: &str) -> Result { + conn.query_row( + "SELECT id, project_id, agent_id, title, status, created_at, updated_at + FROM project_live_sessions + WHERE id = ?1", + params![id], + session_from_row, + ) + } + + pub fn touch(conn: &Connection, id: &str) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE project_live_sessions SET updated_at = ?1 WHERE id = ?2", + params![now, id], + )?; + Ok(()) + } +} + +impl LiveMessage { + pub fn insert( + conn: &Connection, + session_id: &str, + sender: &str, + content: &str, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO project_live_messages (id, session_id, sender, content, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, session_id, sender, content, now], + )?; + + LiveSession::touch(conn, session_id)?; + + Ok(LiveMessage { + id, + session_id: session_id.to_string(), + sender: sender.to_string(), + content: content.to_string(), + created_at: now, + }) + } + + pub fn list_by_session(conn: &Connection, session_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, session_id, sender, content, created_at + FROM project_live_messages + WHERE session_id = ?1 + ORDER BY created_at ASC", + )?; + let rows = stmt.query_map(params![session_id], message_from_row)?; + rows.collect() + } + + pub fn list_recent_by_session( + conn: &Connection, + session_id: &str, + limit: usize, + ) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, session_id, sender, content, created_at + FROM project_live_messages + WHERE session_id = ?1 + ORDER BY created_at DESC + LIMIT ?2", + )?; + let mut messages: Vec = stmt + .query_map(params![session_id, limit as i64], message_from_row)? + .collect::>>()?; + messages.reverse(); + Ok(messages) + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index 32c4ee8..c2e84a2 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,5 +1,8 @@ pub mod agent; +pub mod agent_task; pub mod credential; +pub mod live_agent; +pub mod module; pub mod notification; pub mod project; pub mod ticket; diff --git a/src-tauri/src/models/module.rs b/src-tauri/src/models/module.rs new file mode 100644 index 0000000..2e4532b --- /dev/null +++ b/src-tauri/src/models/module.rs @@ -0,0 +1,142 @@ +use rusqlite::{params, Connection, Result}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub const MODULE_TULEAP_AUTO_RESOLVE: &str = "tuleap_polling_auto_resolve"; +pub const MODULE_AI_LIVE_CHAT: &str = "ai_live_chat"; +pub const MODULE_AGENT_TASK_RUNNER: &str = "agent_task_runner"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectModule { + pub id: String, + pub project_id: String, + pub module_key: String, + pub name: String, + pub description: String, + pub enabled: bool, + pub config_json: String, + pub created_at: String, + pub updated_at: String, +} + +fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + let enabled_int: i32 = row.get(5)?; + + Ok(ProjectModule { + id: row.get(0)?, + project_id: row.get(1)?, + module_key: row.get(2)?, + name: row.get(3)?, + description: row.get(4)?, + enabled: enabled_int != 0, + config_json: row.get(6)?, + created_at: row.get(7)?, + updated_at: row.get(8)?, + }) +} + +fn insert_default( + conn: &Connection, + project_id: &str, + module_key: &str, + name: &str, + description: &str, +) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "INSERT OR IGNORE INTO project_modules \ + (id, project_id, module_key, name, description, enabled, config_json, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, 1, '{}', ?6, ?7)", + params![ + Uuid::new_v4().to_string(), + project_id, + module_key, + name, + description, + now, + now + ], + )?; + + Ok(()) +} + +impl ProjectModule { + pub fn ensure_defaults_for_project(conn: &Connection, project_id: &str) -> Result<()> { + insert_default( + conn, + project_id, + MODULE_TULEAP_AUTO_RESOLVE, + "Polling Tuleap + auto-resolve", + "Surveille Tuleap et lance le pipeline analyste/developpeur.", + )?; + insert_default( + conn, + project_id, + MODULE_AI_LIVE_CHAT, + "Live chat agent", + "Discussion live avec un agent sur le contexte du projet.", + )?; + insert_default( + conn, + project_id, + MODULE_AGENT_TASK_RUNNER, + "Agent task runner", + "File de tâches asynchrones traitées par des agents pré-définis.", + )?; + + Ok(()) + } + + pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { + Self::ensure_defaults_for_project(conn, project_id)?; + + let mut stmt = conn.prepare( + "SELECT id, project_id, module_key, name, description, enabled, config_json, created_at, updated_at \ + FROM project_modules \ + WHERE project_id = ?1 \ + ORDER BY name ASC", + )?; + let rows = stmt.query_map(params![project_id], from_row)?; + rows.collect() + } + + pub fn set_enabled( + conn: &Connection, + project_id: &str, + module_key: &str, + enabled: bool, + ) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let enabled_int = if enabled { 1i32 } else { 0i32 }; + + let affected = conn.execute( + "UPDATE project_modules + SET enabled = ?1, + updated_at = ?2 + WHERE project_id = ?3 + AND module_key = ?4", + params![enabled_int, now, project_id, module_key], + )?; + + if affected == 0 { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + + Ok(()) + } + + pub fn is_enabled(conn: &Connection, project_id: &str, module_key: &str) -> Result { + Self::ensure_defaults_for_project(conn, project_id)?; + + let enabled_int: i32 = conn.query_row( + "SELECT enabled + FROM project_modules + WHERE project_id = ?1 AND module_key = ?2", + params![project_id, module_key], + |row| row.get(0), + )?; + + Ok(enabled_int != 0) + } +} diff --git a/src-tauri/src/models/tracker.rs b/src-tauri/src/models/tracker.rs index 73e4012..f04f2d2 100644 --- a/src-tauri/src/models/tracker.rs +++ b/src-tauri/src/models/tracker.rs @@ -164,7 +164,27 @@ impl WatchedTracker { pub fn list_all_enabled(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, project_id, tracker_id, tracker_label, polling_interval, analyst_agent_id, developer_agent_id, filters_json, enabled, status, last_polled_at, created_at \ - FROM watched_trackers WHERE enabled = 1 AND status = 'valid' AND analyst_agent_id IS NOT NULL AND developer_agent_id IS NOT NULL ORDER BY created_at DESC", + FROM watched_trackers \ + WHERE enabled = 1 \ + AND status = 'valid' \ + AND analyst_agent_id IS NOT NULL \ + AND developer_agent_id IS NOT NULL \ + AND (\n\ + EXISTS (\n\ + SELECT 1\n\ + FROM project_modules pm\n\ + WHERE pm.project_id = watched_trackers.project_id\n\ + AND pm.module_key = 'tuleap_polling_auto_resolve'\n\ + AND pm.enabled = 1\n\ + )\n\ + OR NOT EXISTS (\n\ + SELECT 1\n\ + FROM project_modules pm2\n\ + WHERE pm2.project_id = watched_trackers.project_id\n\ + AND pm2.module_key = 'tuleap_polling_auto_resolve'\n\ + )\n\ + ) \ + ORDER BY created_at DESC", )?; let rows = stmt.query_map([], from_row)?; rows.collect() diff --git a/src-tauri/src/services/agent_runtime.rs b/src-tauri/src/services/agent_runtime.rs new file mode 100644 index 0000000..7b5d464 --- /dev/null +++ b/src-tauri/src/services/agent_runtime.rs @@ -0,0 +1,48 @@ +use tokio::io::AsyncWriteExt; +use tokio::process::Command; +use tokio::time::{timeout, Duration}; + +pub async fn run_agent_command( + command: &str, + args: &[String], + prompt: &str, + working_dir: &str, + timeout_secs: u64, +) -> Result { + let mut child = Command::new(command) + .args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .current_dir(working_dir) + .spawn() + .map_err(|e| format!("Failed to spawn '{}': {}", command, e))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(prompt.as_bytes()) + .await + .map_err(|e| format!("Failed to write prompt to stdin: {}", e))?; + } + + let output = timeout(Duration::from_secs(timeout_secs), child.wait_with_output()) + .await + .map_err(|_| format!("CLI command timed out after {}s", timeout_secs))? + .map_err(|e| format!("Failed to wait process output: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let code = output.status.code().unwrap_or(-1); + if stderr.is_empty() { + return Err(format!("CLI command exited with code {}", code)); + } + return Err(format!("CLI command exited with code {}: {}", code, stderr)); + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + return Ok("(empty response)".to_string()); + } + + Ok(stdout) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index f1d12a5..ef59180 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,7 +1,9 @@ +pub mod agent_runtime; pub mod crypto; pub mod filter_engine; pub mod notifier; pub mod orchestrator; pub mod poller; +pub mod task_runner; pub mod tuleap_client; pub mod worktree_manager; diff --git a/src-tauri/src/services/orchestrator.rs b/src-tauri/src/services/orchestrator.rs index 02e8597..b9a7928 100644 --- a/src-tauri/src/services/orchestrator.rs +++ b/src-tauri/src/services/orchestrator.rs @@ -1,4 +1,5 @@ use crate::models::agent::{Agent, AgentRole}; +use crate::models::module::{ProjectModule, MODULE_TULEAP_AUTO_RESOLVE}; use crate::models::project::Project; use crate::models::ticket::ProcessedTicket; use crate::models::tracker::WatchedTracker; @@ -195,24 +196,35 @@ pub async fn run_cli_command( Ok(result) } -async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> Result { +async fn process_ticket( + db: &Arc>, + app_handle: &AppHandle, +) -> Result { let (ticket, tracker, project) = { let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; - let pending = ProcessedTicket::list_pending(&conn).map_err(|e| format!("list_pending failed: {}", e))?; + let pending = ProcessedTicket::list_pending(&conn) + .map_err(|e| format!("list_pending failed: {}", e))?; + let mut selected: Option<(ProcessedTicket, WatchedTracker, Project)> = None; - let ticket = match pending.into_iter().next() { - Some(t) => t, + for ticket in pending { + let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id) + .map_err(|e| format!("get tracker failed: {}", e))?; + let project = Project::get_by_id(&conn, &tracker.project_id) + .map_err(|e| format!("get project failed: {}", e))?; + + let enabled = ProjectModule::is_enabled(&conn, &project.id, MODULE_TULEAP_AUTO_RESOLVE) + .map_err(|e| format!("module lookup failed: {}", e))?; + if enabled { + selected = Some((ticket, tracker, project)); + break; + } + } + + match selected { + Some(item) => item, None => return Ok(false), - }; - - let tracker = WatchedTracker::get_by_id(&conn, &ticket.tracker_id) - .map_err(|e| format!("get tracker failed: {}", e))?; - - let project = Project::get_by_id(&conn, &tracker.project_id) - .map_err(|e| format!("get project failed: {}", e))?; - - (ticket, tracker, project) + } }; let (analyst_agent, developer_agent) = { @@ -386,19 +398,14 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> "artifact_id": ticket.artifact_id, }), ); - notifier::notify_analysis_done( - db, - app_handle, - &project.id, - &ticket.id, - ticket.artifact_id, - ); + notifier::notify_analysis_done(db, app_handle, &project.id, &ticket.id, ticket.artifact_id); return Ok(true); } { let conn = db.lock().map_err(|e| format!("DB lock: {}", e))?; - let current = ProcessedTicket::get_by_id(&conn, &ticket.id).map_err(|e| format!("get_by_id: {}", e))?; + let current = ProcessedTicket::get_by_id(&conn, &ticket.id) + .map_err(|e| format!("get_by_id: {}", e))?; if current.status == "Cancelled" { return Ok(true); } @@ -487,13 +494,7 @@ async fn process_ticket(db: &Arc>, app_handle: &AppHandle) -> "artifact_id": ticket.artifact_id, }), ); - notifier::notify_fix_ready( - db, - app_handle, - &project.id, - &ticket.id, - ticket.artifact_id, - ); + notifier::notify_fix_ready(db, app_handle, &project.id, &ticket.id, ticket.artifact_id); Ok(true) } @@ -580,7 +581,8 @@ mod tests { created_at: "2026-01-01T00:00:00Z".into(), }; - let prompt = build_developer_prompt(&ticket, &project, "## Bug found in auth.rs", "/tmp/wt"); + let prompt = + build_developer_prompt(&ticket, &project, "## Bug found in auth.rs", "/tmp/wt"); assert!(prompt.contains("## Bug found in auth.rs")); assert!(prompt.contains("42")); assert!(prompt.contains("/tmp/wt")); diff --git a/src-tauri/src/services/task_runner.rs b/src-tauri/src/services/task_runner.rs new file mode 100644 index 0000000..7b26d3b --- /dev/null +++ b/src-tauri/src/services/task_runner.rs @@ -0,0 +1,152 @@ +use crate::models::agent::Agent; +use crate::models::agent_task::AgentTask; +use crate::models::module::{ProjectModule, MODULE_AGENT_TASK_RUNNER}; +use crate::models::project::Project; +use crate::services::agent_runtime; +use rusqlite::Connection; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter}; +use tokio::time::{interval, Duration}; + +fn build_task_prompt(task: &AgentTask, project: &Project) -> String { + format!( + r#"Tu es un agent d'exécution de tâches pour un projet logiciel. + +## Projet +- Nom: {project_name} +- Chemin repo: {project_path} +- Branche de base: {base_branch} + +## Tâche +- Titre: {task_title} +- Description: +{task_description} + +## Attendu +1. Exécute la tâche dans le contexte du dépôt. +2. Fais des changements concrets si nécessaire. +3. Donne un rapport final en markdown: + - Résumé + - Changements + - Vérifications faites + - Risques / suites +"#, + project_name = project.name, + project_path = project.path, + base_branch = project.base_branch, + task_title = task.title, + task_description = task.description, + ) +} + +fn emit_status( + app_handle: &AppHandle, + project_id: &str, + task_id: &str, + status: &str, + error: Option<&str>, +) { + let _ = app_handle.emit( + "agent-task-updated", + serde_json::json!({ + "project_id": project_id, + "task_id": task_id, + "status": status, + "error": error, + }), + ); +} + +async fn process_next_task( + db: &Arc>, + app_handle: &AppHandle, +) -> Result { + let next_task = { + let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; + let pending = AgentTask::list_pending(&conn) + .map_err(|e| format!("list pending tasks failed: {}", e))?; + + let mut selected = None; + for task in pending { + let enabled = + ProjectModule::is_enabled(&conn, &task.project_id, MODULE_AGENT_TASK_RUNNER) + .map_err(|e| format!("task module lookup failed: {}", e))?; + if enabled { + selected = Some(task); + break; + } + } + + selected + }; + + let task = match next_task { + Some(task) => task, + None => return Ok(false), + }; + + let (project, agent) = { + let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; + + AgentTask::mark_running(&conn, &task.id) + .map_err(|e| format!("mark task running failed: {}", e))?; + let project = Project::get_by_id(&conn, &task.project_id) + .map_err(|e| format!("project lookup failed: {}", e))?; + let agent = Agent::get_by_id(&conn, &task.agent_id) + .map_err(|e| format!("agent lookup failed: {}", e))?; + + (project, agent) + }; + + emit_status(app_handle, &task.project_id, &task.id, "running", None); + + let prompt = build_task_prompt(&task, &project); + let args: Vec = Vec::new(); + let result = agent_runtime::run_agent_command( + agent.tool.to_command(), + &args, + &prompt, + &project.path, + 900, + ) + .await; + + match result { + Ok(report) => { + let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; + AgentTask::mark_done(&conn, &task.id, &report) + .map_err(|e| format!("mark task done failed: {}", e))?; + emit_status(app_handle, &task.project_id, &task.id, "done", None); + } + Err(error) => { + let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; + AgentTask::mark_error(&conn, &task.id, &error) + .map_err(|e| format!("mark task error failed: {}", e))?; + emit_status( + app_handle, + &task.project_id, + &task.id, + "error", + Some(&error), + ); + } + } + + Ok(true) +} + +pub fn start(db: Arc>, app_handle: AppHandle) { + tauri::async_runtime::spawn(async move { + let mut tick = interval(Duration::from_secs(8)); + loop { + tick.tick().await; + match process_next_task(&db, &app_handle).await { + Ok(true) => continue, + Ok(false) => {} + Err(e) => { + eprintln!("task_runner: {}", e); + } + } + } + }); +} diff --git a/src/App.tsx b/src/App.tsx index a589b2a..f09c98a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,9 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import AppLayout from "./components/layout/AppLayout"; import ProjectForm from "./components/projects/ProjectForm"; import ProjectDashboard from "./components/projects/ProjectDashboard"; +import ProjectLiveAgent from "./components/projects/ProjectLiveAgent"; +import ProjectModules from "./components/projects/ProjectModules"; +import ProjectTasks from "./components/projects/ProjectTasks"; import SettingsPage from "./components/settings/SettingsPage"; import TicketDetail from "./components/tickets/TicketDetail"; import TicketList from "./components/tickets/TicketList"; @@ -25,6 +28,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/projects/ProjectDashboard.tsx b/src/components/projects/ProjectDashboard.tsx index 228cb6c..74e17ce 100644 --- a/src/components/projects/ProjectDashboard.tsx +++ b/src/components/projects/ProjectDashboard.tsx @@ -288,6 +288,39 @@ export default function ProjectDashboard() { +
+

Orchestrateur IA

+
+ +
Modules
+
+ Active ou désactive les modules IA du projet. +
+ + +
Live agent
+
+ Discussion live avec un agent dans le contexte du repo. +
+ + +
Tâches
+
+ Crée une file de tâches traitées par des agents pré-définis. +
+ +
+
+

Watched Trackers

diff --git a/src/components/projects/ProjectLiveAgent.tsx b/src/components/projects/ProjectLiveAgent.tsx new file mode 100644 index 0000000..07e3780 --- /dev/null +++ b/src/components/projects/ProjectLiveAgent.tsx @@ -0,0 +1,283 @@ +import { listen } from "@tauri-apps/api/event"; +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + createLiveSession, + listAgents, + listLiveMessages, + listLiveSessions, + sendLiveMessage, +} from "../../lib/api"; +import { getErrorMessage } from "../../lib/errors"; +import type { Agent, LiveMessage, LiveSession } from "../../lib/types"; + +interface LiveEventPayload { + project_id: string; + session_id: string; + message: LiveMessage; +} + +export default function ProjectLiveAgent() { + const { projectId } = useParams<{ projectId: string }>(); + const [agents, setAgents] = useState([]); + const [sessions, setSessions] = useState([]); + const [messages, setMessages] = useState([]); + const [selectedSessionId, setSelectedSessionId] = useState(""); + const [selectedAgentId, setSelectedAgentId] = useState(""); + const [sessionTitle, setSessionTitle] = useState(""); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [creatingSession, setCreatingSession] = useState(false); + const [error, setError] = useState(null); + + const usableAgents = useMemo( + () => agents.filter((agent) => agent.role === "analyst" || agent.role === "developer"), + [agents] + ); + + async function refreshSessions(defaultSessionId?: string) { + if (!projectId) return; + + const result = await listLiveSessions(projectId); + setSessions(result); + + const targetSessionId = defaultSessionId ?? selectedSessionId; + const firstSessionId = result[0]?.id; + + if (targetSessionId && result.some((session) => session.id === targetSessionId)) { + setSelectedSessionId(targetSessionId); + const sessionMessages = await listLiveMessages(targetSessionId); + setMessages(sessionMessages); + return; + } + + if (firstSessionId) { + setSelectedSessionId(firstSessionId); + const sessionMessages = await listLiveMessages(firstSessionId); + setMessages(sessionMessages); + return; + } + + setSelectedSessionId(""); + setMessages([]); + } + + useEffect(() => { + async function load() { + if (!projectId) return; + + setError(null); + try { + const [availableAgents] = await Promise.all([listAgents()]); + setAgents(availableAgents); + + const firstAgentId = availableAgents[0]?.id ?? ""; + setSelectedAgentId((current) => current || firstAgentId); + + await refreshSessions(); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } + } + + void load(); + }, [projectId]); + + useEffect(() => { + if (!projectId) return; + + let stop: (() => void) | null = null; + + void (async () => { + const unlisten = await listen("live-agent-message", (event) => { + const payload = event.payload; + if (payload.project_id !== projectId) return; + if (payload.session_id !== selectedSessionId) return; + + setMessages((prev) => { + if (prev.some((msg) => msg.id === payload.message.id)) { + return prev; + } + return [...prev, payload.message]; + }); + }); + + stop = unlisten; + })(); + + return () => { + if (stop) stop(); + }; + }, [projectId, selectedSessionId]); + + async function handleCreateSession(event: FormEvent) { + event.preventDefault(); + if (!projectId || !selectedAgentId) return; + + setCreatingSession(true); + setError(null); + try { + const created = await createLiveSession(projectId, selectedAgentId, sessionTitle); + setSessionTitle(""); + await refreshSessions(created.id); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setCreatingSession(false); + } + } + + async function handleSendMessage(event: FormEvent) { + event.preventDefault(); + if (!selectedSessionId || !draft.trim()) return; + + const content = draft.trim(); + setDraft(""); + setSending(true); + setError(null); + + try { + await sendLiveMessage(selectedSessionId, content); + const updated = await listLiveMessages(selectedSessionId); + setMessages(updated); + } catch (err: unknown) { + setDraft(content); + setError(getErrorMessage(err)); + } finally { + setSending(false); + } + } + + async function handleSessionChange(sessionId: string) { + setSelectedSessionId(sessionId); + if (!sessionId) { + setMessages([]); + return; + } + + try { + const result = await listLiveMessages(sessionId); + setMessages(result); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } + } + + return ( +
+

Live agent

+ + {error && ( +
+ {error} +
+ )} + +
+

Nouvelle session

+
+ + setSessionTitle(e.target.value)} + placeholder="Titre de session (optionnel)" + className="rounded border border-gray-300 px-3 py-2 text-sm" + /> + +
+
+ +
+
+
+ Sessions +
+
+ {sessions.map((session) => ( + + ))} + + {sessions.length === 0 && ( +
Aucune session.
+ )} +
+
+ +
+
Discussion
+
+ {messages.map((msg) => ( +
+
+ {msg.sender} +
+
{msg.content}
+
+ ))} + {messages.length === 0 && ( +
Pas encore de message.
+ )} +
+ +
+ setDraft(e.target.value)} + placeholder="Ton message..." + disabled={!selectedSessionId || sending} + className="flex-1 rounded border border-gray-300 px-3 py-2 text-sm" + /> + +
+
+
+
+ ); +} diff --git a/src/components/projects/ProjectModules.tsx b/src/components/projects/ProjectModules.tsx new file mode 100644 index 0000000..9e351ed --- /dev/null +++ b/src/components/projects/ProjectModules.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { listProjectModules, setProjectModuleEnabled } from "../../lib/api"; +import { getErrorMessage } from "../../lib/errors"; +import type { ProjectModule } from "../../lib/types"; + +export default function ProjectModules() { + const { projectId } = useParams<{ projectId: string }>(); + const [modules, setModules] = useState([]); + const [savingKey, setSavingKey] = useState(null); + const [error, setError] = useState(null); + + async function refresh() { + if (!projectId) return; + + setError(null); + try { + const result = await listProjectModules(projectId); + setModules(result); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } + } + + useEffect(() => { + void refresh(); + }, [projectId]); + + async function handleToggle(moduleKey: string, enabled: boolean) { + if (!projectId) return; + + setSavingKey(moduleKey); + setError(null); + try { + await setProjectModuleEnabled(projectId, moduleKey, enabled); + await refresh(); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setSavingKey(null); + } + } + + return ( +
+

Modules du projet

+ + {error && ( +
+ {error} +
+ )} + +
+ {modules.map((mod) => ( +
+
+
+
{mod.name}
+
{mod.description}
+
{mod.module_key}
+
+ +
+
+ ))} +
+ + {modules.length === 0 && ( +
Aucun module disponible.
+ )} +
+ ); +} diff --git a/src/components/projects/ProjectTasks.tsx b/src/components/projects/ProjectTasks.tsx new file mode 100644 index 0000000..e4c7556 --- /dev/null +++ b/src/components/projects/ProjectTasks.tsx @@ -0,0 +1,253 @@ +import { listen } from "@tauri-apps/api/event"; +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { useParams } from "react-router-dom"; +import { + cancelAgentTask, + createAgentTask, + listAgentTasks, + listAgents, + retryAgentTask, +} from "../../lib/api"; +import { getErrorMessage } from "../../lib/errors"; +import type { Agent, AgentTask } from "../../lib/types"; + +interface TaskEventPayload { + project_id: string; + task_id: string; + status: string; + error?: string | null; +} + +export default function ProjectTasks() { + const { projectId } = useParams<{ projectId: string }>(); + + const [agents, setAgents] = useState([]); + const [tasks, setTasks] = useState([]); + const [agentId, setAgentId] = useState(""); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [creating, setCreating] = useState(false); + const [workingTaskId, setWorkingTaskId] = useState(null); + const [error, setError] = useState(null); + + const usableAgents = useMemo( + () => agents.filter((agent) => agent.role === "analyst" || agent.role === "developer"), + [agents] + ); + + async function refresh() { + if (!projectId) return; + + setError(null); + try { + const [availableAgents, projectTasks] = await Promise.all([ + listAgents(), + listAgentTasks(projectId), + ]); + setAgents(availableAgents); + setTasks(projectTasks); + if (!agentId && availableAgents.length > 0) { + setAgentId(availableAgents[0].id); + } + } catch (err: unknown) { + setError(getErrorMessage(err)); + } + } + + useEffect(() => { + void refresh(); + }, [projectId]); + + useEffect(() => { + if (!projectId) return; + let stop: (() => void) | null = null; + + void (async () => { + const unlisten = await listen("agent-task-updated", (event) => { + if (event.payload.project_id !== projectId) return; + void refresh(); + }); + stop = unlisten; + })(); + + return () => { + if (stop) stop(); + }; + }, [projectId]); + + function statusBadgeClass(status: AgentTask["status"]): string { + switch (status) { + case "pending": + return "bg-yellow-100 text-yellow-800"; + case "running": + return "bg-blue-100 text-blue-800"; + case "done": + return "bg-green-100 text-green-800"; + case "error": + return "bg-red-100 text-red-800"; + case "cancelled": + return "bg-gray-100 text-gray-700"; + default: + return "bg-gray-100 text-gray-700"; + } + } + + async function handleCreateTask(event: FormEvent) { + event.preventDefault(); + if (!projectId || !agentId || !title.trim()) return; + + setCreating(true); + setError(null); + try { + await createAgentTask(projectId, agentId, title.trim(), description.trim()); + setTitle(""); + setDescription(""); + await refresh(); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setCreating(false); + } + } + + async function handleRetry(taskId: string) { + setWorkingTaskId(taskId); + setError(null); + try { + await retryAgentTask(taskId); + await refresh(); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setWorkingTaskId(null); + } + } + + async function handleCancel(taskId: string) { + setWorkingTaskId(taskId); + setError(null); + try { + await cancelAgentTask(taskId); + await refresh(); + } catch (err: unknown) { + setError(getErrorMessage(err)); + } finally { + setWorkingTaskId(null); + } + } + + return ( +
+

Tâches agent

+ + {error && ( +
+ {error} +
+ )} + +
+

Créer une tâche

+
+ + + setTitle(e.target.value)} + placeholder="Titre de la tâche" + className="rounded border border-gray-300 px-3 py-2 text-sm" + /> + + +
+ +