feat: add modular ai orchestration with live chat and task runner
This commit is contained in:
parent
3234cee1c2
commit
5662f34415
26 changed files with 2050 additions and 53 deletions
44
docs/superpowers/specs/2026-04-15-global-ai-orchestrator.md
Normal file
44
docs/superpowers/specs/2026-04-15-global-ai-orchestrator.md
Normal 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.
|
||||
122
src-tauri/migrations/005_orchestration_modules_chat_tasks.sql
Normal file
122
src-tauri/migrations/005_orchestration_modules_chat_tasks.sql
Normal 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;
|
||||
177
src-tauri/src/commands/live_agent.rs
Normal file
177
src-tauri/src/commands/live_agent.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
35
src-tauri/src/commands/module.rs
Normal file
35
src-tauri/src/commands/module.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
90
src-tauri/src/commands/task.rs
Normal file
90
src-tauri/src/commands/task.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
167
src-tauri/src/models/agent_task.rs
Normal file
167
src-tauri/src/models/agent_task.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
161
src-tauri/src/models/live_agent.rs
Normal file
161
src-tauri/src/models/live_agent.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
142
src-tauri/src/models/module.rs
Normal file
142
src-tauri/src/models/module.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
48
src-tauri/src/services/agent_runtime.rs
Normal file
48
src-tauri/src/services/agent_runtime.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
152
src-tauri/src/services/task_runner.rs
Normal file
152
src-tauri/src/services/task_runner.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
283
src/components/projects/ProjectLiveAgent.tsx
Normal file
283
src/components/projects/ProjectLiveAgent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/components/projects/ProjectModules.tsx
Normal file
85
src/components/projects/ProjectModules.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
src/components/projects/ProjectTasks.tsx
Normal file
253
src/components/projects/ProjectTasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue