feat: add modular ai orchestration with live chat and task runner

This commit is contained in:
thibaud-lclr 2026-04-15 17:17:23 +02:00
parent 3234cee1c2
commit 5662f34415
26 changed files with 2050 additions and 53 deletions

View file

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

View file

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

View file

@ -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<LiveSession, AppError> {
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<Vec<LiveSession>, 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<Vec<LiveMessage>, 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<LiveAgentExchange, AppError> {
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<String> = 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,
})
}

View file

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

View file

@ -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<Vec<ProjectModule>, 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(())
}

View file

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

View file

@ -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<Vec<Project>, 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<Project, 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 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(())
}

View file

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

View file

@ -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<Connection> {
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<String> = 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]

View file

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

View file

@ -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<String>,
pub error: Option<String>,
pub created_at: String,
pub started_at: Option<String>,
pub finished_at: Option<String>,
}
fn from_row(row: &rusqlite::Row) -> rusqlite::Result<AgentTask> {
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<AgentTask> {
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<Vec<AgentTask>> {
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<AgentTask> {
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<Vec<AgentTask>> {
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(())
}
}

View file

@ -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<LiveSession> {
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<LiveMessage> {
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<LiveSession> {
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<Vec<LiveSession>> {
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<LiveSession> {
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<LiveMessage> {
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<Vec<LiveMessage>> {
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<Vec<LiveMessage>> {
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<LiveMessage> = stmt
.query_map(params![session_id, limit as i64], message_from_row)?
.collect::<Result<Vec<_>>>()?;
messages.reverse();
Ok(messages)
}
}

View file

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

View file

@ -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<ProjectModule> {
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<Vec<ProjectModule>> {
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<bool> {
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)
}
}

View file

@ -164,7 +164,27 @@ impl WatchedTracker {
pub fn list_all_enabled(conn: &Connection) -> Result<Vec<WatchedTracker>> {
let mut stmt = conn.prepare(
"SELECT id, project_id, tracker_id, tracker_label, polling_interval, 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()

View file

@ -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<String, String> {
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)
}

View file

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

View file

@ -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<Mutex<Connection>>, app_handle: &AppHandle) -> Result<bool, String> {
async fn process_ticket(
db: &Arc<Mutex<Connection>>,
app_handle: &AppHandle,
) -> Result<bool, String> {
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<Mutex<Connection>>, 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<Mutex<Connection>>, 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"));

View file

@ -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<Mutex<Connection>>,
app_handle: &AppHandle,
) -> Result<bool, String> {
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<String> = 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<Mutex<Connection>>, 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);
}
}
}
});
}

View file

@ -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() {
<Route index element={<EmptyState />} />
<Route path="/projects/new" element={<ProjectForm />} />
<Route path="/projects/:projectId" element={<ProjectDashboard />} />
<Route path="/projects/:projectId/modules" element={<ProjectModules />} />
<Route path="/projects/:projectId/live-agent" element={<ProjectLiveAgent />} />
<Route path="/projects/:projectId/tasks" element={<ProjectTasks />} />
<Route path="/projects/:projectId/tickets" element={<TicketList />} />
<Route path="/projects/:projectId/edit" element={<ProjectForm />} />
<Route path="/projects/:projectId/trackers/new" element={<TrackerConfig />} />

View file

@ -288,6 +288,39 @@ export default function ProjectDashboard() {
</div>
</div>
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Orchestrateur IA</h3>
<div className="grid gap-3 md:grid-cols-3">
<Link
to={`/projects/${project.id}/modules`}
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
>
<div className="text-sm font-semibold text-gray-900">Modules</div>
<div className="mt-1 text-xs text-gray-500">
Active ou désactive les modules IA du projet.
</div>
</Link>
<Link
to={`/projects/${project.id}/live-agent`}
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
>
<div className="text-sm font-semibold text-gray-900">Live agent</div>
<div className="mt-1 text-xs text-gray-500">
Discussion live avec un agent dans le contexte du repo.
</div>
</Link>
<Link
to={`/projects/${project.id}/tasks`}
className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
>
<div className="text-sm font-semibold text-gray-900">Tâches</div>
<div className="mt-1 text-xs text-gray-500">
Crée une file de tâches traitées par des agents pré-définis.
</div>
</Link>
</div>
</div>
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Watched Trackers</h3>
<TrackerList trackers={trackers} projectId={project.id} onRefresh={loadData} />

View file

@ -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<Agent[]>([]);
const [sessions, setSessions] = useState<LiveSession[]>([]);
const [messages, setMessages] = useState<LiveMessage[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string>("");
const [selectedAgentId, setSelectedAgentId] = useState<string>("");
const [sessionTitle, setSessionTitle] = useState("");
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
const [error, setError] = useState<string | null>(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<LiveEventPayload>("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 (
<div className="p-8 space-y-6">
<h2 className="text-xl font-bold">Live agent</h2>
{error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
{error}
</div>
)}
<form onSubmit={handleCreateSession} className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold text-gray-800">Nouvelle session</h3>
<div className="grid gap-3 md:grid-cols-3">
<select
value={selectedAgentId}
onChange={(e) => setSelectedAgentId(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
>
{usableAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name} ({agent.tool})
</option>
))}
</select>
<input
type="text"
value={sessionTitle}
onChange={(e) => setSessionTitle(e.target.value)}
placeholder="Titre de session (optionnel)"
className="rounded border border-gray-300 px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={creatingSession || !selectedAgentId}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{creatingSession ? "Création..." : "Créer la session"}
</button>
</div>
</form>
<div className="grid gap-4 md:grid-cols-[260px,1fr]">
<div className="rounded-lg border border-gray-200 bg-white p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
Sessions
</div>
<div className="space-y-2">
{sessions.map((session) => (
<button
type="button"
key={session.id}
onClick={() => void handleSessionChange(session.id)}
className={`w-full rounded px-3 py-2 text-left text-sm ${
selectedSessionId === session.id
? "bg-gray-900 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
<div className="truncate">{session.title}</div>
<div className="mt-1 text-[11px] opacity-70">
{new Date(session.updated_at).toLocaleString()}
</div>
</button>
))}
{sessions.length === 0 && (
<div className="text-xs text-gray-400">Aucune session.</div>
)}
</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<div className="mb-3 text-sm font-semibold text-gray-800">Discussion</div>
<div className="max-h-[360px] space-y-2 overflow-y-auto rounded border border-gray-100 bg-gray-50 p-3">
{messages.map((msg) => (
<div
key={msg.id}
className={`rounded px-3 py-2 text-sm ${
msg.sender === "agent"
? "bg-blue-100 text-blue-900"
: msg.sender === "system"
? "bg-amber-100 text-amber-900"
: "bg-gray-200 text-gray-800"
}`}
>
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide opacity-70">
{msg.sender}
</div>
<div className="whitespace-pre-wrap">{msg.content}</div>
</div>
))}
{messages.length === 0 && (
<div className="text-sm text-gray-400">Pas encore de message.</div>
)}
</div>
<form onSubmit={handleSendMessage} className="mt-3 flex gap-2">
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Ton message..."
disabled={!selectedSessionId || sending}
className="flex-1 rounded border border-gray-300 px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={!selectedSessionId || sending || !draft.trim()}
className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black disabled:opacity-50"
>
{sending ? "Envoi..." : "Envoyer"}
</button>
</form>
</div>
</div>
</div>
);
}

View file

@ -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<ProjectModule[]>([]);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="p-8">
<h2 className="mb-6 text-xl font-bold">Modules du projet</h2>
{error && (
<div className="mb-4 rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
{error}
</div>
)}
<div className="space-y-3">
{modules.map((mod) => (
<div
key={mod.id}
className="rounded-lg border border-gray-200 bg-white p-4"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold text-gray-900">{mod.name}</div>
<div className="mt-1 text-xs text-gray-500">{mod.description}</div>
<div className="mt-2 font-mono text-[11px] text-gray-400">{mod.module_key}</div>
</div>
<label className="inline-flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={mod.enabled}
disabled={savingKey === mod.module_key}
onChange={(e) => void handleToggle(mod.module_key, e.target.checked)}
/>
{mod.enabled ? "Activé" : "Désactivé"}
</label>
</div>
</div>
))}
</div>
{modules.length === 0 && (
<div className="text-sm text-gray-400">Aucun module disponible.</div>
)}
</div>
);
}

View file

@ -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<Agent[]>([]);
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [agentId, setAgentId] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [creating, setCreating] = useState(false);
const [workingTaskId, setWorkingTaskId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<TaskEventPayload>("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 (
<div className="space-y-6 p-8">
<h2 className="text-xl font-bold">Tâches agent</h2>
{error && (
<div className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-600">
{error}
</div>
)}
<form onSubmit={handleCreateTask} className="rounded-lg border border-gray-200 bg-white p-4">
<h3 className="mb-3 text-sm font-semibold text-gray-800">Créer une tâche</h3>
<div className="grid gap-3 md:grid-cols-3">
<select
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
className="rounded border border-gray-300 px-3 py-2 text-sm"
>
{usableAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name} ({agent.tool})
</option>
))}
</select>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Titre de la tâche"
className="rounded border border-gray-300 px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={creating || !agentId || !title.trim()}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{creating ? "Création..." : "Créer"}
</button>
</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
placeholder="Description détaillée"
className="mt-3 w-full rounded border border-gray-300 px-3 py-2 text-sm"
/>
</form>
<div className="space-y-3">
{tasks.map((task) => (
<div key={task.id} className="rounded-lg border border-gray-200 bg-white p-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900">{task.title}</div>
<div className="mt-1 text-xs text-gray-500">
Créée le {new Date(task.created_at).toLocaleString()}
</div>
</div>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusBadgeClass(task.status)}`}>
{task.status}
</span>
</div>
{task.description && (
<div className="mt-3 whitespace-pre-wrap text-sm text-gray-700">{task.description}</div>
)}
{task.result && (
<div className="mt-3 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-900 whitespace-pre-wrap">
{task.result}
</div>
)}
{task.error && (
<div className="mt-3 rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700 whitespace-pre-wrap">
{task.error}
</div>
)}
<div className="mt-3 flex gap-2">
{(task.status === "error" || task.status === "cancelled" || task.status === "done") && (
<button
type="button"
onClick={() => void handleRetry(task.id)}
disabled={workingTaskId === task.id}
className="rounded bg-gray-900 px-3 py-1 text-xs text-white hover:bg-black disabled:opacity-50"
>
Relancer
</button>
)}
{task.status === "pending" || task.status === "running" ? (
<button
type="button"
onClick={() => void handleCancel(task.id)}
disabled={workingTaskId === task.id}
className="rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200 disabled:opacity-50"
>
Annuler
</button>
) : null}
</div>
</div>
))}
{tasks.length === 0 && (
<div className="text-sm text-gray-400">Aucune tâche pour ce projet.</div>
)}
</div>
</div>
);
}

View file

@ -12,6 +12,11 @@ import type {
Worktree,
TicketResult,
OrchaiNotification,
ProjectModule,
LiveSession,
LiveMessage,
LiveAgentExchange,
AgentTask,
} from "./types";
export async function createProject(
@ -195,3 +200,62 @@ export async function markNotificationRead(id: string): Promise<void> {
export async function markAllNotificationsRead(projectId: string): Promise<number> {
return invoke("mark_all_notifications_read", { projectId });
}
// Project modules
export async function listProjectModules(projectId: string): Promise<ProjectModule[]> {
return invoke("list_project_modules", { projectId });
}
export async function setProjectModuleEnabled(
projectId: string,
moduleKey: string,
enabled: boolean
): Promise<void> {
return invoke("set_project_module_enabled", { projectId, moduleKey, enabled });
}
// Live agent
export async function createLiveSession(
projectId: string,
agentId: string,
title: string
): Promise<LiveSession> {
return invoke("create_live_session", { projectId, agentId, title });
}
export async function listLiveSessions(projectId: string): Promise<LiveSession[]> {
return invoke("list_live_sessions", { projectId });
}
export async function listLiveMessages(sessionId: string): Promise<LiveMessage[]> {
return invoke("list_live_messages", { sessionId });
}
export async function sendLiveMessage(
sessionId: string,
message: string
): Promise<LiveAgentExchange> {
return invoke("send_live_message", { sessionId, message });
}
// Agent tasks
export async function createAgentTask(
projectId: string,
agentId: string,
title: string,
description: string
): Promise<AgentTask> {
return invoke("create_agent_task", { projectId, agentId, title, description });
}
export async function listAgentTasks(projectId: string): Promise<AgentTask[]> {
return invoke("list_agent_tasks", { projectId });
}
export async function retryAgentTask(taskId: string): Promise<void> {
return invoke("retry_agent_task", { taskId });
}
export async function cancelAgentTask(taskId: string): Promise<void> {
return invoke("cancel_agent_task", { taskId });
}

View file

@ -105,3 +105,52 @@ export interface OrchaiNotification {
read: boolean;
created_at: string;
}
export interface ProjectModule {
id: string;
project_id: string;
module_key: string;
name: string;
description: string;
enabled: boolean;
config_json: string;
created_at: string;
updated_at: string;
}
export interface LiveSession {
id: string;
project_id: string;
agent_id: string;
title: string;
status: string;
created_at: string;
updated_at: string;
}
export interface LiveMessage {
id: string;
session_id: string;
sender: string;
content: string;
created_at: string;
}
export interface LiveAgentExchange {
user_message: LiveMessage;
agent_message: LiveMessage;
}
export interface AgentTask {
id: string;
project_id: string;
agent_id: string;
title: string;
description: string;
status: string;
result: string | null;
error: string | null;
created_at: string;
started_at: string | null;
finished_at: string | null;
}