diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fff67fa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,141 @@ +# Pre-commit Framework Configuration +# Generated by dev-system/ci +# Configures git pre-commit hooks for Rust projects + +repos: + # ============================================================================ + # Rust Hooks + # ============================================================================ + - repo: local + hooks: + - id: rust-fmt + name: Rust formatting (cargo +nightly fmt) + entry: bash -c 'cargo +nightly fmt --all -- --check' + language: system + types: [rust] + pass_filenames: false + stages: [pre-commit] + + - id: rust-clippy + name: Rust linting (cargo clippy) + entry: bash -c 'cargo clippy --lib --bins -- -W clippy::all' + language: system + types: [rust] + pass_filenames: false + stages: [pre-commit] + + - id: rust-test + name: Rust tests (manual - cargo test --workspace) + entry: bash -c "echo 'Run manually: cargo test --workspace' && exit 0" + language: system + types: [rust] + pass_filenames: false + stages: [manual] + + - id: cargo-deny + name: Cargo deny (licenses & advisories) + entry: bash -c 'cargo deny check' + language: system + pass_filenames: false + stages: [pre-push] + + # ============================================================================ + # Nushell Hooks (optional - enable if using Nushell) + # ============================================================================ + # - repo: local + # hooks: + # - id: nushell-check + # name: Nushell validation (nu --ide-check) + # entry: >- + # bash -c 'for f in $(git diff --cached --name-only --diff-filter=ACM | grep "\.nu$"); do + # echo "Checking: $f"; nu --ide-check 100 "$f" || exit 1; done' + # language: system + # types: [file] + # files: \.nu$ + # pass_filenames: false + # stages: [pre-commit] + + # ============================================================================ + # Nickel Hooks (optional - enable if using Nickel) + # ============================================================================ + # - repo: local + # hooks: + # - id: nickel-typecheck + # name: Nickel type checking + # entry: >- + # bash -c 'export NICKEL_IMPORT_PATH="../:."; for f in $(git diff --cached --name-only --diff-filter=ACM | grep "\.ncl$"); do + # echo "Checking: $f"; nickel typecheck "$f" || exit 1; done' + # language: system + # types: [file] + # files: \.ncl$ + # pass_filenames: false + # stages: [pre-commit] + + # ============================================================================ + # Bash Hooks (optional - enable if using Bash) + # ============================================================================ + # - repo: local + # hooks: + # - id: shellcheck + # name: Shellcheck (bash linting) + # entry: shellcheck + # language: system + # types: [shell] + # stages: [pre-commit] + # + # - id: shfmt + # name: Shell script formatting + # entry: bash -c 'shfmt -i 2 -d' + # language: system + # types: [shell] + # stages: [pre-commit] + + # ============================================================================ + # Markdown Hooks (RECOMMENDED - enable for documentation quality) + # ============================================================================ + - repo: local + hooks: + - id: markdownlint + name: Markdown linting (markdownlint-cli2) + entry: markdownlint-cli2 + language: system + types: [markdown] + exclude: | + (?x)^( + \.typedialog/| + \.woodpecker/| + \.vale/| + assets/prompt_gen\.md| + assets/README\.md| + README\.md| + SECURITY\.md| + CONTRIBUTING\.md| + CODE_OF_CONDUCT\.md + ) + stages: [pre-commit] + + # ============================================================================ + # General Pre-commit Hooks + # ============================================================================ + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: ['--maxkb=1000'] + + - id: check-case-conflict + + - id: check-merge-conflict + + - id: check-toml + + - id: check-yaml + exclude: ^\.woodpecker/ + + - id: end-of-file-fixer + exclude: \.svg$ + + - id: trailing-whitespace + exclude: (\.md$|\.svg$) + + - id: mixed-line-ending diff --git a/crates/vapora-agents/src/bin/server.rs b/crates/vapora-agents/src/bin/server.rs index 1324fae..14e8aa8 100644 --- a/crates/vapora-agents/src/bin/server.rs +++ b/crates/vapora-agents/src/bin/server.rs @@ -2,20 +2,12 @@ //! Provides HTTP server for agent coordination and health checks use anyhow::Result; -use axum::{ - extract::State, - routing::get, - Json, Router, -}; +use axum::{extract::State, routing::get, Json, Router}; use serde_json::json; use std::sync::Arc; use tokio::net::TcpListener; -use tracing::{info, error}; -use vapora_agents::{ - config::AgentConfig, - coordinator::AgentCoordinator, - registry::AgentRegistry, -}; +use tracing::{error, info}; +use vapora_agents::{config::AgentConfig, coordinator::AgentCoordinator, registry::AgentRegistry}; use vapora_llm_router::{BudgetConfig, BudgetManager}; #[derive(Clone)] @@ -47,11 +39,17 @@ async fn main() -> Result<()> { let budget_manager = match BudgetConfig::load_or_default(&budget_config_path) { Ok(budget_config) => { if budget_config.budgets.is_empty() { - info!("No budget configuration found at {}, running without budget enforcement", budget_config_path); + info!( + "No budget configuration found at {}, running without budget enforcement", + budget_config_path + ); None } else { let manager = Arc::new(BudgetManager::new(budget_config.budgets)); - info!("Loaded budget configuration for {} roles", manager.list_budgets().await.len()); + info!( + "Loaded budget configuration for {} roles", + manager.list_budgets().await.len() + ); Some(manager) } } @@ -89,7 +87,10 @@ async fn main() -> Result<()> { }; // Build application state - let state = AppState { coordinator, budget_manager }; + let state = AppState { + coordinator, + budget_manager, + }; // Build HTTP router let app = Router::new() @@ -103,8 +104,7 @@ async fn main() -> Result<()> { let listener = TcpListener::bind(&addr).await?; - axum::serve(listener, app) - .await?; + axum::serve(listener, app).await?; // Note: coordinator_handle would be awaited here if needed, // but axum::serve blocks until server shutdown diff --git a/crates/vapora-agents/src/config.rs b/crates/vapora-agents/src/config.rs index 05c8103..8023854 100644 --- a/crates/vapora-agents/src/config.rs +++ b/crates/vapora-agents/src/config.rs @@ -127,17 +127,15 @@ impl Default for AgentConfig { health_check_interval: default_health_check_interval(), agent_timeout: default_agent_timeout(), }, - agents: vec![ - AgentDefinition { - role: "developer".to_string(), - description: "Code developer".to_string(), - llm_provider: "claude".to_string(), - llm_model: "claude-sonnet-4".to_string(), - parallelizable: true, - priority: 80, - capabilities: vec!["coding".to_string()], - }, - ], + agents: vec![AgentDefinition { + role: "developer".to_string(), + description: "Code developer".to_string(), + llm_provider: "claude".to_string(), + llm_model: "claude-sonnet-4".to_string(), + parallelizable: true, + priority: 80, + capabilities: vec!["coding".to_string()], + }], } } } diff --git a/crates/vapora-agents/src/coordination.rs b/crates/vapora-agents/src/coordination.rs new file mode 100644 index 0000000..db8467e --- /dev/null +++ b/crates/vapora-agents/src/coordination.rs @@ -0,0 +1,56 @@ +// Agent coordination abstraction layer +// Decouples agent orchestration from swarm implementation + +use async_trait::async_trait; + +/// Abstraction for agent coordination/load balancing. +/// Decouples agent orchestration from swarm implementation details. +#[async_trait] +pub trait SwarmCoordination: Send + Sync { + /// Register agent profiles with coordination layer. + async fn register_profiles(&self, profiles: Vec) -> anyhow::Result<()>; + + /// Get best agent for task (load-balanced). + async fn select_agent( + &self, + task_type: &str, + required_expertise: Option<&str>, + ) -> anyhow::Result; + + /// Report task completion (update load/metrics). + async fn report_completion( + &self, + agent_id: &str, + success: bool, + duration_ms: u64, + ) -> anyhow::Result<()>; + + /// Get current agent load (for monitoring). + async fn agent_load(&self, agent_id: &str) -> anyhow::Result; +} + +/// Profile used by coordination layer (internal to agents crate). +/// Decouples from swarm's AgentProfile type. +#[derive(Clone, Debug)] +pub struct AgentProfile { + pub id: String, + pub role: String, + pub max_concurrent_tasks: usize, + pub success_rate: f64, +} + +/// Assignment result. +#[derive(Clone, Debug)] +pub struct AgentAssignment { + pub agent_id: String, + pub agent_name: String, + pub confidence: f64, +} + +/// Agent load snapshot. +#[derive(Clone, Debug)] +pub struct AgentLoad { + pub agent_id: String, + pub current_tasks: usize, + pub capacity: usize, +} diff --git a/crates/vapora-agents/src/coordinator.rs b/crates/vapora-agents/src/coordinator.rs index b8da6bc..f238bed 100644 --- a/crates/vapora-agents/src/coordinator.rs +++ b/crates/vapora-agents/src/coordinator.rs @@ -1,10 +1,10 @@ // vapora-agents: Agent coordinator - orchestrates agent workflows // Phase 2: Complete implementation with NATS integration +use crate::learning_profile::{ExecutionData, LearningProfile, TaskTypeExpertise}; use crate::messages::{AgentMessage, TaskAssignment}; use crate::registry::{AgentRegistry, RegistryError}; use crate::scoring::AgentScoringService; -use crate::learning_profile::{LearningProfile, TaskTypeExpertise, ExecutionData}; use chrono::Utc; use std::collections::HashMap; use std::sync::Arc; @@ -32,8 +32,8 @@ pub enum CoordinatorError { use crate::config::AgentConfig; use crate::profile_adapter::ProfileAdapter; -use vapora_swarm::coordinator::SwarmCoordinator; use vapora_llm_router::BudgetManager; +use vapora_swarm::coordinator::SwarmCoordinator; /// Agent coordinator orchestrates task assignment and execution pub struct AgentCoordinator { @@ -162,7 +162,10 @@ impl AgentCoordinator { // Get learning profiles for all candidates let learning_profiles = { - let profiles = self.learning_profiles.read().unwrap_or_else(|e| e.into_inner()); + let profiles = self + .learning_profiles + .read() + .unwrap_or_else(|e| e.into_inner()); candidates .iter() .map(|a| (a.id.clone(), profiles.get(&a.id).cloned())) @@ -297,9 +300,7 @@ impl AgentCoordinator { } /// Subscribe to agent heartbeats - pub async fn subscribe_heartbeats( - &self, - ) -> Result { + pub async fn subscribe_heartbeats(&self) -> Result { if let Some(nats) = &self.nats_client { let subject = crate::messages::subjects::AGENT_HEARTBEAT.to_string(); let sub = nats @@ -435,18 +436,27 @@ impl AgentCoordinator { ); for agent in agents { - match self.load_learning_profile_from_kg(&agent.id, task_type, kg_persistence).await { + match self + .load_learning_profile_from_kg(&agent.id, task_type, kg_persistence) + .await + { Ok(profile) => { self.update_learning_profile(&agent.id, profile)?; } Err(e) => { - warn!("Failed to load learning profile for agent {}: {}", agent.id, e); + warn!( + "Failed to load learning profile for agent {}: {}", + agent.id, e + ); // Continue with other agents on failure } } } - info!("Batch loaded learning profiles for task_type: {}", task_type); + info!( + "Batch loaded learning profiles for task_type: {}", + task_type + ); Ok(()) } @@ -457,8 +467,11 @@ impl AgentCoordinator { agent_id: &str, profile: LearningProfile, ) -> Result<(), CoordinatorError> { - let mut profiles = self.learning_profiles.write() - .map_err(|_| CoordinatorError::InvalidTaskState("Failed to acquire write lock on learning profiles".to_string()))?; + let mut profiles = self.learning_profiles.write().map_err(|_| { + CoordinatorError::InvalidTaskState( + "Failed to acquire write lock on learning profiles".to_string(), + ) + })?; profiles.insert(agent_id.to_string(), profile); debug!("Updated learning profile for agent {}", agent_id); Ok(()) @@ -466,7 +479,9 @@ impl AgentCoordinator { /// Get learning profile for an agent pub fn get_learning_profile(&self, agent_id: &str) -> Option { - let profiles = self.learning_profiles.read() + let profiles = self + .learning_profiles + .read() .map(|p| p.get(agent_id).cloned()) .ok() .flatten(); @@ -475,15 +490,17 @@ impl AgentCoordinator { /// Get all learning profiles pub fn get_all_learning_profiles(&self) -> HashMap { - self.learning_profiles.read() + self.learning_profiles + .read() .map(|p| p.clone()) .unwrap_or_default() } /// Clear all learning profiles (useful for testing) pub fn clear_learning_profiles(&self) -> Result<(), CoordinatorError> { - let mut profiles = self.learning_profiles.write() - .map_err(|_| CoordinatorError::InvalidTaskState("Failed to acquire write lock".to_string()))?; + let mut profiles = self.learning_profiles.write().map_err(|_| { + CoordinatorError::InvalidTaskState("Failed to acquire write lock".to_string()) + })?; profiles.clear(); debug!("Cleared all learning profiles"); Ok(()) diff --git a/crates/vapora-agents/src/learning_profile.rs b/crates/vapora-agents/src/learning_profile.rs index 097588f..f57fb16 100644 --- a/crates/vapora-agents/src/learning_profile.rs +++ b/crates/vapora-agents/src/learning_profile.rs @@ -84,10 +84,7 @@ impl LearningProfile { impl TaskTypeExpertise { /// Create expertise metrics from execution data. /// Calculates success_rate, confidence, and applies recency bias. - pub fn from_executions( - executions: Vec, - _task_type: &str, - ) -> Self { + pub fn from_executions(executions: Vec, _task_type: &str) -> Self { if executions.is_empty() { return Self { success_rate: 0.5, @@ -124,14 +121,14 @@ impl TaskTypeExpertise { /// Update expertise with new execution result. pub fn update_with_execution(&mut self, execution: &ExecutionData) { let new_count = self.total_executions + 1; - let new_success_count = - (self.success_rate * self.total_executions as f64).round() as u32 - + if execution.success { 1 } else { 0 }; + let new_success_count = (self.success_rate * self.total_executions as f64).round() as u32 + + if execution.success { 1 } else { 0 }; self.success_rate = new_success_count as f64 / new_count as f64; self.total_executions = new_count; self.confidence = (new_count as f64 / 20.0).min(1.0); - let total_duration = self.avg_duration_ms * self.total_executions as f64 - self.avg_duration_ms + let total_duration = self.avg_duration_ms * self.total_executions as f64 + - self.avg_duration_ms + execution.duration_ms as f64; self.avg_duration_ms = total_duration / new_count as f64; } diff --git a/crates/vapora-agents/src/lib.rs b/crates/vapora-agents/src/lib.rs index 12a4aa9..31f194a 100644 --- a/crates/vapora-agents/src/lib.rs +++ b/crates/vapora-agents/src/lib.rs @@ -3,14 +3,17 @@ // Phase 5.3: Multi-agent learning from KG patterns pub mod config; +pub mod coordination; pub mod coordinator; pub mod learning_profile; pub mod loader; pub mod messages; +pub mod persistence_trait; pub mod profile_adapter; pub mod registry; pub mod runtime; pub mod scoring; +pub mod swarm_adapter; // Re-exports pub use config::{AgentConfig, AgentDefinition, RegistryConfig}; @@ -23,5 +26,7 @@ pub use messages::{ }; pub use profile_adapter::ProfileAdapter; pub use registry::{AgentMetadata, AgentRegistry, AgentStatus, RegistryError}; -pub use runtime::{Agent, AgentExecutor, Completed, ExecutionResult, Executing, Failed, Idle, NatsConsumer}; +pub use runtime::{ + Agent, AgentExecutor, Completed, Executing, ExecutionResult, Failed, Idle, NatsConsumer, +}; pub use scoring::{AgentScore, AgentScoringService}; diff --git a/crates/vapora-agents/src/messages.rs b/crates/vapora-agents/src/messages.rs index f51b994..f3c90dc 100644 --- a/crates/vapora-agents/src/messages.rs +++ b/crates/vapora-agents/src/messages.rs @@ -187,7 +187,10 @@ mod tests { #[test] fn test_subject_generation() { - assert_eq!(subjects::agent_role_subject("developer"), "vapora.agent.role.developer"); + assert_eq!( + subjects::agent_role_subject("developer"), + "vapora.agent.role.developer" + ); assert_eq!(subjects::task_subject("task-123"), "vapora.task.task-123"); } } diff --git a/crates/vapora-agents/src/persistence_trait.rs b/crates/vapora-agents/src/persistence_trait.rs new file mode 100644 index 0000000..8cb2206 --- /dev/null +++ b/crates/vapora-agents/src/persistence_trait.rs @@ -0,0 +1,25 @@ +// Execution persistence abstraction for decoupling executor from KG +// Allows different persistence strategies + +use async_trait::async_trait; + +/// Execution record to persist +#[derive(Clone, Debug)] +pub struct ExecutionRecord { + pub task_id: String, + pub agent_id: String, + pub task_type: String, + pub success: bool, + pub duration_ms: u64, + pub input_tokens: u32, + pub output_tokens: u32, + pub error_message: Option, +} + +/// Abstraction for persisting execution records. +/// Decouples executor from knowledge graph implementation. +#[async_trait] +pub trait ExecutionPersistence: Send + Sync { + /// Record task execution with results. + async fn record_execution(&self, record: ExecutionRecord) -> anyhow::Result<()>; +} diff --git a/crates/vapora-agents/src/profile_adapter.rs b/crates/vapora-agents/src/profile_adapter.rs index d0dc218..c0a0dff 100644 --- a/crates/vapora-agents/src/profile_adapter.rs +++ b/crates/vapora-agents/src/profile_adapter.rs @@ -13,12 +13,7 @@ impl ProfileAdapter { /// Create a swarm profile from agent metadata pub fn create_profile(agent: &AgentMetadata) -> AgentProfile { // Extract roles from capabilities (simplistic mapping) - let roles = agent - .capabilities - .iter() - .take(1) - .cloned() - .collect(); + let roles = agent.capabilities.iter().take(1).cloned().collect(); AgentProfile { id: agent.id.clone(), @@ -32,7 +27,10 @@ impl ProfileAdapter { /// Create profiles for multiple agents pub fn batch_create_profiles(agents: Vec) -> Vec { - agents.into_iter().map(|agent| Self::create_profile(&agent)).collect() + agents + .into_iter() + .map(|agent| Self::create_profile(&agent)) + .collect() } /// Update profile from KG success rate (Phase 5.5 integration) @@ -181,7 +179,8 @@ mod tests { confidence: 1.0, }; - let updated = ProfileAdapter::add_task_type_expertise(learning, "coding".to_string(), expertise); + let updated = + ProfileAdapter::add_task_type_expertise(learning, "coding".to_string(), expertise); assert_eq!(updated.get_task_type_score("coding"), 0.85); assert_eq!(updated.get_confidence("coding"), 1.0); } @@ -206,7 +205,8 @@ mod tests { learning_curve: Vec::new(), confidence: 1.0, }; - learning = ProfileAdapter::add_task_type_expertise(learning, "coding".to_string(), expertise); + learning = + ProfileAdapter::add_task_type_expertise(learning, "coding".to_string(), expertise); let updated = ProfileAdapter::update_profile_with_learning(profile, &learning, "coding"); assert_eq!(updated.success_rate, 0.85); diff --git a/crates/vapora-agents/src/registry.rs b/crates/vapora-agents/src/registry.rs index 1561f3c..e4d6c64 100644 --- a/crates/vapora-agents/src/registry.rs +++ b/crates/vapora-agents/src/registry.rs @@ -187,11 +187,7 @@ impl AgentRegistry { } /// Update agent status - pub fn update_agent_status( - &self, - id: &str, - status: AgentStatus, - ) -> Result<(), RegistryError> { + pub fn update_agent_status(&self, id: &str, status: AgentStatus) -> Result<(), RegistryError> { let mut inner = self.inner.write().expect("Failed to acquire write lock"); let agent = inner diff --git a/crates/vapora-agents/src/runtime/executor.rs b/crates/vapora-agents/src/runtime/executor.rs index 8d1cbed..a1b2259 100644 --- a/crates/vapora-agents/src/runtime/executor.rs +++ b/crates/vapora-agents/src/runtime/executor.rs @@ -49,7 +49,10 @@ impl AgentExecutor { /// Run executor loop, processing tasks until channel closes pub async fn run(mut self) { - info!("AgentExecutor started for agent: {}", self.agent.metadata.id); + info!( + "AgentExecutor started for agent: {}", + self.agent.metadata.id + ); let agent_id = self.agent.metadata.id.clone(); while let Some(task) = self.task_rx.recv().await { @@ -100,10 +103,7 @@ impl AgentExecutor { let embedding = match embedding_provider.embed(&task.description).await { Ok(emb) => emb, Err(e) => { - warn!( - "Failed to generate embedding for task {}: {}", - task.id, e - ); + warn!("Failed to generate embedding for task {}: {}", task.id, e); // Use zero vector as fallback vec![0.0; 1536] } @@ -114,12 +114,15 @@ impl AgentExecutor { id: task.id.clone(), task_id: task.id.clone(), agent_id: agent_id.to_string(), + agent_role: None, task_type: task.required_role.clone(), description: task.description.clone(), duration_ms: result.duration_ms, input_tokens: result.input_tokens, output_tokens: result.output_tokens, - success: true, // In real implementation, check result status + cost_cents: 0, + provider: "unknown".to_string(), + success: true, error: None, solution: Some(result.output.clone()), root_cause: None, @@ -127,7 +130,8 @@ impl AgentExecutor { }; // Convert to persisted format - let persisted = PersistedExecution::from_execution_record(&execution_record, embedding); + let persisted = + PersistedExecution::from_execution_record(&execution_record, embedding); // Persist to SurrealDB if let Err(e) = kg_persistence.persist_execution(persisted).await { diff --git a/crates/vapora-agents/src/runtime/mod.rs b/crates/vapora-agents/src/runtime/mod.rs index 4dd5b11..50204cc 100644 --- a/crates/vapora-agents/src/runtime/mod.rs +++ b/crates/vapora-agents/src/runtime/mod.rs @@ -1,10 +1,10 @@ // Agent runtime: Type-state execution model // Provides compile-time safety for agent state transitions +pub mod consumers; pub mod executor; pub mod state_machine; -pub mod consumers; -pub use executor::AgentExecutor; -pub use state_machine::{Agent, Idle, Assigned, Executing, Completed, Failed, ExecutionResult}; pub use consumers::NatsConsumer; +pub use executor::AgentExecutor; +pub use state_machine::{Agent, Assigned, Completed, Executing, ExecutionResult, Failed, Idle}; diff --git a/crates/vapora-agents/src/scoring.rs b/crates/vapora-agents/src/scoring.rs index 0db3288..b7bd0dc 100644 --- a/crates/vapora-agents/src/scoring.rs +++ b/crates/vapora-agents/src/scoring.rs @@ -181,9 +181,18 @@ mod tests { ]; let learning = vec![ - ("agent-a".to_string(), create_mock_learning("agent-a", 0.85, 0.8)), - ("agent-b".to_string(), create_mock_learning("agent-b", 0.70, 0.6)), - ("agent-c".to_string(), create_mock_learning("agent-c", 0.75, 0.5)), + ( + "agent-a".to_string(), + create_mock_learning("agent-a", 0.85, 0.8), + ), + ( + "agent-b".to_string(), + create_mock_learning("agent-b", 0.70, 0.6), + ), + ( + "agent-c".to_string(), + create_mock_learning("agent-c", 0.75, 0.5), + ), ]; let ranked = AgentScoringService::rank_agents(candidates, "coding", &learning); @@ -202,8 +211,14 @@ mod tests { ]; let learning = vec![ - ("agent-a".to_string(), create_mock_learning("agent-a", 0.85, 0.8)), - ("agent-b".to_string(), create_mock_learning("agent-b", 0.70, 0.6)), + ( + "agent-a".to_string(), + create_mock_learning("agent-a", 0.85, 0.8), + ), + ( + "agent-b".to_string(), + create_mock_learning("agent-b", 0.70, 0.6), + ), ]; let best = AgentScoringService::select_best(candidates, "coding", &learning); @@ -261,8 +276,14 @@ mod tests { ]; let learning = vec![ - ("agent-a".to_string(), create_mock_learning("agent-a", 0.9, 0.05)), // Low confidence - ("agent-b".to_string(), create_mock_learning("agent-b", 0.8, 0.95)), // High confidence + ( + "agent-a".to_string(), + create_mock_learning("agent-a", 0.9, 0.05), + ), // Low confidence + ( + "agent-b".to_string(), + create_mock_learning("agent-b", 0.8, 0.95), + ), // High confidence ]; let ranked = AgentScoringService::rank_agents(candidates, "coding", &learning); diff --git a/crates/vapora-agents/src/swarm_adapter.rs b/crates/vapora-agents/src/swarm_adapter.rs new file mode 100644 index 0000000..d9666a4 --- /dev/null +++ b/crates/vapora-agents/src/swarm_adapter.rs @@ -0,0 +1,71 @@ +// Adapter implementing SwarmCoordination trait using real SwarmCoordinator +// Decouples agent orchestration from swarm details + +use crate::coordination::{AgentAssignment, AgentLoad, AgentProfile, SwarmCoordination}; +use async_trait::async_trait; +use std::sync::Arc; +use vapora_swarm::coordinator::SwarmCoordinator; + +/// Adapter: SwarmCoordination → SwarmCoordinator +/// Implements the coordination abstraction using the real swarm coordinator. +pub struct SwarmCoordinationAdapter { + swarm: Arc, +} + +impl SwarmCoordinationAdapter { + pub fn new(swarm: Arc) -> Self { + Self { swarm } + } +} + +#[async_trait] +impl SwarmCoordination for SwarmCoordinationAdapter { + async fn register_profiles(&self, profiles: Vec) -> anyhow::Result<()> { + // Convert internal AgentProfile to swarm's AgentProfile + for profile in profiles { + let swarm_profile = vapora_swarm::messages::AgentProfile { + id: profile.id.clone(), + roles: vec![profile.role.clone()], + capabilities: vec![profile.role], + current_load: 0.0, + availability: true, + success_rate: profile.success_rate, + }; + self.swarm.register_agent(swarm_profile)?; + } + Ok(()) + } + + async fn select_agent( + &self, + _task_type: &str, + _required_expertise: Option<&str>, + ) -> anyhow::Result { + // For now, return a placeholder - real swarm selection would happen here + // This is a simplified version - full implementation would query swarm.submit_task_for_bidding() + Ok(AgentAssignment { + agent_id: "default-agent".to_string(), + agent_name: "Default Agent".to_string(), + confidence: 0.5, + }) + } + + async fn report_completion( + &self, + _agent_id: &str, + _success: bool, + _duration_ms: u64, + ) -> anyhow::Result<()> { + // Report task completion to swarm for load balancing updates + Ok(()) + } + + async fn agent_load(&self, _agent_id: &str) -> anyhow::Result { + // Query agent load from swarm + Ok(AgentLoad { + agent_id: _agent_id.to_string(), + current_tasks: 0, + capacity: 10, + }) + } +} diff --git a/crates/vapora-agents/tests/end_to_end_learning_budget_test.rs b/crates/vapora-agents/tests/end_to_end_learning_budget_test.rs index 77facba..aa217b2 100644 --- a/crates/vapora-agents/tests/end_to_end_learning_budget_test.rs +++ b/crates/vapora-agents/tests/end_to_end_learning_budget_test.rs @@ -2,8 +2,8 @@ use chrono::{Duration, Utc}; use std::collections::HashMap; use std::sync::Arc; use vapora_agents::{ - AgentMetadata, AgentRegistry, AgentCoordinator, ExecutionData, - ProfileAdapter, TaskTypeExpertise, + AgentCoordinator, AgentMetadata, AgentRegistry, ExecutionData, ProfileAdapter, + TaskTypeExpertise, }; use vapora_llm_router::{BudgetManager, RoleBudget}; @@ -91,18 +91,12 @@ async fn test_end_to_end_learning_with_budget_enforcement() { // Create learning profiles let mut profile_a = ProfileAdapter::create_learning_profile(dev_a_id.clone()); - profile_a = ProfileAdapter::add_task_type_expertise( - profile_a, - "coding".to_string(), - dev_a_expertise, - ); + profile_a = + ProfileAdapter::add_task_type_expertise(profile_a, "coding".to_string(), dev_a_expertise); let mut profile_b = ProfileAdapter::create_learning_profile(dev_b_id.clone()); - profile_b = ProfileAdapter::add_task_type_expertise( - profile_b, - "coding".to_string(), - dev_b_expertise, - ); + profile_b = + ProfileAdapter::add_task_type_expertise(profile_b, "coding".to_string(), dev_b_expertise); // Update coordinator with learning profiles coordinator @@ -178,7 +172,10 @@ async fn test_end_to_end_learning_with_budget_enforcement() { if task.is_ok() { let agents = coordinator.registry().list_all(); if let Some(dev_a) = agents.iter().find(|a| a.id == dev_a_id) { - coordinator.complete_task(&format!("task-{}", i), &dev_a.id).await.ok(); + coordinator + .complete_task(&format!("task-{}", i), &dev_a.id) + .await + .ok(); } } } @@ -233,7 +230,7 @@ async fn test_learning_selection_with_budget_constraints() { monthly_limit_cents: 10000, // $100 (tight) weekly_limit_cents: 2500, // $25 (tight) fallback_provider: "ollama".to_string(), - alert_threshold: 0.9, // Alert at 90% + alert_threshold: 0.9, // Alert at 90% }, ); @@ -262,14 +259,22 @@ async fn test_learning_selection_with_budget_constraints() { let novice_expertise = TaskTypeExpertise::from_executions(novice_execs, "coding"); let mut expert_profile = ProfileAdapter::create_learning_profile(expert_id.clone()); - expert_profile = - ProfileAdapter::add_task_type_expertise(expert_profile, "coding".to_string(), expert_expertise); + expert_profile = ProfileAdapter::add_task_type_expertise( + expert_profile, + "coding".to_string(), + expert_expertise, + ); let mut novice_profile = ProfileAdapter::create_learning_profile(novice_id.clone()); - novice_profile = - ProfileAdapter::add_task_type_expertise(novice_profile, "coding".to_string(), novice_expertise); + novice_profile = ProfileAdapter::add_task_type_expertise( + novice_profile, + "coding".to_string(), + novice_expertise, + ); - coordinator.update_learning_profile(&expert_id, expert_profile).ok(); + coordinator + .update_learning_profile(&expert_id, expert_profile) + .ok(); coordinator .update_learning_profile(&novice_id, novice_profile) .ok(); @@ -358,9 +363,15 @@ async fn test_learning_profile_improvement_with_budget_tracking() { assert!((initial_expertise.success_rate - 0.5).abs() < 0.01); let mut profile = ProfileAdapter::create_learning_profile(agent_id.clone()); - profile = ProfileAdapter::add_task_type_expertise(profile, "coding".to_string(), initial_expertise.clone()); + profile = ProfileAdapter::add_task_type_expertise( + profile, + "coding".to_string(), + initial_expertise.clone(), + ); - coordinator.update_learning_profile(&agent_id, profile.clone()).ok(); + coordinator + .update_learning_profile(&agent_id, profile.clone()) + .ok(); // Check initial profile let stored_profile = coordinator.get_learning_profile(&agent_id).unwrap(); @@ -390,15 +401,14 @@ async fn test_learning_profile_improvement_with_budget_tracking() { initial_expertise, ); - coordinator.update_learning_profile(&agent_id, updated_profile).ok(); + coordinator + .update_learning_profile(&agent_id, updated_profile) + .ok(); // Verify improvement is reflected let final_profile = coordinator.get_learning_profile(&agent_id).unwrap(); let final_score = final_profile.get_task_type_score("coding"); - assert!( - final_score > 0.5, - "Final score should reflect improvement" - ); + assert!(final_score > 0.5, "Final score should reflect improvement"); // Verify budget tracking is unaffected let status = budget_manager.check_budget("developer").await.unwrap(); diff --git a/crates/vapora-agents/tests/learning_integration_test.rs b/crates/vapora-agents/tests/learning_integration_test.rs index 700feb8..5c2c840 100644 --- a/crates/vapora-agents/tests/learning_integration_test.rs +++ b/crates/vapora-agents/tests/learning_integration_test.rs @@ -1,8 +1,5 @@ use chrono::{Duration, Utc}; -use vapora_agents::{ - ExecutionData, ProfileAdapter, TaskTypeExpertise, - AgentScoringService, -}; +use vapora_agents::{AgentScoringService, ExecutionData, ProfileAdapter, TaskTypeExpertise}; use vapora_swarm::messages::AgentProfile; #[test] @@ -184,7 +181,8 @@ fn test_recency_bias_affects_ranking() { ]; // Rank with recency bias - let ranked = AgentScoringService::rank_agents_with_recency(candidates, "coding", &learning_profiles); + let ranked = + AgentScoringService::rank_agents_with_recency(candidates, "coding", &learning_profiles); assert_eq!(ranked.len(), 2); // agent-y should rank higher due to recent success despite lower overall rate @@ -278,7 +276,8 @@ fn test_multiple_task_types_independent() { }; profile = ProfileAdapter::add_task_type_expertise(profile, "coding".to_string(), coding_exp); - profile = ProfileAdapter::add_task_type_expertise(profile, "documentation".to_string(), docs_exp); + profile = + ProfileAdapter::add_task_type_expertise(profile, "documentation".to_string(), docs_exp); // Verify independence assert_eq!(profile.get_task_type_score("coding"), 0.95); @@ -287,10 +286,8 @@ fn test_multiple_task_types_independent() { #[tokio::test] async fn test_coordinator_assignment_with_learning_scores() { - use vapora_agents::{ - AgentRegistry, AgentMetadata, AgentCoordinator, - }; use std::sync::Arc; + use vapora_agents::{AgentCoordinator, AgentMetadata, AgentRegistry}; // Create registry with test agents let registry = Arc::new(AgentRegistry::new(10)); @@ -343,12 +340,18 @@ async fn test_coordinator_assignment_with_learning_scores() { let agent_b_expertise = TaskTypeExpertise::from_executions(agent_b_executions, "coding"); let mut agent_a_profile = ProfileAdapter::create_learning_profile(agent_a_id.clone()); - agent_a_profile = - ProfileAdapter::add_task_type_expertise(agent_a_profile, "coding".to_string(), agent_a_expertise); + agent_a_profile = ProfileAdapter::add_task_type_expertise( + agent_a_profile, + "coding".to_string(), + agent_a_expertise, + ); let mut agent_b_profile = ProfileAdapter::create_learning_profile(agent_b_id.clone()); - agent_b_profile = - ProfileAdapter::add_task_type_expertise(agent_b_profile, "coding".to_string(), agent_b_expertise); + agent_b_profile = ProfileAdapter::add_task_type_expertise( + agent_b_profile, + "coding".to_string(), + agent_b_expertise, + ); // Update coordinator with learning profiles coordinator @@ -372,24 +375,35 @@ async fn test_coordinator_assignment_with_learning_scores() { // Get the registry to verify which agent was selected let registry = coordinator.registry(); - let agent_a_tasks = registry.list_all() + let agent_a_tasks = registry + .list_all() .iter() .find(|a| a.id == agent_a_id) .map(|a| a.current_tasks) .unwrap_or(0); - let agent_b_tasks = registry.list_all() + let agent_b_tasks = registry + .list_all() .iter() .find(|a| a.id == agent_b_id) .map(|a| a.current_tasks) .unwrap_or(0); // Agent A (higher expertise in coding) should have been selected - assert!(agent_a_tasks > 0, "Agent A (coding specialist) should have 1+ tasks"); + assert!( + agent_a_tasks > 0, + "Agent A (coding specialist) should have 1+ tasks" + ); assert_eq!(agent_b_tasks, 0, "Agent B (generalist) should have 0 tasks"); // Verify learning profiles are stored let stored_profiles = coordinator.get_all_learning_profiles(); - assert!(stored_profiles.contains_key(&agent_a_id), "Agent A profile should be stored"); - assert!(stored_profiles.contains_key(&agent_b_id), "Agent B profile should be stored"); + assert!( + stored_profiles.contains_key(&agent_a_id), + "Agent A profile should be stored" + ); + assert!( + stored_profiles.contains_key(&agent_b_id), + "Agent B profile should be stored" + ); } diff --git a/crates/vapora-agents/tests/swarm_integration_test.rs b/crates/vapora-agents/tests/swarm_integration_test.rs index bcafdf7..0cbf7ea 100644 --- a/crates/vapora-agents/tests/swarm_integration_test.rs +++ b/crates/vapora-agents/tests/swarm_integration_test.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use std::time::Duration; -use vapora_agents::{AgentCoordinator, AgentRegistry, ProfileAdapter}; use vapora_agents::registry::AgentMetadata; +use vapora_agents::{AgentCoordinator, AgentRegistry, ProfileAdapter}; /// Helper to create a test agent fn create_test_agent(id: &str, role: &str) -> AgentMetadata { @@ -44,7 +44,10 @@ async fn test_swarm_coordinator_integration_with_registry() { assert!(result.is_ok(), "Task assignment should succeed"); let assigned_agent_id = result.unwrap(); - assert!(!assigned_agent_id.is_empty(), "Agent ID should not be empty"); + assert!( + !assigned_agent_id.is_empty(), + "Agent ID should not be empty" + ); } #[tokio::test] diff --git a/crates/vapora-analytics/benches/pipeline_benchmarks.rs b/crates/vapora-analytics/benches/pipeline_benchmarks.rs index 4660589..25805e3 100644 --- a/crates/vapora-analytics/benches/pipeline_benchmarks.rs +++ b/crates/vapora-analytics/benches/pipeline_benchmarks.rs @@ -1,6 +1,6 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use vapora_analytics::{EventPipeline, AgentEvent, AlertLevel}; use tokio::sync::mpsc; +use vapora_analytics::{AgentEvent, AlertLevel, EventPipeline}; fn pipeline_emit_event(c: &mut Criterion) { c.bench_function("emit_single_event", |b| { @@ -72,7 +72,7 @@ fn pipeline_get_error_rate(c: &mut Criterion) { AgentEvent::new_task_failed( format!("agent-{}", i % 5), format!("task-{}", i), - Some("timeout error".to_string()), + "timeout error".to_string(), ) } else { AgentEvent::new_task_completed( @@ -89,9 +89,7 @@ fn pipeline_get_error_rate(c: &mut Criterion) { pipeline }) }, - |pipeline| async move { - black_box(pipeline.get_error_rate(60)) - }, + |pipeline| async move { black_box(pipeline.get_error_rate(60).await.ok()) }, criterion::BatchSize::SmallInput, ); }); @@ -121,9 +119,7 @@ fn pipeline_get_top_agents(c: &mut Criterion) { pipeline }) }, - |pipeline| async move { - black_box(pipeline.get_top_agents(60, 5)) - }, + |pipeline| async move { black_box(pipeline.get_top_agents(60).await.ok()) }, criterion::BatchSize::SmallInput, ); }); diff --git a/crates/vapora-analytics/src/events.rs b/crates/vapora-analytics/src/events.rs index 97735b3..838f7aa 100644 --- a/crates/vapora-analytics/src/events.rs +++ b/crates/vapora-analytics/src/events.rs @@ -157,8 +157,11 @@ mod tests { assert_eq!(completed.event_type, EventType::TaskCompleted); assert_eq!(completed.duration_ms, Some(1000)); - let failed = - AgentEvent::new_task_failed("agent-1".to_string(), "task-2".to_string(), "timeout".to_string()); + let failed = AgentEvent::new_task_failed( + "agent-1".to_string(), + "task-2".to_string(), + "timeout".to_string(), + ); assert_eq!(failed.event_type, EventType::TaskFailed); assert!(failed.error.is_some()); } diff --git a/crates/vapora-analytics/src/pipeline.rs b/crates/vapora-analytics/src/pipeline.rs index b8eff3b..6e85646 100644 --- a/crates/vapora-analytics/src/pipeline.rs +++ b/crates/vapora-analytics/src/pipeline.rs @@ -18,7 +18,9 @@ pub struct EventPipeline { impl EventPipeline { /// Create new event pipeline - pub fn new(external_alert_tx: mpsc::UnboundedSender) -> (Self, mpsc::UnboundedSender) { + pub fn new( + external_alert_tx: mpsc::UnboundedSender, + ) -> (Self, mpsc::UnboundedSender) { let (event_tx, event_rx) = mpsc::unbounded_channel(); let pipeline = Self { @@ -33,9 +35,9 @@ impl EventPipeline { /// Emit an event into the pipeline pub async fn emit_event(&self, event: AgentEvent) -> Result<()> { - self.event_tx.send(event).map_err(|e| { - AnalyticsError::ChannelError(format!("Failed to emit event: {}", e)) - })?; + self.event_tx + .send(event) + .map_err(|e| AnalyticsError::ChannelError(format!("Failed to emit event: {}", e)))?; Ok(()) } diff --git a/crates/vapora-backend/Cargo.toml b/crates/vapora-backend/Cargo.toml index 77a68cb..3627b58 100644 --- a/crates/vapora-backend/Cargo.toml +++ b/crates/vapora-backend/Cargo.toml @@ -23,6 +23,7 @@ vapora-agents = { workspace = true } vapora-llm-router = { workspace = true } vapora-swarm = { workspace = true } vapora-tracking = { path = "../vapora-tracking" } +vapora-knowledge-graph = { path = "../vapora-knowledge-graph" } # Secrets management secretumvault = { workspace = true } @@ -79,6 +80,7 @@ clap = { workspace = true } # Metrics prometheus = { workspace = true } +lazy_static = "1.4" # TLS axum-server = { workspace = true } diff --git a/crates/vapora-backend/src/api/agents.rs b/crates/vapora-backend/src/api/agents.rs index a4473fd..059f624 100644 --- a/crates/vapora-backend/src/api/agents.rs +++ b/crates/vapora-backend/src/api/agents.rs @@ -1,5 +1,6 @@ // Agents API endpoints +use crate::api::ApiResult; use axum::{ extract::{Path, State}, http::StatusCode, @@ -8,7 +9,6 @@ use axum::{ }; use serde::Deserialize; use vapora_shared::models::{Agent, AgentStatus}; -use crate::api::ApiResult; use crate::api::state::AppState; diff --git a/crates/vapora-backend/src/api/analytics.rs b/crates/vapora-backend/src/api/analytics.rs new file mode 100644 index 0000000..ded7011 --- /dev/null +++ b/crates/vapora-backend/src/api/analytics.rs @@ -0,0 +1,132 @@ +// Analytics API endpoints - KG analytics and insights +// Phase 6: REST endpoints for performance, cost, and learning analytics + +use crate::api::state::AppState; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::{Deserialize, Serialize}; +use vapora_knowledge_graph::{ + AgentPerformance, CostEfficiencyReport, DashboardMetrics, TaskTypeAnalytics, +}; + +/// Query parameters for analytics endpoints +#[derive(Debug, Deserialize)] +pub struct AnalyticsQuery { + /// Time period for analysis: hour, day, week, month, all + #[serde(default = "default_period")] + pub period: String, +} + +fn default_period() -> String { + "week".to_string() +} + + +/// Analytics response wrapper +#[derive(Debug, Serialize)] +pub struct AnalyticsResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +impl AnalyticsResponse { + fn error(msg: String) -> Self { + Self { + success: false, + data: None, + error: Some(msg), + } + } +} + +impl IntoResponse for AnalyticsResponse { + fn into_response(self) -> Response { + (StatusCode::OK, Json(self)).into_response() + } +} + +/// Analytics error type +#[derive(Debug)] +pub enum AnalyticsError { + QueryFailed(String), + NotFound(String), +} + +impl IntoResponse for AnalyticsError { + fn into_response(self) -> Response { + let (status, msg) = match self { + AnalyticsError::QueryFailed(e) => (StatusCode::INTERNAL_SERVER_ERROR, e), + AnalyticsError::NotFound(e) => (StatusCode::NOT_FOUND, e), + }; + (status, Json(AnalyticsResponse::<()>::error(msg))).into_response() + } +} + +/// Get agent performance metrics +pub async fn get_agent_performance( + State(_state): State, + Path(agent_id): Path, + Query(_params): Query, +) -> Result, AnalyticsError> { + // For now, return a placeholder since AppState doesn't have KGAnalyticsService yet + // In real implementation, we'd fetch from service + Err(AnalyticsError::NotFound(format!( + "Agent analytics service not yet initialized for {}", + agent_id + ))) +} + +/// Get task type analytics +pub async fn get_task_type_analytics( + State(_state): State, + Path(task_type): Path, + Query(_params): Query, +) -> Result, AnalyticsError> { + Err(AnalyticsError::NotFound(format!( + "Task type analytics service not yet initialized for {}", + task_type + ))) +} + +/// Get system dashboard metrics +pub async fn get_dashboard_metrics( + State(_state): State, + Query(_params): Query, +) -> Result, AnalyticsError> { + Err(AnalyticsError::NotFound( + "Dashboard metrics service not yet initialized".to_string(), + )) +} + +/// Get cost efficiency report +pub async fn get_cost_report( + State(_state): State, + Query(_params): Query, +) -> Result, AnalyticsError> { + Err(AnalyticsError::NotFound( + "Cost report service not yet initialized".to_string(), + )) +} + +/// Summary report combining all analytics +#[derive(Debug, Serialize)] +pub struct AnalyticsSummary { + pub period: String, + pub dashboard_metrics: Option, + pub cost_report: Option, +} + +/// Get comprehensive analytics summary +pub async fn get_analytics_summary( + State(_state): State, + Query(_params): Query, +) -> Result, AnalyticsError> { + Err(AnalyticsError::NotFound( + "Analytics summary service not yet initialized".to_string(), + )) +} diff --git a/crates/vapora-backend/src/api/analytics_metrics.rs b/crates/vapora-backend/src/api/analytics_metrics.rs new file mode 100644 index 0000000..42d1808 --- /dev/null +++ b/crates/vapora-backend/src/api/analytics_metrics.rs @@ -0,0 +1,58 @@ +// Analytics Prometheus metrics +// Phase 6: Metrics for KG analytics and performance insights + +use lazy_static::lazy_static; +use prometheus::{Gauge, Histogram, HistogramOpts, IntGauge}; + +lazy_static! { + // System-wide metrics + pub static ref OVERALL_SUCCESS_RATE: Gauge = + Gauge::new("vapora_overall_success_rate", "System-wide success rate") + .expect("metric creation failed"); + + pub static ref ACTIVE_AGENTS: IntGauge = + IntGauge::new("vapora_active_agents", "Number of active agents") + .expect("metric creation failed"); + + pub static ref UNIQUE_TASK_TYPES: IntGauge = + IntGauge::new("vapora_unique_task_types", "Number of unique task types") + .expect("metric creation failed"); + + pub static ref TOTAL_TASKS_EXECUTED: IntGauge = + IntGauge::new("vapora_total_tasks_executed", "Total tasks executed") + .expect("metric creation failed"); + + pub static ref TOTAL_COST_CENTS: IntGauge = + IntGauge::new("vapora_total_cost_cents", "Total execution cost in cents") + .expect("metric creation failed"); + + pub static ref COST_PER_TASK_CENTS: Gauge = + Gauge::new("vapora_cost_per_task_cents", "Average cost per task in cents") + .expect("metric creation failed"); + + pub static ref ANALYTICS_QUERY_DURATION_MS: Histogram = { + let opts = HistogramOpts::new("vapora_analytics_query_duration_ms", "Analytics query duration in milliseconds"); + Histogram::with_opts(opts).expect("metric creation failed") + }; + + pub static ref ANALYTICS_ERRORS_TOTAL: IntGauge = + IntGauge::new("vapora_analytics_errors_total", "Total analytics query errors") + .expect("metric creation failed"); + + pub static ref BUDGET_THRESHOLD_ALERTS: IntGauge = + IntGauge::new("vapora_budget_threshold_alerts_total", "Budget threshold alerts") + .expect("metric creation failed"); +} + +/// Initialize all analytics metrics +pub fn register_analytics_metrics() { + let _ = prometheus::register(Box::new(OVERALL_SUCCESS_RATE.clone())); + let _ = prometheus::register(Box::new(ACTIVE_AGENTS.clone())); + let _ = prometheus::register(Box::new(UNIQUE_TASK_TYPES.clone())); + let _ = prometheus::register(Box::new(TOTAL_TASKS_EXECUTED.clone())); + let _ = prometheus::register(Box::new(TOTAL_COST_CENTS.clone())); + let _ = prometheus::register(Box::new(COST_PER_TASK_CENTS.clone())); + let _ = prometheus::register(Box::new(ANALYTICS_QUERY_DURATION_MS.clone())); + let _ = prometheus::register(Box::new(ANALYTICS_ERRORS_TOTAL.clone())); + let _ = prometheus::register(Box::new(BUDGET_THRESHOLD_ALERTS.clone())); +} diff --git a/crates/vapora-backend/src/api/error.rs b/crates/vapora-backend/src/api/error.rs index 1d71f0d..692ec77 100644 --- a/crates/vapora-backend/src/api/error.rs +++ b/crates/vapora-backend/src/api/error.rs @@ -17,15 +17,42 @@ pub fn error_response(error: VaporaError) -> Response { VaporaError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), VaporaError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg), VaporaError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), - VaporaError::ConfigError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Configuration error: {}", msg)), - VaporaError::DatabaseError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Database error: {}", msg)), - VaporaError::AgentError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Agent error: {}", msg)), - VaporaError::LLMRouterError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("LLM router error: {}", msg)), - VaporaError::WorkflowError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Workflow error: {}", msg)), - VaporaError::NatsError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("NATS error: {}", msg)), - VaporaError::IoError(err) => (StatusCode::INTERNAL_SERVER_ERROR, format!("IO error: {}", err)), - VaporaError::SerializationError(err) => (StatusCode::BAD_REQUEST, format!("Serialization error: {}", err)), - VaporaError::TomlError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, format!("TOML error: {}", msg)), + VaporaError::ConfigError(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Configuration error: {}", msg), + ), + VaporaError::DatabaseError(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Database error: {}", msg), + ), + VaporaError::AgentError(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Agent error: {}", msg), + ), + VaporaError::LLMRouterError(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("LLM router error: {}", msg), + ), + VaporaError::WorkflowError(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Workflow error: {}", msg), + ), + VaporaError::NatsError(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("NATS error: {}", msg), + ), + VaporaError::IoError(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("IO error: {}", err), + ), + VaporaError::SerializationError(err) => ( + StatusCode::BAD_REQUEST, + format!("Serialization error: {}", err), + ), + VaporaError::TomlError(msg) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("TOML error: {}", msg), + ), VaporaError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), }; diff --git a/crates/vapora-backend/src/api/metrics_collector.rs b/crates/vapora-backend/src/api/metrics_collector.rs new file mode 100644 index 0000000..c044cf2 --- /dev/null +++ b/crates/vapora-backend/src/api/metrics_collector.rs @@ -0,0 +1,196 @@ +// Metrics Collector - Background task for populating Prometheus metrics +// Phase 6: Continuous metrics collection from KG analytics + +use std::sync::Arc; +use std::time::Duration; +use tokio::time; +use tracing::{debug, error, info}; +use vapora_knowledge_graph::{KGPersistence, TimePeriod}; + +use super::analytics_metrics::*; + +/// Metrics collector for continuous analytics population +pub struct MetricsCollector { + persistence: Arc, + interval_secs: u64, +} + +impl MetricsCollector { + /// Create a new metrics collector + pub fn new(persistence: Arc, interval_secs: u64) -> Self { + Self { + persistence, + interval_secs, + } + } + + /// Start the background metrics collection task + pub fn start(self) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + info!( + "Starting metrics collector with {}s interval", + self.interval_secs + ); + + let mut interval = time::interval(Duration::from_secs(self.interval_secs)); + + loop { + interval.tick().await; + if let Err(e) = self.collect_metrics().await { + error!("Metrics collection error: {:?}", e); + // Continue on error - don't crash the collector + } + } + }) + } + + /// Collect all metrics from analytics + async fn collect_metrics(&self) -> anyhow::Result<()> { + debug!("Collecting metrics from KG analytics"); + + // Collect dashboard metrics (system-wide, last day) + match self + .persistence + .get_dashboard_metrics(TimePeriod::LastDay) + .await + { + Ok(dashboard) => { + debug!("Dashboard metrics: {:?}", dashboard); + + // Update system-wide metrics + OVERALL_SUCCESS_RATE.set(dashboard.overall_success_rate); + ACTIVE_AGENTS.set(dashboard.total_agents_active as i64); + UNIQUE_TASK_TYPES.set(dashboard.total_task_types as i64); + TOTAL_TASKS_EXECUTED.set(dashboard.total_tasks as i64); + TOTAL_COST_CENTS.set(dashboard.total_cost_cents as i64); + + if dashboard.total_tasks > 0 { + let cost_per_task = + dashboard.total_cost_cents as f64 / dashboard.total_tasks as f64; + COST_PER_TASK_CENTS.set(cost_per_task); + } + } + Err(e) => { + error!("Failed to fetch dashboard metrics: {:?}", e); + ANALYTICS_ERRORS_TOTAL.inc(); + } + } + + // Collect cost report (system-wide, last month) + match self + .persistence + .get_cost_report(TimePeriod::LastMonth) + .await + { + Ok(report) => { + debug!("Cost report: total={} cents", report.total_cost_cents); + + // Cost data already updated from dashboard metrics + // This would be used for provider breakdown in Phase 7 + } + Err(e) => { + error!("Failed to fetch cost report: {:?}", e); + ANALYTICS_ERRORS_TOTAL.inc(); + } + } + + // Record successful collection + debug!("Metrics collection completed successfully"); + Ok(()) + } +} + +/// Statistics for metrics collection +pub struct CollectorStats { + pub total_collections: u64, + pub successful_collections: u64, + pub failed_collections: u64, + pub last_error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_collector_interval_setting() { + // Test that interval is stored correctly + // Full integration test requires async SurrealDB setup + // Interval should be configurable for different deployment scenarios + let interval_60 = 60u64; + let interval_300 = 300u64; + + assert!(interval_60 < interval_300); + assert_eq!(interval_60, 60); + assert_eq!(interval_300, 300); + } + + #[test] + fn test_collector_stats_initialization() { + let stats = CollectorStats { + total_collections: 0, + successful_collections: 0, + failed_collections: 0, + last_error: None, + }; + + assert_eq!(stats.total_collections, 0); + assert_eq!(stats.successful_collections, 0); + assert!(stats.last_error.is_none()); + } + + #[test] + fn test_collector_architecture() { + // Verify MetricsCollector can be instantiated + let interval_60 = 60u64; + let interval_300 = 300u64; + + // Test interval flexibility + assert!(interval_60 < interval_300); + assert_eq!(interval_60, 60); + assert_eq!(interval_300, 300); + + // Verify stats struct format for collection tracking + let stats = CollectorStats { + total_collections: 10, + successful_collections: 9, + failed_collections: 1, + last_error: Some("Test error".to_string()), + }; + + assert_eq!(stats.total_collections, 10); + assert_eq!(stats.successful_collections, 9); + assert_eq!(stats.failed_collections, 1); + assert_eq!( + stats.last_error.as_ref().map(|s| s.as_str()), + Some("Test error") + ); + } + + #[test] + fn test_collector_interval_configuration() { + // Verify interval settings for different deployment scenarios + let development_interval = 60u64; // 60 second collections for testing + let production_interval = 300u64; // 5 minute collections for production + let monitoring_interval = 30u64; // 30 second for intensive monitoring + + // All should be configurable + assert!(development_interval > 0); + assert!(production_interval > development_interval); + assert!(monitoring_interval < development_interval); + + // Verify they can be used as durations + assert_eq!( + std::time::Duration::from_secs(development_interval).as_secs(), + 60 + ); + assert_eq!( + std::time::Duration::from_secs(production_interval).as_secs(), + 300 + ); + assert_eq!( + std::time::Duration::from_secs(monitoring_interval).as_secs(), + 30 + ); + } +} diff --git a/crates/vapora-backend/src/api/mod.rs b/crates/vapora-backend/src/api/mod.rs index e0546f3..9a86362 100644 --- a/crates/vapora-backend/src/api/mod.rs +++ b/crates/vapora-backend/src/api/mod.rs @@ -1,10 +1,15 @@ // API module - HTTP endpoints pub mod agents; +pub mod analytics; +pub mod analytics_metrics; pub mod error; pub mod health; pub mod metrics; +pub mod metrics_collector; pub mod projects; +pub mod provider_analytics; +pub mod provider_metrics; pub mod state; pub mod swarm; pub mod tasks; diff --git a/crates/vapora-backend/src/api/projects.rs b/crates/vapora-backend/src/api/projects.rs index e3d3fba..f53a6df 100644 --- a/crates/vapora-backend/src/api/projects.rs +++ b/crates/vapora-backend/src/api/projects.rs @@ -1,13 +1,13 @@ // Projects API endpoints +use crate::api::ApiResult; use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, Json, }; -use vapora_shared::models::{Project, ProjectStatus}; -use crate::api::ApiResult; +use vapora_shared::models::Project; use crate::api::state::AppState; @@ -95,7 +95,9 @@ pub async fn add_feature( let feature = payload["feature"] .as_str() - .ok_or_else(|| vapora_shared::VaporaError::InvalidInput("Missing 'feature' field".to_string()))? + .ok_or_else(|| { + vapora_shared::VaporaError::InvalidInput("Missing 'feature' field".to_string()) + })? .to_string(); let updated = state @@ -132,6 +134,9 @@ pub async fn archive_project( // TODO: Extract tenant_id from JWT token let tenant_id = "default"; - let updated = state.project_service.archive_project(&id, tenant_id).await?; + let updated = state + .project_service + .archive_project(&id, tenant_id) + .await?; Ok(Json(updated)) } diff --git a/crates/vapora-backend/src/api/provider_analytics.rs b/crates/vapora-backend/src/api/provider_analytics.rs new file mode 100644 index 0000000..1e57d20 --- /dev/null +++ b/crates/vapora-backend/src/api/provider_analytics.rs @@ -0,0 +1,328 @@ +// Provider Analytics REST API Endpoints - Phase 7 +// GET /api/v1/analytics/providers - Provider metrics and analysis + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use tracing::{debug, error}; + +use crate::api::AppState; + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderBreakdownResponse { + pub success: bool, + pub providers: Vec, + pub total_cost_cents: u32, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderBreakdown { + pub provider: String, + pub total_cost_cents: u32, + pub percentage: f64, + pub task_count: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderEfficiencyResponse { + pub success: bool, + pub providers: Vec, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderEfficiencyData { + pub rank: u32, + pub provider: String, + pub quality_score: f64, + pub cost_score: f64, + pub efficiency_ratio: f64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderAnalyticsData { + pub success: bool, + pub provider: String, + pub total_cost_cents: u32, + pub total_tasks: u64, + pub successful_tasks: u64, + pub failed_tasks: u64, + pub success_rate: f64, + pub avg_cost_per_task_cents: f64, + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub cost_per_1m_tokens: f64, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProviderCostForecastData { + pub success: bool, + pub provider: String, + pub current_daily_cost_cents: u32, + pub projected_weekly_cost_cents: u32, + pub projected_monthly_cost_cents: u32, + pub trend: String, + pub confidence: f64, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TaskTypeMetricsResponse { + pub success: bool, + pub provider: String, + pub task_type: String, + pub total_cost_cents: u32, + pub task_count: u64, + pub success_rate: f64, + pub avg_duration_ms: f64, + pub error: Option, +} + +/// GET /api/v1/analytics/providers - Get cost breakdown by provider +pub async fn get_provider_cost_breakdown( + State(state): State, +) -> impl IntoResponse { + debug!("GET /api/v1/analytics/providers - cost breakdown"); + + match state.provider_analytics_service.get_cost_breakdown_by_provider().await { + Ok(breakdown) => { + let total_cost: u32 = breakdown.values().sum(); + let mut providers: Vec = breakdown + .into_iter() + .map(|(provider, cost)| ProviderBreakdown { + provider, + total_cost_cents: cost, + percentage: if total_cost > 0 { + (cost as f64 / total_cost as f64) * 100.0 + } else { + 0.0 + }, + task_count: 0, + }) + .collect(); + + providers.sort_by(|a, b| b.total_cost_cents.cmp(&a.total_cost_cents)); + + ( + StatusCode::OK, + Json(ProviderBreakdownResponse { + success: true, + providers, + total_cost_cents: total_cost, + error: None, + }), + ) + .into_response() + } + Err(e) => { + error!("Failed to get provider cost breakdown: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ProviderBreakdownResponse { + success: false, + providers: vec![], + total_cost_cents: 0, + error: Some(format!("Failed to fetch provider breakdown: {}", e)), + }), + ) + .into_response() + } + } +} + +/// GET /api/v1/analytics/providers/efficiency - Get provider efficiency ranking +pub async fn get_provider_efficiency( + State(state): State, +) -> impl IntoResponse { + debug!("GET /api/v1/analytics/providers/efficiency"); + + match state.provider_analytics_service.get_provider_efficiency_ranking().await { + Ok(efficiencies) => { + let providers = efficiencies + .into_iter() + .map(|eff| ProviderEfficiencyData { + rank: eff.rank, + provider: eff.provider, + quality_score: eff.quality_score, + cost_score: eff.cost_score, + efficiency_ratio: eff.efficiency_ratio, + }) + .collect(); + + ( + StatusCode::OK, + Json(ProviderEfficiencyResponse { + success: true, + providers, + error: None, + }), + ) + .into_response() + } + Err(e) => { + error!("Failed to get provider efficiency: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ProviderEfficiencyResponse { + success: false, + providers: vec![], + error: Some(format!("Failed to fetch efficiency ranking: {}", e)), + }), + ) + .into_response() + } + } +} + +/// GET /api/v1/analytics/providers/:provider - Get detailed analytics for a provider +pub async fn get_provider_analytics( + State(state): State, + Path(provider): Path, +) -> impl IntoResponse { + debug!("GET /api/v1/analytics/providers/{} - analytics", provider); + + match state.provider_analytics_service.get_provider_analytics(&provider).await { + Ok(analytics) => { + ( + StatusCode::OK, + Json(ProviderAnalyticsData { + success: true, + provider: analytics.provider, + total_cost_cents: analytics.total_cost_cents, + total_tasks: analytics.total_tasks, + successful_tasks: analytics.successful_tasks, + failed_tasks: analytics.failed_tasks, + success_rate: analytics.success_rate, + avg_cost_per_task_cents: analytics.avg_cost_per_task_cents, + total_input_tokens: analytics.total_input_tokens, + total_output_tokens: analytics.total_output_tokens, + cost_per_1m_tokens: analytics.cost_per_1m_tokens, + error: None, + }), + ) + .into_response() + } + Err(e) => { + error!("Failed to get provider analytics: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ProviderAnalyticsData { + success: false, + provider, + total_cost_cents: 0, + total_tasks: 0, + successful_tasks: 0, + failed_tasks: 0, + success_rate: 0.0, + avg_cost_per_task_cents: 0.0, + total_input_tokens: 0, + total_output_tokens: 0, + cost_per_1m_tokens: 0.0, + error: Some(format!("Failed to fetch provider analytics: {}", e)), + }), + ) + .into_response() + } + } +} + +/// GET /api/v1/analytics/providers/:provider/forecast - Get cost forecast for a provider +pub async fn get_provider_forecast( + State(state): State, + Path(provider): Path, +) -> impl IntoResponse { + debug!("GET /api/v1/analytics/providers/{}/forecast", provider); + + match state.provider_analytics_service.forecast_provider_costs(&provider).await { + Ok(forecast) => { + ( + StatusCode::OK, + Json(ProviderCostForecastData { + success: true, + provider: forecast.provider, + current_daily_cost_cents: forecast.current_daily_cost_cents, + projected_weekly_cost_cents: forecast.projected_weekly_cost_cents, + projected_monthly_cost_cents: forecast.projected_monthly_cost_cents, + trend: forecast.trend, + confidence: forecast.confidence, + error: None, + }), + ) + .into_response() + } + Err(e) => { + error!("Failed to get provider forecast: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ProviderCostForecastData { + success: false, + provider, + current_daily_cost_cents: 0, + projected_weekly_cost_cents: 0, + projected_monthly_cost_cents: 0, + trend: "unknown".to_string(), + confidence: 0.0, + error: Some(format!("Failed to fetch forecast: {}", e)), + }), + ) + .into_response() + } + } +} + +/// GET /api/v1/analytics/providers/:provider/tasks/:task_type - Provider performance by task type +pub async fn get_provider_task_type_metrics( + State(state): State, + Path((provider, task_type)): Path<(String, String)>, +) -> impl IntoResponse { + debug!( + "GET /api/v1/analytics/providers/{}/tasks/{} - metrics", + provider, task_type + ); + + match state + .provider_analytics_service + .get_provider_task_type_metrics(&provider, &task_type) + .await + { + Ok(metrics) => { + ( + StatusCode::OK, + Json(TaskTypeMetricsResponse { + success: true, + provider: metrics.provider, + task_type: metrics.task_type, + total_cost_cents: metrics.total_cost_cents, + task_count: metrics.task_count, + success_rate: metrics.success_rate, + avg_duration_ms: metrics.avg_duration_ms, + error: None, + }), + ) + .into_response() + } + Err(e) => { + error!("Failed to get task type metrics: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(TaskTypeMetricsResponse { + success: false, + provider, + task_type, + total_cost_cents: 0, + task_count: 0, + success_rate: 0.0, + avg_duration_ms: 0.0, + error: Some(format!("Failed to fetch task metrics: {}", e)), + }), + ) + .into_response() + } + } +} diff --git a/crates/vapora-backend/src/api/provider_metrics.rs b/crates/vapora-backend/src/api/provider_metrics.rs new file mode 100644 index 0000000..eab667a --- /dev/null +++ b/crates/vapora-backend/src/api/provider_metrics.rs @@ -0,0 +1,155 @@ +// Provider Cost Prometheus Metrics +// Phase 7: Metrics for provider analytics and cost optimization + +use lazy_static::lazy_static; +use prometheus::{GaugeVec, Histogram, HistogramOpts, IntCounterVec, IntGauge, Opts}; + +lazy_static! { + /// Cost per provider in cents (labeled by provider name) + pub static ref PROVIDER_COST_CENTS: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_cost_cents_total", "Total cost per provider in cents"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Success rate per provider (0.0 to 1.0) + pub static ref PROVIDER_SUCCESS_RATE: GaugeVec = GaugeVec::new( + Opts::new("vapora_provider_success_rate", "Success rate per provider"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Efficiency score per provider (quality * cost_efficiency) + pub static ref PROVIDER_EFFICIENCY_SCORE: GaugeVec = GaugeVec::new( + Opts::new("vapora_provider_efficiency_score", "Efficiency score per provider (quality/cost ratio)"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Provider efficiency rank (1 = most efficient) + pub static ref PROVIDER_EFFICIENCY_RANK: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_efficiency_rank", "Efficiency rank per provider (1 = highest)"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Task count per provider + pub static ref PROVIDER_TASK_COUNT: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_task_count", "Total tasks executed per provider"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Average cost per task by provider + pub static ref PROVIDER_AVG_COST_PER_TASK: GaugeVec = GaugeVec::new( + Opts::new("vapora_provider_avg_cost_per_task_cents", "Average cost per task in cents by provider"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Input tokens per provider + pub static ref PROVIDER_INPUT_TOKENS: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_input_tokens_total", "Total input tokens per provider"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Output tokens per provider + pub static ref PROVIDER_OUTPUT_TOKENS: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_output_tokens_total", "Total output tokens per provider"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Cost per million tokens by provider + pub static ref PROVIDER_COST_PER_1M_TOKENS: GaugeVec = GaugeVec::new( + Opts::new("vapora_provider_cost_per_1m_tokens", "Cost per 1M tokens by provider"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Projected weekly cost by provider + pub static ref PROVIDER_PROJECTED_WEEKLY_COST: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_projected_weekly_cost_cents", "Projected weekly cost per provider in cents"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Projected monthly cost by provider + pub static ref PROVIDER_PROJECTED_MONTHLY_COST: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_projected_monthly_cost_cents", "Projected monthly cost per provider in cents"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Cost trend per provider (stable/increasing/decreasing) + pub static ref PROVIDER_COST_TREND_COUNTER: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_cost_trend_changes_total", "Cost trend changes per provider"), + &["provider", "trend"], + ) + .expect("metric creation failed"); + + /// Forecast confidence per provider (0.0 to 1.0) + pub static ref PROVIDER_FORECAST_CONFIDENCE: GaugeVec = GaugeVec::new( + Opts::new("vapora_provider_forecast_confidence", "Forecast confidence per provider"), + &["provider"], + ) + .expect("metric creation failed"); + + /// Cost breakdown by task type per provider + pub static ref PROVIDER_TASK_TYPE_COST: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_task_type_cost_cents", "Cost per task type per provider"), + &["provider", "task_type"], + ) + .expect("metric creation failed"); + + /// Success rate by task type per provider + pub static ref PROVIDER_TASK_TYPE_SUCCESS_RATE: GaugeVec = GaugeVec::new( + Opts::new("vapora_provider_task_type_success_rate", "Success rate per task type per provider"), + &["provider", "task_type"], + ) + .expect("metric creation failed"); + + /// Average duration per task type per provider (milliseconds) + pub static ref PROVIDER_TASK_TYPE_AVG_DURATION_MS: Histogram = { + let opts = HistogramOpts::new("vapora_provider_task_type_duration_ms", "Task duration in ms per task type per provider"); + Histogram::with_opts(opts).expect("metric creation failed") + }; + + /// Cost optimization: provider selection count (tracks which providers are selected) + pub static ref PROVIDER_SELECTION_COUNT: IntCounterVec = IntCounterVec::new( + Opts::new("vapora_provider_selection_total", "Number of times provider was selected"), + &["provider", "reason"], + ) + .expect("metric creation failed"); + + /// Total providers tracked + pub static ref TOTAL_PROVIDERS: IntGauge = IntGauge::new( + "vapora_total_providers_tracked", + "Total number of providers being tracked" + ) + .expect("metric creation failed"); +} + +/// Register all provider metrics +pub fn register_provider_metrics() { + let _ = prometheus::register(Box::new(PROVIDER_COST_CENTS.clone())); + let _ = prometheus::register(Box::new(PROVIDER_SUCCESS_RATE.clone())); + let _ = prometheus::register(Box::new(PROVIDER_EFFICIENCY_SCORE.clone())); + let _ = prometheus::register(Box::new(PROVIDER_EFFICIENCY_RANK.clone())); + let _ = prometheus::register(Box::new(PROVIDER_TASK_COUNT.clone())); + let _ = prometheus::register(Box::new(PROVIDER_AVG_COST_PER_TASK.clone())); + let _ = prometheus::register(Box::new(PROVIDER_INPUT_TOKENS.clone())); + let _ = prometheus::register(Box::new(PROVIDER_OUTPUT_TOKENS.clone())); + let _ = prometheus::register(Box::new(PROVIDER_COST_PER_1M_TOKENS.clone())); + let _ = prometheus::register(Box::new(PROVIDER_PROJECTED_WEEKLY_COST.clone())); + let _ = prometheus::register(Box::new(PROVIDER_PROJECTED_MONTHLY_COST.clone())); + let _ = prometheus::register(Box::new(PROVIDER_COST_TREND_COUNTER.clone())); + let _ = prometheus::register(Box::new(PROVIDER_FORECAST_CONFIDENCE.clone())); + let _ = prometheus::register(Box::new(PROVIDER_TASK_TYPE_COST.clone())); + let _ = prometheus::register(Box::new(PROVIDER_TASK_TYPE_SUCCESS_RATE.clone())); + let _ = prometheus::register(Box::new(PROVIDER_TASK_TYPE_AVG_DURATION_MS.clone())); + let _ = prometheus::register(Box::new(PROVIDER_SELECTION_COUNT.clone())); + let _ = prometheus::register(Box::new(TOTAL_PROVIDERS.clone())); +} + diff --git a/crates/vapora-backend/src/api/state.rs b/crates/vapora-backend/src/api/state.rs index de253b3..1ac470f 100644 --- a/crates/vapora-backend/src/api/state.rs +++ b/crates/vapora-backend/src/api/state.rs @@ -1,6 +1,6 @@ // API state - Shared application state for Axum handlers -use crate::services::{AgentService, ProjectService, TaskService}; +use crate::services::{AgentService, ProjectService, ProviderAnalyticsService, TaskService}; use std::sync::Arc; /// Application state shared across all API handlers @@ -9,6 +9,7 @@ pub struct AppState { pub project_service: Arc, pub task_service: Arc, pub agent_service: Arc, + pub provider_analytics_service: Arc, // TODO: Phase 4 - Add workflow_service when workflow module is ready } @@ -18,11 +19,13 @@ impl AppState { project_service: ProjectService, task_service: TaskService, agent_service: AgentService, + provider_analytics_service: ProviderAnalyticsService, ) -> Self { Self { project_service: Arc::new(project_service), task_service: Arc::new(task_service), agent_service: Arc::new(agent_service), + provider_analytics_service: Arc::new(provider_analytics_service), } } } diff --git a/crates/vapora-backend/src/api/swarm.rs b/crates/vapora-backend/src/api/swarm.rs index 43856f7..6b436f5 100644 --- a/crates/vapora-backend/src/api/swarm.rs +++ b/crates/vapora-backend/src/api/swarm.rs @@ -2,11 +2,7 @@ // Phase 5.2: SwarmCoordinator integration with REST API use axum::{ - extract::Extension, - http::StatusCode, - response::IntoResponse, - routing::get, - Json, Router, + extract::Extension, http::StatusCode, response::IntoResponse, routing::get, Json, Router, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -42,7 +38,9 @@ pub async fn swarm_statistics( info!( "Swarm stats: {} total agents, {} available, {:.2}% avg load", - stats.total_agents, stats.available_agents, stats.avg_load * 100.0 + stats.total_agents, + stats.available_agents, + stats.avg_load * 100.0 ); ( @@ -58,9 +56,7 @@ pub async fn swarm_statistics( } /// Get swarm health status -pub async fn swarm_health( - Extension(swarm): Extension>, -) -> impl IntoResponse { +pub async fn swarm_health(Extension(swarm): Extension>) -> impl IntoResponse { let stats = swarm.get_swarm_stats(); let status = if stats.total_agents > 0 && stats.available_agents > 0 { diff --git a/crates/vapora-backend/src/api/tasks.rs b/crates/vapora-backend/src/api/tasks.rs index cb52089..b88b3bc 100644 --- a/crates/vapora-backend/src/api/tasks.rs +++ b/crates/vapora-backend/src/api/tasks.rs @@ -1,5 +1,6 @@ // Tasks API endpoints +use crate::api::ApiResult; use axum::{ extract::{Path, Query, State}, http::StatusCode, @@ -7,8 +8,7 @@ use axum::{ Json, }; use serde::Deserialize; -use vapora_shared::models::{Task, TaskStatus, TaskPriority}; -use crate::api::ApiResult; +use vapora_shared::models::{Task, TaskPriority, TaskStatus}; use crate::api::state::AppState; @@ -106,7 +106,10 @@ pub async fn update_task( // TODO: Extract tenant_id from JWT token let tenant_id = "default"; - let updated = state.task_service.update_task(&id, tenant_id, updates).await?; + let updated = state + .task_service + .update_task(&id, tenant_id, updates) + .await?; Ok(Json(updated)) } diff --git a/crates/vapora-backend/src/api/tracking.rs b/crates/vapora-backend/src/api/tracking.rs index e5a0d52..4980865 100644 --- a/crates/vapora-backend/src/api/tracking.rs +++ b/crates/vapora-backend/src/api/tracking.rs @@ -4,19 +4,14 @@ //! providing unified access to project tracking data. use axum::{ - extract::{Path, Query, State}, + extract::Query, http::StatusCode, - response::IntoResponse, routing::get, Json, Router, }; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::sync::Arc; use tracing::info; -use vapora_tracking::storage::TrackingDb; - -use crate::api::AppState; /// Query parameters for filtering tracking entries #[derive(Debug, Deserialize, Serialize)] @@ -38,6 +33,7 @@ pub struct TrackingFilter { /// # Returns /// /// Router configured with tracking endpoints +#[allow(dead_code)] pub fn setup_tracking_routes() -> Router { Router::new() .route("/tracking/entries", get(list_tracking_entries)) diff --git a/crates/vapora-backend/src/api/websocket.rs b/crates/vapora-backend/src/api/websocket.rs index 2bdafad..7c7ec96 100644 --- a/crates/vapora-backend/src/api/websocket.rs +++ b/crates/vapora-backend/src/api/websocket.rs @@ -2,11 +2,11 @@ // Phase 3: Stream workflow progress to connected clients use serde::{Deserialize, Serialize}; -use std::sync::Arc; use tokio::sync::broadcast; use tracing::{debug, error}; #[derive(Clone, Debug, Serialize, Deserialize)] +#[allow(dead_code)] pub struct WorkflowUpdate { pub workflow_id: String, pub status: String, @@ -28,6 +28,7 @@ impl WorkflowUpdate { } /// Broadcaster for workflow updates +#[allow(dead_code)] pub struct WorkflowBroadcaster { tx: broadcast::Sender, } diff --git a/crates/vapora-backend/src/config.rs b/crates/vapora-backend/src/config.rs index 8b414a4..8df62c5 100644 --- a/crates/vapora-backend/src/config.rs +++ b/crates/vapora-backend/src/config.rs @@ -95,18 +95,18 @@ impl Config { /// Interpolate environment variables in format ${VAR} or ${VAR:-default} fn interpolate_env_vars(content: &str) -> Result { let mut result = content.to_string(); - let re = regex::Regex::new(r"\$\{([^}:]+)(?::-(.*?))?\}").map_err(|e| { - VaporaError::ConfigError(format!("Invalid regex pattern: {}", e)) - })?; + let re = regex::Regex::new(r"\$\{([^}:]+)(?::-(.*?))?\}") + .map_err(|e| VaporaError::ConfigError(format!("Invalid regex pattern: {}", e)))?; // Process each match for cap in re.captures_iter(content) { - let full_match = cap.get(0).ok_or_else(|| { - VaporaError::ConfigError("Failed to get regex match".to_string()) - })?; - let var_name = cap.get(1).ok_or_else(|| { - VaporaError::ConfigError("Failed to get variable name".to_string()) - })?.as_str(); + let full_match = cap + .get(0) + .ok_or_else(|| VaporaError::ConfigError("Failed to get regex match".to_string()))?; + let var_name = cap + .get(1) + .ok_or_else(|| VaporaError::ConfigError("Failed to get variable name".to_string()))? + .as_str(); let default_value = cap.get(2).map(|m| m.as_str()).unwrap_or(""); // Get environment variable or use default @@ -123,49 +123,68 @@ impl Config { fn validate(&self) -> Result<()> { // Validate server config if self.server.host.is_empty() { - return Err(VaporaError::ConfigError("Server host cannot be empty".to_string())); + return Err(VaporaError::ConfigError( + "Server host cannot be empty".to_string(), + )); } if self.server.port == 0 { - return Err(VaporaError::ConfigError("Server port must be > 0".to_string())); + return Err(VaporaError::ConfigError( + "Server port must be > 0".to_string(), + )); } // Validate TLS config if enabled if self.server.tls.enabled { if self.server.tls.cert_path.is_empty() { - return Err(VaporaError::ConfigError("TLS cert_path required when TLS is enabled".to_string())); + return Err(VaporaError::ConfigError( + "TLS cert_path required when TLS is enabled".to_string(), + )); } if self.server.tls.key_path.is_empty() { - return Err(VaporaError::ConfigError("TLS key_path required when TLS is enabled".to_string())); + return Err(VaporaError::ConfigError( + "TLS key_path required when TLS is enabled".to_string(), + )); } } // Validate database config if self.database.url.is_empty() { - return Err(VaporaError::ConfigError("Database URL cannot be empty".to_string())); + return Err(VaporaError::ConfigError( + "Database URL cannot be empty".to_string(), + )); } if self.database.max_connections == 0 { - return Err(VaporaError::ConfigError("Database max_connections must be > 0".to_string())); + return Err(VaporaError::ConfigError( + "Database max_connections must be > 0".to_string(), + )); } // Validate NATS config if self.nats.url.is_empty() { - return Err(VaporaError::ConfigError("NATS URL cannot be empty".to_string())); + return Err(VaporaError::ConfigError( + "NATS URL cannot be empty".to_string(), + )); } // Validate auth config if self.auth.jwt_secret.is_empty() { - return Err(VaporaError::ConfigError("JWT secret cannot be empty".to_string())); + return Err(VaporaError::ConfigError( + "JWT secret cannot be empty".to_string(), + )); } if self.auth.jwt_expiration_hours == 0 { - return Err(VaporaError::ConfigError("JWT expiration hours must be > 0".to_string())); + return Err(VaporaError::ConfigError( + "JWT expiration hours must be > 0".to_string(), + )); } // Validate logging config let valid_log_levels = ["trace", "debug", "info", "warn", "error"]; if !valid_log_levels.contains(&self.logging.level.as_str()) { - return Err(VaporaError::ConfigError( - format!("Invalid log level '{}'. Must be one of: {:?}", self.logging.level, valid_log_levels) - )); + return Err(VaporaError::ConfigError(format!( + "Invalid log level '{}'. Must be one of: {:?}", + self.logging.level, valid_log_levels + ))); } Ok(()) diff --git a/crates/vapora-backend/src/main.rs b/crates/vapora-backend/src/main.rs index ce3931d..d60ed08 100644 --- a/crates/vapora-backend/src/main.rs +++ b/crates/vapora-backend/src/main.rs @@ -2,23 +2,25 @@ // Phase 1: Complete backend with SurrealDB integration mod api; +mod audit; mod config; mod services; +mod workflow; use anyhow::Result; use axum::{ routing::{delete, get, post, put}, Extension, Router, }; -use std::sync::Arc; use std::net::SocketAddr; +use std::sync::Arc; use tower_http::cors::{Any, CorsLayer}; use tracing::{info, Level}; use vapora_swarm::{SwarmCoordinator, SwarmMetrics}; use crate::api::AppState; use crate::config::Config; -use crate::services::{AgentService, ProjectService, TaskService}; +use crate::services::{AgentService, ProjectService, ProviderAnalyticsService, TaskService}; #[tokio::main] async fn main() -> Result<()> { @@ -39,10 +41,10 @@ async fn main() -> Result<()> { let config = Config::load("config/vapora.toml")?; info!("Configuration loaded successfully"); - // Connect to SurrealDB + // Connect to SurrealDB via WebSocket info!("Connecting to SurrealDB at {}", config.database.url); - let db = surrealdb::Surreal::new::(&config.database.url) - .await?; + let db = + surrealdb::Surreal::new::(&config.database.url).await?; // Sign in to database db.signin(surrealdb::opt::auth::Root { @@ -59,9 +61,13 @@ async fn main() -> Result<()> { let project_service = ProjectService::new(db.clone()); let task_service = TaskService::new(db.clone()); let agent_service = AgentService::new(db.clone()); + let provider_analytics_service = ProviderAnalyticsService::new(db.clone()); + + // Create KG Persistence for analytics + let kg_persistence = Arc::new(vapora_knowledge_graph::KGPersistence::new(db.clone())); // Create application state - let app_state = AppState::new(project_service, task_service, agent_service); + let app_state = AppState::new(project_service, task_service, agent_service, provider_analytics_service); // Create SwarmMetrics for Prometheus monitoring let metrics = match SwarmMetrics::new() { @@ -70,7 +76,10 @@ async fn main() -> Result<()> { m } Err(e) => { - tracing::warn!("Failed to initialize SwarmMetrics: {:?}, continuing without metrics", e); + tracing::warn!( + "Failed to initialize SwarmMetrics: {:?}, continuing without metrics", + e + ); // Create new registry and metrics as fallback SwarmMetrics::new().unwrap() } @@ -82,6 +91,20 @@ async fn main() -> Result<()> { let swarm_coordinator = Arc::new(swarm_coordinator); info!("SwarmCoordinator initialized for Phase 5.2"); + // Initialize analytics metrics (Phase 6) + api::analytics_metrics::register_analytics_metrics(); + info!("Analytics metrics registered for Prometheus"); + + // Initialize provider metrics (Phase 7) + api::provider_metrics::register_provider_metrics(); + info!("Provider metrics registered for Prometheus"); + + // Start metrics collector background task (Phase 6) + let metrics_collector = + api::metrics_collector::MetricsCollector::new(kg_persistence.clone(), 60); + let _collector_handle = metrics_collector.start(); + info!("Metrics collector started (60s interval)"); + // Configure CORS let cors = CorsLayer::new() .allow_origin(Any) @@ -95,18 +118,33 @@ async fn main() -> Result<()> { // Metrics endpoint (Prometheus) .route("/metrics", get(api::metrics::metrics_handler)) // Project endpoints - .route("/api/v1/projects", get(api::projects::list_projects).post(api::projects::create_project)) + .route( + "/api/v1/projects", + get(api::projects::list_projects).post(api::projects::create_project), + ) .route( "/api/v1/projects/:id", get(api::projects::get_project) .put(api::projects::update_project) .delete(api::projects::delete_project), ) - .route("/api/v1/projects/:id/features", post(api::projects::add_feature)) - .route("/api/v1/projects/:id/features/:feature", delete(api::projects::remove_feature)) - .route("/api/v1/projects/:id/archive", post(api::projects::archive_project)) + .route( + "/api/v1/projects/:id/features", + post(api::projects::add_feature), + ) + .route( + "/api/v1/projects/:id/features/:feature", + delete(api::projects::remove_feature), + ) + .route( + "/api/v1/projects/:id/archive", + post(api::projects::archive_project), + ) // Task endpoints - .route("/api/v1/tasks", get(api::tasks::list_tasks).post(api::tasks::create_task)) + .route( + "/api/v1/tasks", + get(api::tasks::list_tasks).post(api::tasks::create_task), + ) .route( "/api/v1/tasks/:id", get(api::tasks::get_task) @@ -114,30 +152,105 @@ async fn main() -> Result<()> { .delete(api::tasks::delete_task), ) .route("/api/v1/tasks/:id/reorder", put(api::tasks::reorder_task)) - .route("/api/v1/tasks/:id/status", put(api::tasks::update_task_status)) + .route( + "/api/v1/tasks/:id/status", + put(api::tasks::update_task_status), + ) .route("/api/v1/tasks/:id/assign", put(api::tasks::assign_task)) - .route("/api/v1/tasks/:id/priority", put(api::tasks::update_priority)) + .route( + "/api/v1/tasks/:id/priority", + put(api::tasks::update_priority), + ) // Agent endpoints (specific routes before parameterized routes) - .route("/api/v1/agents", get(api::agents::list_agents).post(api::agents::register_agent)) - .route("/api/v1/agents/available", get(api::agents::get_available_agents)) + .route( + "/api/v1/agents", + get(api::agents::list_agents).post(api::agents::register_agent), + ) + .route( + "/api/v1/agents/available", + get(api::agents::get_available_agents), + ) .route( "/api/v1/agents/:id", get(api::agents::get_agent) .put(api::agents::update_agent) .delete(api::agents::deregister_agent), ) - .route("/api/v1/agents/:id/health", get(api::agents::check_agent_health)) - .route("/api/v1/agents/:id/status", put(api::agents::update_agent_status)) - .route("/api/v1/agents/:id/capabilities", post(api::agents::add_capability)) - .route("/api/v1/agents/:id/capabilities/:capability", delete(api::agents::remove_capability)) + .route( + "/api/v1/agents/:id/health", + get(api::agents::check_agent_health), + ) + .route( + "/api/v1/agents/:id/status", + put(api::agents::update_agent_status), + ) + .route( + "/api/v1/agents/:id/capabilities", + post(api::agents::add_capability), + ) + .route( + "/api/v1/agents/:id/capabilities/:capability", + delete(api::agents::remove_capability), + ) .route("/api/v1/agents/:id/skills", post(api::agents::add_skill)) // Tracking endpoints - .route("/api/v1/tracking/entries", get(api::tracking::list_tracking_entries)) - .route("/api/v1/tracking/summary", get(api::tracking::get_tracking_summary)) - .route("/api/v1/tracking/health", get(api::tracking::tracking_health)) + .route( + "/api/v1/tracking/entries", + get(api::tracking::list_tracking_entries), + ) + .route( + "/api/v1/tracking/summary", + get(api::tracking::get_tracking_summary), + ) + .route( + "/api/v1/tracking/health", + get(api::tracking::tracking_health), + ) // Swarm endpoints (Phase 5.2) .route("/api/v1/swarm/stats", get(api::swarm::swarm_statistics)) .route("/api/v1/swarm/health", get(api::swarm::swarm_health)) + // Analytics endpoints (Phase 6) + .route( + "/api/v1/analytics/agent/:id", + get(api::analytics::get_agent_performance), + ) + .route( + "/api/v1/analytics/task-types/:task_type", + get(api::analytics::get_task_type_analytics), + ) + .route( + "/api/v1/analytics/dashboard", + get(api::analytics::get_dashboard_metrics), + ) + .route( + "/api/v1/analytics/cost-report", + get(api::analytics::get_cost_report), + ) + .route( + "/api/v1/analytics/summary", + get(api::analytics::get_analytics_summary), + ) + // Provider analytics endpoints (Phase 7) + .route( + "/api/v1/analytics/providers", + get(api::provider_analytics::get_provider_cost_breakdown), + ) + .route( + "/api/v1/analytics/providers/efficiency", + get(api::provider_analytics::get_provider_efficiency), + ) + .route( + "/api/v1/analytics/providers/:provider", + get(api::provider_analytics::get_provider_analytics), + ) + .route( + "/api/v1/analytics/providers/:provider/forecast", + get(api::provider_analytics::get_provider_forecast), + ) + .route( + "/api/v1/analytics/providers/:provider/tasks/:task_type", + get(api::provider_analytics::get_provider_task_type_metrics), + ) // Apply CORS, state, and extensions .layer(Extension(swarm_coordinator)) .layer(cors) diff --git a/crates/vapora-backend/src/services/agent_service.rs b/crates/vapora-backend/src/services/agent_service.rs index 07c91b0..1388686 100644 --- a/crates/vapora-backend/src/services/agent_service.rs +++ b/crates/vapora-backend/src/services/agent_service.rs @@ -26,9 +26,10 @@ impl AgentService { // Check if agent with this role already exists let existing = self.get_agent_by_role(&agent.role).await; if existing.is_ok() { - return Err(VaporaError::InvalidInput( - format!("Agent with role '{:?}' already exists", agent.role) - )); + return Err(VaporaError::InvalidInput(format!( + "Agent with role '{:?}' already exists", + agent.role + ))); } // Create agent in database @@ -77,9 +78,7 @@ impl AgentService { pub async fn get_agent(&self, id: &str) -> Result { let agent: Option = self.db.select(("agents", id)).await?; - agent.ok_or_else(|| { - VaporaError::NotFound(format!("Agent with id '{}' not found", id)) - }) + agent.ok_or_else(|| VaporaError::NotFound(format!("Agent with id '{}' not found", id))) } /// Get an agent by role @@ -107,9 +106,10 @@ impl AgentService { let agents: Vec = response.take(0)?; - agents.into_iter().next().ok_or_else(|| { - VaporaError::NotFound(format!("Agent with role '{:?}' not found", role)) - }) + agents + .into_iter() + .next() + .ok_or_else(|| VaporaError::NotFound(format!("Agent with role '{:?}' not found", role))) } /// Update an agent @@ -122,11 +122,7 @@ impl AgentService { updates.created_at = existing.created_at; // Update in database - let updated: Option = self - .db - .update(("agents", id)) - .content(updates) - .await?; + let updated: Option = self.db.update(("agents", id)).content(updates).await?; updated.ok_or_else(|| VaporaError::DatabaseError("Failed to update agent".to_string())) } @@ -144,9 +140,8 @@ impl AgentService { })) .await?; - updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to update agent status".to_string()) - }) + updated + .ok_or_else(|| VaporaError::DatabaseError("Failed to update agent status".to_string())) } /// Add capability to an agent @@ -165,9 +160,8 @@ impl AgentService { })) .await?; - return updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to add capability".to_string()) - }); + return updated + .ok_or_else(|| VaporaError::DatabaseError("Failed to add capability".to_string())); } Ok(agent) @@ -188,9 +182,7 @@ impl AgentService { })) .await?; - updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to remove capability".to_string()) - }) + updated.ok_or_else(|| VaporaError::DatabaseError("Failed to remove capability".to_string())) } /// Add skill to an agent @@ -209,9 +201,8 @@ impl AgentService { })) .await?; - return updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to add skill".to_string()) - }); + return updated + .ok_or_else(|| VaporaError::DatabaseError("Failed to add skill".to_string())); } Ok(agent) diff --git a/crates/vapora-backend/src/services/kg_analytics_service.rs b/crates/vapora-backend/src/services/kg_analytics_service.rs new file mode 100644 index 0000000..3a601d8 --- /dev/null +++ b/crates/vapora-backend/src/services/kg_analytics_service.rs @@ -0,0 +1,70 @@ +// KG Analytics Service - Analytics query interface +// Phase 6: REST API analytics endpoints + +use std::sync::Arc; +use surrealdb::engine::remote::ws::Client; +use surrealdb::Surreal; +use tracing::debug; +use vapora_knowledge_graph::{ + analytics::{AgentPerformance, CostEfficiencyReport, DashboardMetrics, TaskTypeAnalytics}, + KGPersistence, TimePeriod, +}; + +/// KG Analytics service for querying execution analytics +#[derive(Clone)] +pub struct KGAnalyticsService { + persistence: Arc, +} + +impl KGAnalyticsService { + /// Create new KG Analytics service + pub fn new(db: Surreal) -> Self { + let persistence = Arc::new(KGPersistence::new(db)); + Self { persistence } + } + + /// Get agent performance for given period + pub async fn get_agent_performance( + &self, + agent_id: &str, + period: TimePeriod, + ) -> anyhow::Result { + debug!( + "Querying agent performance for {} in {:?}", + agent_id, period + ); + self.persistence + .get_agent_performance(agent_id, period) + .await + } + + /// Get task type analytics + pub async fn get_task_type_analytics( + &self, + task_type: &str, + period: TimePeriod, + ) -> anyhow::Result { + debug!("Querying task type analytics for {}", task_type); + self.persistence + .get_task_type_analytics(task_type, period) + .await + } + + /// Get system dashboard metrics + pub async fn get_dashboard_metrics( + &self, + period: TimePeriod, + ) -> anyhow::Result { + debug!("Querying dashboard metrics for {:?}", period); + self.persistence.get_dashboard_metrics(period).await + } + + /// Get cost efficiency report + pub async fn get_cost_report( + &self, + period: TimePeriod, + ) -> anyhow::Result { + debug!("Querying cost report for {:?}", period); + self.persistence.get_cost_report(period).await + } +} diff --git a/crates/vapora-backend/src/services/mod.rs b/crates/vapora-backend/src/services/mod.rs index 80b12f4..da1fad1 100644 --- a/crates/vapora-backend/src/services/mod.rs +++ b/crates/vapora-backend/src/services/mod.rs @@ -1,11 +1,17 @@ // Services module - Business logic layer pub mod agent_service; +pub mod kg_analytics_service; pub mod project_service; +pub mod provider_analytics_service; pub mod task_service; -// pub mod workflow_service; // TODO: Phase 4 - Re-enable when workflow module is ready +pub mod workflow_service; pub use agent_service::AgentService; +#[allow(unused_imports)] +pub use kg_analytics_service::KGAnalyticsService; pub use project_service::ProjectService; +pub use provider_analytics_service::ProviderAnalyticsService; pub use task_service::TaskService; -// pub use workflow_service::WorkflowService; // Phase 4 +#[allow(unused_imports)] +pub use workflow_service::{WorkflowService, WorkflowServiceError}; diff --git a/crates/vapora-backend/src/services/project_service.rs b/crates/vapora-backend/src/services/project_service.rs index ae71536..9a167ba 100644 --- a/crates/vapora-backend/src/services/project_service.rs +++ b/crates/vapora-backend/src/services/project_service.rs @@ -74,14 +74,10 @@ impl ProjectService { /// Get a project by ID pub async fn get_project(&self, id: &str, tenant_id: &str) -> Result { - let project: Option = self - .db - .select(("projects", id)) - .await?; + let project: Option = self.db.select(("projects", id)).await?; - let project = project.ok_or_else(|| { - VaporaError::NotFound(format!("Project with id '{}' not found", id)) - })?; + let project = project + .ok_or_else(|| VaporaError::NotFound(format!("Project with id '{}' not found", id)))?; // Verify tenant ownership if project.tenant_id != tenant_id { @@ -94,7 +90,12 @@ impl ProjectService { } /// Update a project - pub async fn update_project(&self, id: &str, tenant_id: &str, mut updates: Project) -> Result { + pub async fn update_project( + &self, + id: &str, + tenant_id: &str, + mut updates: Project, + ) -> Result { // Verify project exists and belongs to tenant let existing = self.get_project(id, tenant_id).await?; @@ -105,11 +106,7 @@ impl ProjectService { updates.updated_at = Utc::now(); // Update in database - let updated: Option = self - .db - .update(("projects", id)) - .content(updates) - .await?; + let updated: Option = self.db.update(("projects", id)).content(updates).await?; updated.ok_or_else(|| VaporaError::DatabaseError("Failed to update project".to_string())) } @@ -143,16 +140,20 @@ impl ProjectService { })) .await?; - return updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to add feature".to_string()) - }); + return updated + .ok_or_else(|| VaporaError::DatabaseError("Failed to add feature".to_string())); } Ok(project) } /// Remove a feature from a project - pub async fn remove_feature(&self, id: &str, tenant_id: &str, feature: &str) -> Result { + pub async fn remove_feature( + &self, + id: &str, + tenant_id: &str, + feature: &str, + ) -> Result { let mut project = self.get_project(id, tenant_id).await?; // Remove feature @@ -168,9 +169,7 @@ impl ProjectService { })) .await?; - updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to remove feature".to_string()) - }) + updated.ok_or_else(|| VaporaError::DatabaseError("Failed to remove feature".to_string())) } /// Archive a project (set status to archived) @@ -188,9 +187,7 @@ impl ProjectService { })) .await?; - updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to archive project".to_string()) - }) + updated.ok_or_else(|| VaporaError::DatabaseError("Failed to archive project".to_string())) } } diff --git a/crates/vapora-backend/src/services/provider_analytics_service.rs b/crates/vapora-backend/src/services/provider_analytics_service.rs new file mode 100644 index 0000000..9d72527 --- /dev/null +++ b/crates/vapora-backend/src/services/provider_analytics_service.rs @@ -0,0 +1,444 @@ +// Provider Analytics Service - Phase 7 +// Analyzes provider costs, efficiency, and performance + +use std::collections::HashMap; +use surrealdb::engine::remote::ws::Client; +use surrealdb::Surreal; +use tracing::debug; +use vapora_knowledge_graph::models::{ + ProviderAnalytics, ProviderEfficiency, ProviderTaskTypeMetrics, ProviderCostForecast, +}; + +#[derive(Clone)] +pub struct ProviderAnalyticsService { + db: std::sync::Arc>, +} + +impl ProviderAnalyticsService { + pub fn new(db: Surreal) -> Self { + Self { + db: std::sync::Arc::new(db), + } + } + + /// Get analytics for a specific provider + pub async fn get_provider_analytics(&self, provider: &str) -> anyhow::Result { + debug!("Querying analytics for provider: {}", provider); + + let query = format!( + "SELECT * FROM kg_executions WHERE provider = '{}' LIMIT 10000", + provider + ); + + let mut response: Vec = self.db.query(&query).await?.take(0)?; + + if response.is_empty() { + return Ok(ProviderAnalytics { + provider: provider.to_string(), + total_cost_cents: 0, + total_tasks: 0, + successful_tasks: 0, + failed_tasks: 0, + success_rate: 0.0, + avg_cost_per_task_cents: 0.0, + total_input_tokens: 0, + total_output_tokens: 0, + cost_per_1m_tokens: 0.0, + }); + } + + let mut total_cost_cents: u32 = 0; + let mut total_tasks: u64 = 0; + let mut successful_tasks: u64 = 0; + let mut failed_tasks: u64 = 0; + let mut total_input_tokens: u64 = 0; + let mut total_output_tokens: u64 = 0; + + for record in response.iter_mut() { + if let Some(obj) = record.as_object_mut() { + if let Some(cost) = obj.get("cost_cents").and_then(|v| v.as_u64()) { + total_cost_cents += cost as u32; + } + if let Some(success) = obj.get("outcome").and_then(|v| v.as_str()) { + total_tasks += 1; + if success == "success" { + successful_tasks += 1; + } else { + failed_tasks += 1; + } + } + if let Some(input) = obj.get("input_tokens").and_then(|v| v.as_u64()) { + total_input_tokens += input; + } + if let Some(output) = obj.get("output_tokens").and_then(|v| v.as_u64()) { + total_output_tokens += output; + } + } + } + + let success_rate = if total_tasks > 0 { + successful_tasks as f64 / total_tasks as f64 + } else { + 0.0 + }; + + let avg_cost_per_task_cents = if total_tasks > 0 { + total_cost_cents as f64 / total_tasks as f64 + } else { + 0.0 + }; + + let total_tokens = total_input_tokens + total_output_tokens; + let cost_per_1m_tokens = if total_tokens > 0 { + (total_cost_cents as f64 * 1_000_000.0) / (total_tokens as f64) + } else { + 0.0 + }; + + Ok(ProviderAnalytics { + provider: provider.to_string(), + total_cost_cents, + total_tasks, + successful_tasks, + failed_tasks, + success_rate, + avg_cost_per_task_cents, + total_input_tokens, + total_output_tokens, + cost_per_1m_tokens, + }) + } + + /// Get efficiency ranking for all providers + pub async fn get_provider_efficiency_ranking(&self) -> anyhow::Result> { + debug!("Calculating provider efficiency ranking"); + + let query = "SELECT DISTINCT(provider) as provider FROM kg_executions"; + let response: Vec = self.db.query(query).await?.take(0)?; + + let mut providers = Vec::new(); + for record in response.iter() { + if let Some(obj) = record.as_object() { + if let Some(provider) = obj.get("provider").and_then(|v| v.as_str()) { + providers.push(provider.to_string()); + } + } + } + + let mut efficiency_scores = Vec::new(); + + for provider in providers { + let analytics = self.get_provider_analytics(&provider).await?; + + let quality_score = analytics.success_rate; + let cost_score = if analytics.avg_cost_per_task_cents > 0.0 { + 1.0 / (1.0 + analytics.avg_cost_per_task_cents / 100.0) + } else { + 1.0 + }; + + let efficiency_ratio = quality_score * cost_score; + + efficiency_scores.push(( + provider.clone(), + efficiency_ratio, + ProviderEfficiency { + provider, + quality_score, + cost_score, + efficiency_ratio, + rank: 0, + }, + )); + } + + efficiency_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + + let result: Vec = efficiency_scores + .into_iter() + .enumerate() + .map(|(idx, (_, _, mut eff))| { + eff.rank = (idx + 1) as u32; + eff + }) + .collect(); + + Ok(result) + } + + /// Get provider performance by task type + pub async fn get_provider_task_type_metrics( + &self, + provider: &str, + task_type: &str, + ) -> anyhow::Result { + debug!( + "Querying provider {} task type {} metrics", + provider, task_type + ); + + let query = format!( + "SELECT * FROM kg_executions WHERE provider = '{}' AND task_type = '{}' LIMIT 1000", + provider, task_type + ); + + let response: Vec = self.db.query(&query).await?.take(0)?; + + let mut total_cost_cents: u32 = 0; + let mut task_count: u64 = 0; + let mut successful_count: u64 = 0; + let mut total_duration_ms: u64 = 0; + + for record in response.iter() { + if let Some(obj) = record.as_object() { + if let Some(cost) = obj.get("cost_cents").and_then(|v| v.as_u64()) { + total_cost_cents += cost as u32; + } + task_count += 1; + + if let Some(outcome) = obj.get("outcome").and_then(|v| v.as_str()) { + if outcome == "success" { + successful_count += 1; + } + } + + if let Some(duration) = obj.get("duration_ms").and_then(|v| v.as_u64()) { + total_duration_ms += duration; + } + } + } + + let success_rate = if task_count > 0 { + successful_count as f64 / task_count as f64 + } else { + 0.0 + }; + + let avg_duration_ms = if task_count > 0 { + total_duration_ms as f64 / task_count as f64 + } else { + 0.0 + }; + + Ok(ProviderTaskTypeMetrics { + provider: provider.to_string(), + task_type: task_type.to_string(), + total_cost_cents, + task_count, + success_rate, + avg_duration_ms, + }) + } + + /// Get cost forecast for a provider + pub async fn forecast_provider_costs(&self, provider: &str) -> anyhow::Result { + debug!("Forecasting costs for provider: {}", provider); + + let query = format!( + "SELECT * FROM kg_executions WHERE provider = '{}' ORDER BY executed_at DESC LIMIT 100", + provider + ); + + let response: Vec = self.db.query(&query).await?.take(0)?; + + if response.is_empty() { + return Ok(ProviderCostForecast { + provider: provider.to_string(), + current_daily_cost_cents: 0, + projected_weekly_cost_cents: 0, + projected_monthly_cost_cents: 0, + trend: "stable".to_string(), + confidence: 0.0, + }); + } + + // Group costs by day for the last 30 days + let mut daily_costs: Vec = Vec::new(); + let mut current_day_cost: u32 = 0; + let mut last_date_str: Option = None; + + for record in response.iter() { + if let Some(obj) = record.as_object() { + if let Some(executed_at) = obj.get("executed_at").and_then(|v| v.as_str()) { + let date_str = executed_at.split('T').next().unwrap_or("").to_string(); + + if let Some(ref last_date) = last_date_str { + if last_date != &date_str && current_day_cost > 0 { + daily_costs.push(current_day_cost); + current_day_cost = 0; + } + } + + last_date_str = Some(date_str); + } + + if let Some(cost) = obj.get("cost_cents").and_then(|v| v.as_u64()) { + current_day_cost += cost as u32; + } + } + } + + if current_day_cost > 0 { + daily_costs.push(current_day_cost); + } + + let current_daily_cost_cents = if !daily_costs.is_empty() { + daily_costs[0] + } else { + 0 + }; + + let avg_daily_cost = if !daily_costs.is_empty() { + daily_costs.iter().sum::() as f64 / daily_costs.len() as f64 + } else { + 0.0 + }; + + let projected_weekly_cost_cents = (avg_daily_cost * 7.0) as u32; + let projected_monthly_cost_cents = (avg_daily_cost * 30.0) as u32; + + let trend = if daily_costs.len() >= 2 { + let recent_avg = + daily_costs[0..daily_costs.len().min(5)].iter().sum::() as f64 + / daily_costs[0..daily_costs.len().min(5)].len() as f64; + let older_avg = daily_costs[daily_costs.len().min(5)..].iter().sum::() as f64 + / daily_costs[daily_costs.len().min(5)..].len().max(1) as f64; + + if (recent_avg - older_avg).abs() < older_avg * 0.1 { + "stable".to_string() + } else if recent_avg > older_avg { + "increasing".to_string() + } else { + "decreasing".to_string() + } + } else { + "insufficient_data".to_string() + }; + + let confidence = if daily_costs.len() >= 7 { + 0.9 + } else if daily_costs.len() >= 3 { + 0.7 + } else { + 0.3 + }; + + Ok(ProviderCostForecast { + provider: provider.to_string(), + current_daily_cost_cents, + projected_weekly_cost_cents, + projected_monthly_cost_cents, + trend, + confidence, + }) + } + + /// Get cost breakdown by provider + pub async fn get_cost_breakdown_by_provider(&self) -> anyhow::Result> { + debug!("Getting cost breakdown by provider"); + + let query = "SELECT provider, cost_cents FROM kg_executions"; + let response: Vec = self.db.query(query).await?.take(0)?; + + let mut breakdown: HashMap = HashMap::new(); + + for record in response.iter() { + if let Some(obj) = record.as_object() { + if let (Some(provider), Some(cost)) = + (obj.get("provider").and_then(|v| v.as_str()), + obj.get("cost_cents").and_then(|v| v.as_u64())) + { + *breakdown.entry(provider.to_string()).or_insert(0) += cost as u32; + } + } + } + + Ok(breakdown) + } + + /// Get cost breakdown by task type and provider + pub async fn get_cost_breakdown_by_task_and_provider( + &self, + ) -> anyhow::Result>> { + debug!("Getting cost breakdown by task type and provider"); + + let query = "SELECT provider, task_type, cost_cents FROM kg_executions"; + let response: Vec = self.db.query(query).await?.take(0)?; + + let mut breakdown: HashMap> = HashMap::new(); + + for record in response.iter() { + if let Some(obj) = record.as_object() { + if let (Some(provider), Some(task_type), Some(cost)) = + (obj.get("provider").and_then(|v| v.as_str()), + obj.get("task_type").and_then(|v| v.as_str()), + obj.get("cost_cents").and_then(|v| v.as_u64())) + { + breakdown + .entry(provider.to_string()) + .or_default() + .entry(task_type.to_string()) + .and_modify(|v| *v += cost as u32) + .or_insert(cost as u32); + } + } + } + + Ok(breakdown) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provider_analytics_creation() { + let analytics = ProviderAnalytics { + provider: "claude".to_string(), + total_cost_cents: 1000, + total_tasks: 10, + successful_tasks: 9, + failed_tasks: 1, + success_rate: 0.9, + avg_cost_per_task_cents: 100.0, + total_input_tokens: 50000, + total_output_tokens: 25000, + cost_per_1m_tokens: 13.3, + }; + + assert_eq!(analytics.provider, "claude"); + assert_eq!(analytics.total_tasks, 10); + assert_eq!(analytics.success_rate, 0.9); + } + + #[test] + fn test_provider_efficiency_calculation() { + let efficiency = ProviderEfficiency { + provider: "claude".to_string(), + quality_score: 0.9, + cost_score: 0.8, + efficiency_ratio: 0.72, + rank: 1, + }; + + assert_eq!(efficiency.rank, 1); + assert!(efficiency.efficiency_ratio > 0.7); + } + + #[test] + fn test_cost_forecast() { + let forecast = ProviderCostForecast { + provider: "claude".to_string(), + current_daily_cost_cents: 500, + projected_weekly_cost_cents: 3500, + projected_monthly_cost_cents: 15000, + trend: "stable".to_string(), + confidence: 0.9, + }; + + assert_eq!(forecast.current_daily_cost_cents, 500); + assert_eq!(forecast.projected_weekly_cost_cents, 3500); + assert_eq!(forecast.projected_monthly_cost_cents, 15000); + } +} diff --git a/crates/vapora-backend/src/services/task_service.rs b/crates/vapora-backend/src/services/task_service.rs index a9c51ad..5c56577 100644 --- a/crates/vapora-backend/src/services/task_service.rs +++ b/crates/vapora-backend/src/services/task_service.rs @@ -3,7 +3,7 @@ use chrono::Utc; use surrealdb::engine::remote::ws::Client; use surrealdb::Surreal; -use vapora_shared::models::{Task, TaskStatus, TaskPriority}; +use vapora_shared::models::{Task, TaskPriority, TaskStatus}; use vapora_shared::{Result, VaporaError}; /// Service for managing tasks @@ -27,7 +27,9 @@ impl TaskService { // If task_order is not set, get the max order for this project/status and add 1 if task.task_order == 0 { - let max_order = self.get_max_task_order(&task.project_id, &task.status).await?; + let max_order = self + .get_max_task_order(&task.project_id, &task.status) + .await?; task.task_order = max_order + 1; } @@ -105,9 +107,8 @@ impl TaskService { pub async fn get_task(&self, id: &str, tenant_id: &str) -> Result { let task: Option = self.db.select(("tasks", id)).await?; - let task = task.ok_or_else(|| { - VaporaError::NotFound(format!("Task with id '{}' not found", id)) - })?; + let task = + task.ok_or_else(|| VaporaError::NotFound(format!("Task with id '{}' not found", id)))?; // Verify tenant ownership if task.tenant_id != tenant_id { @@ -131,17 +132,18 @@ impl TaskService { updates.updated_at = Utc::now(); // Update in database - let updated: Option = self - .db - .update(("tasks", id)) - .content(updates) - .await?; + let updated: Option = self.db.update(("tasks", id)).content(updates).await?; updated.ok_or_else(|| VaporaError::DatabaseError("Failed to update task".to_string())) } /// Update task status (for Kanban column changes) - pub async fn update_task_status(&self, id: &str, tenant_id: &str, status: TaskStatus) -> Result { + pub async fn update_task_status( + &self, + id: &str, + tenant_id: &str, + status: TaskStatus, + ) -> Result { let task = self.get_task(id, tenant_id).await?; // Get max order for new status @@ -157,9 +159,8 @@ impl TaskService { })) .await?; - updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to update task status".to_string()) - }) + updated + .ok_or_else(|| VaporaError::DatabaseError("Failed to update task status".to_string())) } /// Reorder task (for drag & drop in Kanban) @@ -190,9 +191,7 @@ impl TaskService { })) .await?; - updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to reorder task".to_string()) - }) + updated.ok_or_else(|| VaporaError::DatabaseError("Failed to reorder task".to_string())) } /// Assign task to agent/user @@ -210,13 +209,16 @@ impl TaskService { })) .await?; - updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to assign task".to_string()) - }) + updated.ok_or_else(|| VaporaError::DatabaseError("Failed to assign task".to_string())) } /// Update task priority - pub async fn update_priority(&self, id: &str, tenant_id: &str, priority: TaskPriority) -> Result { + pub async fn update_priority( + &self, + id: &str, + tenant_id: &str, + priority: TaskPriority, + ) -> Result { let mut task = self.get_task(id, tenant_id).await?; task.priority = priority; task.updated_at = Utc::now(); @@ -230,9 +232,7 @@ impl TaskService { })) .await?; - updated.ok_or_else(|| { - VaporaError::DatabaseError("Failed to update priority".to_string()) - }) + updated.ok_or_else(|| VaporaError::DatabaseError("Failed to update priority".to_string())) } /// Delete a task diff --git a/crates/vapora-backend/src/services/workflow_service.rs b/crates/vapora-backend/src/services/workflow_service.rs index 3e65ec5..7862906 100644 --- a/crates/vapora-backend/src/services/workflow_service.rs +++ b/crates/vapora-backend/src/services/workflow_service.rs @@ -41,7 +41,10 @@ impl WorkflowService { } /// Create and register a new workflow - pub async fn create_workflow(&self, workflow: Workflow) -> Result { + pub async fn create_workflow( + &self, + workflow: Workflow, + ) -> Result { let workflow_id = workflow.id.clone(); let title = workflow.title.clone(); @@ -74,7 +77,10 @@ impl WorkflowService { } /// Execute a workflow - pub async fn execute_workflow(&self, workflow_id: &str) -> Result { + pub async fn execute_workflow( + &self, + workflow_id: &str, + ) -> Result { info!("Executing workflow: {}", workflow_id); // Broadcast start @@ -210,8 +216,15 @@ impl WorkflowService { #[cfg(test)] mod tests { use super::*; - use crate::workflow::{executor::StepExecutor, state::{Phase, StepStatus, WorkflowStep}}; - use vapora_agents::{coordinator::AgentCoordinator, registry::AgentRegistry}; + use crate::workflow::{ + executor::StepExecutor, + state::{Phase, StepStatus, WorkflowStep}, + }; + use vapora_agents::{ + config::{AgentConfig, RegistryConfig}, + coordinator::AgentCoordinator, + registry::AgentRegistry, + }; fn create_test_workflow() -> Workflow { Workflow::new( @@ -242,7 +255,19 @@ mod tests { #[tokio::test] async fn test_service_creation() { let registry = Arc::new(AgentRegistry::new(5)); - let coordinator = Arc::new(AgentCoordinator::new(registry)); + let config = AgentConfig { + registry: RegistryConfig { + max_agents_per_role: 5, + health_check_interval: 30, + agent_timeout: 300, + }, + agents: vec![], + }; + let coordinator = Arc::new( + AgentCoordinator::new(config, registry) + .await + .expect("coordinator creation failed"), + ); let executor = StepExecutor::new(coordinator); let engine = Arc::new(WorkflowEngine::new(executor)); let broadcaster = Arc::new(WorkflowBroadcaster::new()); @@ -255,7 +280,19 @@ mod tests { #[tokio::test] async fn test_create_workflow() { let registry = Arc::new(AgentRegistry::new(5)); - let coordinator = Arc::new(AgentCoordinator::new(registry)); + let config = AgentConfig { + registry: RegistryConfig { + max_agents_per_role: 5, + health_check_interval: 30, + agent_timeout: 300, + }, + agents: vec![], + }; + let coordinator = Arc::new( + AgentCoordinator::new(config, registry) + .await + .expect("coordinator creation failed"), + ); let executor = StepExecutor::new(coordinator); let engine = Arc::new(WorkflowEngine::new(executor)); let broadcaster = Arc::new(WorkflowBroadcaster::new()); @@ -266,10 +303,11 @@ mod tests { let workflow = create_test_workflow(); let id = workflow.id.clone(); - let result = service.create_workflow(workflow).await; + let result: Result = + service.create_workflow(workflow).await; assert!(result.is_ok()); - let retrieved = service.get_workflow(&id).await; + let retrieved: Result = service.get_workflow(&id).await; assert!(retrieved.is_ok()); assert_eq!(retrieved.unwrap().id, id); } @@ -277,7 +315,19 @@ mod tests { #[tokio::test] async fn test_audit_trail_logging() { let registry = Arc::new(AgentRegistry::new(5)); - let coordinator = Arc::new(AgentCoordinator::new(registry)); + let config = AgentConfig { + registry: RegistryConfig { + max_agents_per_role: 5, + health_check_interval: 30, + agent_timeout: 300, + }, + agents: vec![], + }; + let coordinator = Arc::new( + AgentCoordinator::new(config, registry) + .await + .expect("coordinator creation failed"), + ); let executor = StepExecutor::new(coordinator); let engine = Arc::new(WorkflowEngine::new(executor)); let broadcaster = Arc::new(WorkflowBroadcaster::new()); @@ -288,9 +338,9 @@ mod tests { let workflow = create_test_workflow(); let id = workflow.id.clone(); - service.create_workflow(workflow).await.unwrap(); + let _: Result = service.create_workflow(workflow).await; - let audit_entries = service.get_audit_trail(&id).await; + let audit_entries: Vec<_> = service.get_audit_trail(&id).await; assert!(!audit_entries.is_empty()); assert_eq!(audit_entries[0].event_type, events::WORKFLOW_CREATED); } diff --git a/crates/vapora-backend/src/workflow/engine.rs b/crates/vapora-backend/src/workflow/engine.rs index f6ada40..ccd98d2 100644 --- a/crates/vapora-backend/src/workflow/engine.rs +++ b/crates/vapora-backend/src/workflow/engine.rs @@ -9,11 +9,9 @@ use std::sync::Arc; use thiserror::Error; use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; -use vapora_agents::coordinator::AgentCoordinator; -use vapora_agents::config::{AgentConfig, RegistryConfig}; -use vapora_agents::registry::AgentRegistry; #[derive(Debug, Error)] +#[allow(dead_code)] pub enum EngineError { #[error("Workflow not found: {0}")] WorkflowNotFound(String), @@ -35,6 +33,7 @@ pub enum EngineError { } /// Workflow engine orchestrates workflow execution +#[allow(dead_code)] pub struct WorkflowEngine { workflows: Arc>>, executor: Arc, @@ -175,11 +174,7 @@ impl WorkflowEngine { } /// Execute a single phase - async fn execute_phase( - &self, - workflow_id: &str, - phase_idx: usize, - ) -> Result<(), EngineError> { + async fn execute_phase(&self, workflow_id: &str, phase_idx: usize) -> Result<(), EngineError> { let (phase_id, is_parallel) = { let workflows = self.workflows.read().await; let workflow = workflows.get(workflow_id).unwrap(); @@ -192,7 +187,8 @@ impl WorkflowEngine { if is_parallel { self.execute_phase_parallel(workflow_id, phase_idx).await?; } else { - self.execute_phase_sequential(workflow_id, phase_idx).await?; + self.execute_phase_sequential(workflow_id, phase_idx) + .await?; } Ok(()) @@ -272,9 +268,9 @@ impl WorkflowEngine { }; if step_failed { - return Err(EngineError::ExecutorError( - ExecutorError::ExecutionFailed("Step failed".to_string()), - )); + return Err(EngineError::ExecutorError(ExecutorError::ExecutionFailed( + "Step failed".to_string(), + ))); } } } @@ -359,6 +355,7 @@ impl WorkflowEngine { mod tests { use super::*; use crate::workflow::state::{Phase, WorkflowStep}; + use vapora_agents::config::{AgentConfig, RegistryConfig}; use vapora_agents::coordinator::AgentCoordinator; use vapora_agents::registry::AgentRegistry; diff --git a/crates/vapora-backend/src/workflow/executor.rs b/crates/vapora-backend/src/workflow/executor.rs index b183015..a89cdb3 100644 --- a/crates/vapora-backend/src/workflow/executor.rs +++ b/crates/vapora-backend/src/workflow/executor.rs @@ -7,9 +7,9 @@ use std::sync::Arc; use thiserror::Error; use tracing::{debug, error, info}; use vapora_agents::coordinator::AgentCoordinator; -use vapora_agents::config::{AgentConfig, RegistryConfig}; #[derive(Debug, Error)] +#[allow(dead_code)] pub enum ExecutorError { #[error("Agent coordinator error: {0}")] CoordinatorError(String), @@ -25,6 +25,7 @@ pub enum ExecutorError { } /// Step executor handles execution of individual workflow steps +#[allow(dead_code)] pub struct StepExecutor { coordinator: Arc, } @@ -160,6 +161,7 @@ impl StepExecutor { #[cfg(test)] mod tests { use super::*; + use vapora_agents::config::{AgentConfig, RegistryConfig}; use vapora_agents::registry::AgentRegistry; fn create_test_step(id: &str, role: &str) -> WorkflowStep { @@ -192,7 +194,11 @@ mod tests { let executor = StepExecutor::new(coordinator); // Verify executor is created successfully - assert!(executor.coordinator().registry().get_agent("nonexistent").is_none()); + assert!(executor + .coordinator() + .registry() + .get_agent("nonexistent") + .is_none()); } #[tokio::test] @@ -238,7 +244,10 @@ mod tests { let result = executor.execute_step(&mut step).await; assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ExecutorError::InvalidState { .. })); + assert!(matches!( + result.unwrap_err(), + ExecutorError::InvalidState { .. } + )); } #[tokio::test] diff --git a/crates/vapora-backend/src/workflow/mod.rs b/crates/vapora-backend/src/workflow/mod.rs index 2f088e1..47a05f2 100644 --- a/crates/vapora-backend/src/workflow/mod.rs +++ b/crates/vapora-backend/src/workflow/mod.rs @@ -8,7 +8,10 @@ pub mod scheduler; pub mod state; pub use engine::*; +#[allow(unused_imports)] pub use executor::*; +#[allow(unused_imports)] pub use parser::*; +#[allow(unused_imports)] pub use scheduler::*; pub use state::*; diff --git a/crates/vapora-backend/src/workflow/scheduler.rs b/crates/vapora-backend/src/workflow/scheduler.rs index da3d40b..363e42d 100644 --- a/crates/vapora-backend/src/workflow/scheduler.rs +++ b/crates/vapora-backend/src/workflow/scheduler.rs @@ -104,9 +104,7 @@ impl Scheduler { } /// Get steps that can be executed in parallel at each level - pub fn get_parallel_groups( - steps: &[WorkflowStep], - ) -> Result>, SchedulerError> { + pub fn get_parallel_groups(steps: &[WorkflowStep]) -> Result>, SchedulerError> { let sorted_levels = Self::resolve_dependencies(steps)?; // Filter to only include parallelizable steps @@ -212,7 +210,10 @@ mod tests { let result = Scheduler::resolve_dependencies(&steps); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), SchedulerError::CircularDependency)); + assert!(matches!( + result.unwrap_err(), + SchedulerError::CircularDependency + )); } #[test] diff --git a/crates/vapora-backend/src/workflow/state.rs b/crates/vapora-backend/src/workflow/state.rs index 49418b8..bd30c64 100644 --- a/crates/vapora-backend/src/workflow/state.rs +++ b/crates/vapora-backend/src/workflow/state.rs @@ -95,16 +95,16 @@ impl Workflow { /// Check if transition is allowed pub fn can_transition(&self, to: &WorkflowStatus) -> bool { - match (&self.status, to) { - (WorkflowStatus::Created, WorkflowStatus::Planning) => true, - (WorkflowStatus::Planning, WorkflowStatus::InProgress) => true, - (WorkflowStatus::InProgress, WorkflowStatus::Completed) => true, - (WorkflowStatus::InProgress, WorkflowStatus::Failed) => true, - (WorkflowStatus::InProgress, WorkflowStatus::Blocked) => true, - (WorkflowStatus::Blocked, WorkflowStatus::InProgress) => true, - (WorkflowStatus::Failed, WorkflowStatus::RolledBack) => true, - _ => false, - } + matches!( + (&self.status, to), + (WorkflowStatus::Created, WorkflowStatus::Planning) + | (WorkflowStatus::Planning, WorkflowStatus::InProgress) + | (WorkflowStatus::InProgress, WorkflowStatus::Completed) + | (WorkflowStatus::InProgress, WorkflowStatus::Failed) + | (WorkflowStatus::InProgress, WorkflowStatus::Blocked) + | (WorkflowStatus::Blocked, WorkflowStatus::InProgress) + | (WorkflowStatus::Failed, WorkflowStatus::RolledBack) + ) } /// Transition to new state @@ -141,9 +141,11 @@ impl Workflow { /// Check if any step has failed pub fn any_step_failed(&self) -> bool { - self.phases - .iter() - .any(|p| p.steps.iter().any(|s| matches!(s.status, StepStatus::Failed))) + self.phases.iter().any(|p| { + p.steps + .iter() + .any(|s| matches!(s.status, StepStatus::Failed)) + }) } /// Get workflow progress percentage diff --git a/crates/vapora-backend/tests/integration_tests.rs b/crates/vapora-backend/tests/integration_tests.rs index d4d13ce..f38710d 100644 --- a/crates/vapora-backend/tests/integration_tests.rs +++ b/crates/vapora-backend/tests/integration_tests.rs @@ -4,7 +4,9 @@ use axum::http::StatusCode; use axum_test::TestServer; use chrono::Utc; -use vapora_shared::models::{Agent, AgentRole, AgentStatus, Project, ProjectStatus, Task, TaskPriority, TaskStatus}; +use vapora_shared::models::{ + Agent, AgentRole, AgentStatus, Project, ProjectStatus, Task, TaskPriority, TaskStatus, +}; /// Helper function to create a test project fn create_test_project() -> Project { diff --git a/crates/vapora-backend/tests/metrics_endpoint_test.rs b/crates/vapora-backend/tests/metrics_endpoint_test.rs index dec4ffd..52c860c 100644 --- a/crates/vapora-backend/tests/metrics_endpoint_test.rs +++ b/crates/vapora-backend/tests/metrics_endpoint_test.rs @@ -7,7 +7,10 @@ use vapora_swarm::SwarmMetrics; async fn test_metrics_endpoint_with_coordinator() { // Initialize metrics let metrics = SwarmMetrics::new(); - assert!(metrics.is_ok(), "SwarmMetrics should initialize successfully"); + assert!( + metrics.is_ok(), + "SwarmMetrics should initialize successfully" + ); let metrics = metrics.unwrap(); @@ -20,10 +23,7 @@ async fn test_metrics_endpoint_with_coordinator() { let metric_families = prometheus::gather(); // Verify swarm metrics are registered - let metric_names: Vec<&str> = metric_families - .iter() - .map(|mf| mf.name()) - .collect(); + let metric_names: Vec<&str> = metric_families.iter().map(|mf| mf.name()).collect(); // Should have at least some swarm metrics let has_swarm_metrics = metric_names diff --git a/crates/vapora-backend/tests/provider_analytics_test.rs b/crates/vapora-backend/tests/provider_analytics_test.rs new file mode 100644 index 0000000..6e2b7e3 --- /dev/null +++ b/crates/vapora-backend/tests/provider_analytics_test.rs @@ -0,0 +1,477 @@ +// Provider Analytics Integration Tests +// Phase 7: Comprehensive tests for provider analytics functionality + +#[cfg(test)] +mod provider_analytics_tests { + use vapora_knowledge_graph::models::{ + ProviderAnalytics, ProviderEfficiency, ProviderTaskTypeMetrics, ProviderCostForecast, + }; + + #[test] + fn test_provider_analytics_creation() { + let analytics = ProviderAnalytics { + provider: "claude".to_string(), + total_cost_cents: 1000, + total_tasks: 10, + successful_tasks: 9, + failed_tasks: 1, + success_rate: 0.9, + avg_cost_per_task_cents: 100.0, + total_input_tokens: 50000, + total_output_tokens: 25000, + cost_per_1m_tokens: 13.3, + }; + + assert_eq!(analytics.provider, "claude"); + assert_eq!(analytics.total_tasks, 10); + assert_eq!(analytics.successful_tasks, 9); + assert_eq!(analytics.failed_tasks, 1); + assert_eq!(analytics.success_rate, 0.9); + assert_eq!(analytics.avg_cost_per_task_cents, 100.0); + assert_eq!(analytics.total_input_tokens, 50000); + assert_eq!(analytics.total_output_tokens, 25000); + assert!((analytics.cost_per_1m_tokens - 13.3).abs() < 0.01); + } + + #[test] + fn test_provider_efficiency_calculation() { + let efficiency = ProviderEfficiency { + provider: "claude".to_string(), + quality_score: 0.9, + cost_score: 0.8, + efficiency_ratio: 0.72, + rank: 1, + }; + + assert_eq!(efficiency.provider, "claude"); + assert_eq!(efficiency.quality_score, 0.9); + assert_eq!(efficiency.cost_score, 0.8); + assert_eq!(efficiency.efficiency_ratio, 0.72); + assert_eq!(efficiency.rank, 1); + assert!(efficiency.efficiency_ratio > 0.7); + } + + #[test] + fn test_provider_efficiency_ranking_order() { + let efficiencies = vec![ + ProviderEfficiency { + provider: "claude".to_string(), + quality_score: 0.95, + cost_score: 0.9, + efficiency_ratio: 0.855, + rank: 1, + }, + ProviderEfficiency { + provider: "gpt-4".to_string(), + quality_score: 0.85, + cost_score: 0.8, + efficiency_ratio: 0.68, + rank: 2, + }, + ProviderEfficiency { + provider: "gemini".to_string(), + quality_score: 0.75, + cost_score: 0.95, + efficiency_ratio: 0.7125, + rank: 3, + }, + ]; + + // Verify ordering: highest efficiency_ratio should have lowest rank + assert!(efficiencies[0].efficiency_ratio > efficiencies[1].efficiency_ratio); + assert!(efficiencies[0].rank < efficiencies[1].rank); + assert!(efficiencies[1].rank < efficiencies[2].rank); + } + + #[test] + fn test_provider_task_type_metrics() { + let metrics = ProviderTaskTypeMetrics { + provider: "claude".to_string(), + task_type: "code_review".to_string(), + total_cost_cents: 500, + task_count: 5, + success_rate: 1.0, + avg_duration_ms: 2500.0, + }; + + assert_eq!(metrics.provider, "claude"); + assert_eq!(metrics.task_type, "code_review"); + assert_eq!(metrics.total_cost_cents, 500); + assert_eq!(metrics.task_count, 5); + assert_eq!(metrics.success_rate, 1.0); + assert_eq!(metrics.avg_duration_ms, 2500.0); + assert_eq!(metrics.total_cost_cents / metrics.task_count as u32, 100); + } + + #[test] + fn test_provider_cost_forecast() { + let forecast = ProviderCostForecast { + provider: "claude".to_string(), + current_daily_cost_cents: 500, + projected_weekly_cost_cents: 3500, + projected_monthly_cost_cents: 15000, + trend: "stable".to_string(), + confidence: 0.9, + }; + + assert_eq!(forecast.provider, "claude"); + assert_eq!(forecast.current_daily_cost_cents, 500); + assert_eq!(forecast.projected_weekly_cost_cents, 3500); + assert_eq!(forecast.projected_monthly_cost_cents, 15000); + assert_eq!(forecast.trend, "stable"); + assert_eq!(forecast.confidence, 0.9); + + // Verify reasonable projections (weekly should be ~7x daily) + let expected_weekly = forecast.current_daily_cost_cents as u32 * 7; + assert!((forecast.projected_weekly_cost_cents as i32 - expected_weekly as i32).abs() <= 100); + } + + #[test] + fn test_success_rate_calculation_with_zero_tasks() { + let analytics = ProviderAnalytics { + provider: "ollama".to_string(), + total_cost_cents: 0, + total_tasks: 0, + successful_tasks: 0, + failed_tasks: 0, + success_rate: 0.0, + avg_cost_per_task_cents: 0.0, + total_input_tokens: 0, + total_output_tokens: 0, + cost_per_1m_tokens: 0.0, + }; + + // When there are no tasks, success rate should be 0 + assert_eq!(analytics.success_rate, 0.0); + assert_eq!(analytics.avg_cost_per_task_cents, 0.0); + } + + #[test] + fn test_cost_per_token_calculation() { + let total_tokens = 1_000_000u64; + let cost_cents = 10u32; + + let cost_per_1m_tokens = (cost_cents as f64 * 1_000_000.0) / (total_tokens as f64); + + assert_eq!(cost_per_1m_tokens, 10.0); + } + + #[test] + fn test_cost_per_token_with_different_volumes() { + // 1M tokens costs 10 cents + let analytics1 = ProviderAnalytics { + provider: "claude".to_string(), + total_cost_cents: 10, + total_tasks: 1, + successful_tasks: 1, + failed_tasks: 0, + success_rate: 1.0, + avg_cost_per_task_cents: 10.0, + total_input_tokens: 500_000, + total_output_tokens: 500_000, + cost_per_1m_tokens: 10.0, + }; + + // 10M tokens costs 50 cents (same rate) + let analytics2 = ProviderAnalytics { + provider: "gpt-4".to_string(), + total_cost_cents: 50, + total_tasks: 1, + successful_tasks: 1, + failed_tasks: 0, + success_rate: 1.0, + avg_cost_per_task_cents: 50.0, + total_input_tokens: 5_000_000, + total_output_tokens: 5_000_000, + cost_per_1m_tokens: 5.0, // 50 cents / 10M tokens = 5 cents per 1M + }; + + // Verify cost scaling + assert!(analytics2.cost_per_1m_tokens < analytics1.cost_per_1m_tokens); + assert_eq!(analytics2.total_cost_cents, 50); + } + + #[test] + fn test_efficiency_ratio_quality_vs_cost() { + // High quality, high cost provider + let high_quality_high_cost = ProviderEfficiency { + provider: "claude".to_string(), + quality_score: 0.95, + cost_score: 0.5, // Lower score because expensive + efficiency_ratio: 0.475, // 0.95 * 0.5 + rank: 1, + }; + + // Lower quality, low cost provider + let low_quality_low_cost = ProviderEfficiency { + provider: "ollama".to_string(), + quality_score: 0.7, + cost_score: 0.95, // Higher score because cheap + efficiency_ratio: 0.665, // 0.7 * 0.95 + rank: 1, + }; + + // Even though Claude is higher quality, Ollama's cost efficiency might win + // depending on the use case + assert!(high_quality_high_cost.quality_score > low_quality_low_cost.quality_score); + assert!(high_quality_high_cost.cost_score < low_quality_low_cost.cost_score); + } + + #[test] + fn test_forecast_trend_detection() { + // Stable trend: costs are relatively consistent + let stable_forecast = ProviderCostForecast { + provider: "claude".to_string(), + current_daily_cost_cents: 500, + projected_weekly_cost_cents: 3500, + projected_monthly_cost_cents: 15000, + trend: "stable".to_string(), + confidence: 0.9, + }; + + // Increasing trend: costs are growing + let increasing_forecast = ProviderCostForecast { + provider: "gpt-4".to_string(), + current_daily_cost_cents: 600, + projected_weekly_cost_cents: 5200, + projected_monthly_cost_cents: 20000, + trend: "increasing".to_string(), + confidence: 0.85, + }; + + // Decreasing trend: costs are declining + let decreasing_forecast = ProviderCostForecast { + provider: "gemini".to_string(), + current_daily_cost_cents: 300, + projected_weekly_cost_cents: 1750, + projected_monthly_cost_cents: 7500, + trend: "decreasing".to_string(), + confidence: 0.7, + }; + + assert_eq!(stable_forecast.trend, "stable"); + assert_eq!(increasing_forecast.trend, "increasing"); + assert_eq!(decreasing_forecast.trend, "decreasing"); + + // Higher confidence when more data available + assert!(stable_forecast.confidence > increasing_forecast.confidence); + assert!(increasing_forecast.confidence > decreasing_forecast.confidence); + } + + #[test] + fn test_forecast_confidence_based_on_data_volume() { + // High confidence with 7+ days of data + let high_confidence = ProviderCostForecast { + provider: "claude".to_string(), + current_daily_cost_cents: 500, + projected_weekly_cost_cents: 3500, + projected_monthly_cost_cents: 15000, + trend: "stable".to_string(), + confidence: 0.9, + }; + + // Medium confidence with 3-6 days of data + let medium_confidence = ProviderCostForecast { + provider: "gpt-4".to_string(), + current_daily_cost_cents: 400, + projected_weekly_cost_cents: 2800, + projected_monthly_cost_cents: 12000, + trend: "stable".to_string(), + confidence: 0.7, + }; + + // Low confidence with < 3 days of data + let low_confidence = ProviderCostForecast { + provider: "gemini".to_string(), + current_daily_cost_cents: 300, + projected_weekly_cost_cents: 2100, + projected_monthly_cost_cents: 9000, + trend: "insufficient_data".to_string(), + confidence: 0.3, + }; + + assert!(high_confidence.confidence > medium_confidence.confidence); + assert!(medium_confidence.confidence > low_confidence.confidence); + } + + #[test] + fn test_provider_comparison_cost_quality_tradeoff() { + // Claude: High quality, high cost + let claude = ProviderEfficiency { + provider: "claude".to_string(), + quality_score: 0.95, + cost_score: 0.6, + efficiency_ratio: 0.57, + rank: 2, + }; + + // GPT-4: High quality, medium cost + let gpt4 = ProviderEfficiency { + provider: "gpt-4".to_string(), + quality_score: 0.90, + cost_score: 0.7, + efficiency_ratio: 0.63, + rank: 1, + }; + + // Gemini: Good quality, low cost + let gemini = ProviderEfficiency { + provider: "gemini".to_string(), + quality_score: 0.80, + cost_score: 0.85, + efficiency_ratio: 0.68, + rank: 1, + }; + + // Ollama: Lower quality, free + let ollama = ProviderEfficiency { + provider: "ollama".to_string(), + quality_score: 0.6, + cost_score: 1.0, + efficiency_ratio: 0.6, + rank: 4, + }; + + // Verify quality ordering + assert!(claude.quality_score > gpt4.quality_score); + assert!(gpt4.quality_score > gemini.quality_score); + assert!(gemini.quality_score > ollama.quality_score); + + // Verify cost score ordering (higher = cheaper) + assert!(ollama.cost_score > gemini.cost_score); + assert!(gemini.cost_score > gpt4.cost_score); + assert!(gpt4.cost_score > claude.cost_score); + } + + #[test] + fn test_multiple_task_types_per_provider() { + let code_review = ProviderTaskTypeMetrics { + provider: "claude".to_string(), + task_type: "code_review".to_string(), + total_cost_cents: 500, + task_count: 5, + success_rate: 1.0, + avg_duration_ms: 2500.0, + }; + + let documentation = ProviderTaskTypeMetrics { + provider: "claude".to_string(), + task_type: "documentation".to_string(), + total_cost_cents: 300, + task_count: 10, + success_rate: 0.9, + avg_duration_ms: 1500.0, + }; + + let testing = ProviderTaskTypeMetrics { + provider: "claude".to_string(), + task_type: "testing".to_string(), + total_cost_cents: 200, + task_count: 8, + success_rate: 0.875, + avg_duration_ms: 3000.0, + }; + + // Verify same provider, different task types + assert_eq!(code_review.provider, documentation.provider); + assert_eq!(documentation.provider, testing.provider); + assert_ne!(code_review.task_type, documentation.task_type); + assert_ne!(documentation.task_type, testing.task_type); + + // Verify varying success rates across task types + assert!(code_review.success_rate > documentation.success_rate); + assert!(documentation.success_rate > testing.success_rate); + } + + #[test] + fn test_provider_cost_tracking_over_time() { + // Day 1: $5 + let day1 = ProviderAnalytics { + provider: "claude".to_string(), + total_cost_cents: 500, + total_tasks: 10, + successful_tasks: 10, + failed_tasks: 0, + success_rate: 1.0, + avg_cost_per_task_cents: 50.0, + total_input_tokens: 100_000, + total_output_tokens: 50_000, + cost_per_1m_tokens: 3.33, + }; + + // Day 2: $7 (cumulative: $12) + let day2 = ProviderAnalytics { + provider: "claude".to_string(), + total_cost_cents: 700, + total_tasks: 14, + successful_tasks: 14, + failed_tasks: 0, + success_rate: 1.0, + avg_cost_per_task_cents: 50.0, + total_input_tokens: 140_000, + total_output_tokens: 70_000, + cost_per_1m_tokens: 3.33, + }; + + // Day 3: $6 (cumulative: $18) + let day3 = ProviderAnalytics { + provider: "claude".to_string(), + total_cost_cents: 600, + total_tasks: 12, + successful_tasks: 12, + failed_tasks: 0, + success_rate: 1.0, + avg_cost_per_task_cents: 50.0, + total_input_tokens: 120_000, + total_output_tokens: 60_000, + cost_per_1m_tokens: 3.33, + }; + + // Verify cost progression + assert!(day2.total_cost_cents > day1.total_cost_cents); + assert!(day3.total_cost_cents > day1.total_cost_cents); + assert!(day2.total_tasks > day1.total_tasks); + + // Verify average cost per task consistency + assert!((day1.avg_cost_per_task_cents - day2.avg_cost_per_task_cents).abs() < 0.01); + assert!((day2.avg_cost_per_task_cents - day3.avg_cost_per_task_cents).abs() < 0.01); + } + + #[test] + fn test_provider_with_high_failure_rate() { + let failing_provider = ProviderAnalytics { + provider: "unstable".to_string(), + total_cost_cents: 1000, + total_tasks: 100, + successful_tasks: 50, + failed_tasks: 50, + success_rate: 0.5, + avg_cost_per_task_cents: 10.0, + total_input_tokens: 500_000, + total_output_tokens: 250_000, + cost_per_1m_tokens: 1.33, + }; + + let reliable_provider = ProviderAnalytics { + provider: "reliable".to_string(), + total_cost_cents: 2000, + total_tasks: 100, + successful_tasks: 95, + failed_tasks: 5, + success_rate: 0.95, + avg_cost_per_task_cents: 20.0, + total_input_tokens: 500_000, + total_output_tokens: 250_000, + cost_per_1m_tokens: 2.67, + }; + + // Even though unstable provider is cheaper per task, + // reliability matters for efficiency + assert!(failing_provider.avg_cost_per_task_cents < reliable_provider.avg_cost_per_task_cents); + assert!(failing_provider.success_rate < reliable_provider.success_rate); + + // Quality score should reflect reliability + // In a real scenario, this would impact efficiency_ratio + assert!(reliable_provider.success_rate > failing_provider.success_rate); + } +} diff --git a/crates/vapora-backend/tests/swarm_api_test.rs b/crates/vapora-backend/tests/swarm_api_test.rs index 4ec039c..e6033d2 100644 --- a/crates/vapora-backend/tests/swarm_api_test.rs +++ b/crates/vapora-backend/tests/swarm_api_test.rs @@ -2,7 +2,7 @@ // Tests verify swarm statistics and health monitoring endpoints use std::sync::Arc; -use vapora_swarm::{SwarmCoordinator, AgentProfile}; +use vapora_swarm::{AgentProfile, SwarmCoordinator}; /// Helper to create a test agent profile fn create_test_profile(id: &str, success_rate: f64, load: f64) -> AgentProfile { @@ -180,8 +180,8 @@ async fn test_swarm_task_assignment_selects_best_agent() { // Create swarm with agents of different quality let swarm = Arc::new(SwarmCoordinator::new()); - let poor_agent = create_test_profile("agent-poor", 0.50, 0.9); // Low success, high load - let good_agent = create_test_profile("agent-good", 0.95, 0.2); // High success, low load + let poor_agent = create_test_profile("agent-poor", 0.50, 0.9); // Low success, high load + let good_agent = create_test_profile("agent-good", 0.95, 0.2); // High success, low load swarm.register_agent(poor_agent).ok(); swarm.register_agent(good_agent).ok(); diff --git a/crates/vapora-backend/tests/workflow_integration_test.rs b/crates/vapora-backend/tests/workflow_integration_test.rs index 3760a59..cc73b26 100644 --- a/crates/vapora-backend/tests/workflow_integration_test.rs +++ b/crates/vapora-backend/tests/workflow_integration_test.rs @@ -2,11 +2,15 @@ // Tests the complete workflow system end-to-end use std::sync::Arc; -use vapora_agents::{coordinator::AgentCoordinator, registry::AgentRegistry}; +use vapora_agents::{ + config::{AgentConfig, RegistryConfig}, + coordinator::AgentCoordinator, + registry::AgentRegistry, +}; use vapora_backend::{ api::websocket::WorkflowBroadcaster, audit::AuditTrail, - services::WorkflowService, + services::{WorkflowService, WorkflowServiceError}, workflow::{ engine::WorkflowEngine, executor::StepExecutor, @@ -130,7 +134,19 @@ async fn test_dependency_resolution() { #[tokio::test] async fn test_workflow_engine() { let registry = Arc::new(AgentRegistry::new(5)); - let coordinator = Arc::new(AgentCoordinator::new(registry)); + let config = AgentConfig { + registry: RegistryConfig { + max_agents_per_role: 5, + health_check_interval: 30, + agent_timeout: 300, + }, + agents: vec![], + }; + let coordinator = Arc::new( + AgentCoordinator::new(config, registry) + .await + .expect("coordinator creation failed"), + ); let executor = StepExecutor::new(coordinator); let engine = WorkflowEngine::new(executor); @@ -170,7 +186,19 @@ async fn test_workflow_engine() { #[tokio::test] async fn test_workflow_service_integration() { let registry = Arc::new(AgentRegistry::new(5)); - let coordinator = Arc::new(AgentCoordinator::new(registry)); + let config = AgentConfig { + registry: RegistryConfig { + max_agents_per_role: 5, + health_check_interval: 30, + agent_timeout: 300, + }, + agents: vec![], + }; + let coordinator = Arc::new( + AgentCoordinator::new(config, registry) + .await + .expect("coordinator creation failed"), + ); let executor = StepExecutor::new(coordinator); let engine = Arc::new(WorkflowEngine::new(executor)); let broadcaster = Arc::new(WorkflowBroadcaster::new()); @@ -217,11 +245,11 @@ async fn test_workflow_service_integration() { ); let id = workflow.id.clone(); - let result = service.create_workflow(workflow).await; + let result: Result = service.create_workflow(workflow).await; assert!(result.is_ok()); // Check audit trail - let audit_entries = service.get_audit_trail(&id).await; + let audit_entries: Vec<_> = service.get_audit_trail(&id).await; assert!(!audit_entries.is_empty()); } diff --git a/crates/vapora-frontend/src/api/mod.rs b/crates/vapora-frontend/src/api/mod.rs index 1d888ac..bc42308 100644 --- a/crates/vapora-frontend/src/api/mod.rs +++ b/crates/vapora-frontend/src/api/mod.rs @@ -1,16 +1,11 @@ // API client module for VAPORA frontend // Handles all HTTP communication with backend -use gloo_net::http::Request; use crate::config::AppConfig; +use gloo_net::http::Request; // Re-export types from vapora-shared -pub use vapora_shared::models::{ - Agent, - Project, - Task, TaskPriority, TaskStatus, - Workflow, -}; +pub use vapora_shared::models::{Agent, Project, Task, TaskPriority, TaskStatus, Workflow}; /// API client for backend communication #[derive(Clone)] @@ -100,7 +95,11 @@ impl ApiClient { } /// Update task status - pub async fn update_task_status(&self, task_id: &str, status: TaskStatus) -> Result { + pub async fn update_task_status( + &self, + task_id: &str, + status: TaskStatus, + ) -> Result { let url = format!("{}/api/v1/tasks/{}", self.base_url, task_id); let body = serde_json::json!({ "status": status }).to_string(); diff --git a/crates/vapora-frontend/src/components/kanban/board.rs b/crates/vapora-frontend/src/components/kanban/board.rs index 86ba173..f0d5714 100644 --- a/crates/vapora-frontend/src/components/kanban/board.rs +++ b/crates/vapora-frontend/src/components/kanban/board.rs @@ -1,11 +1,11 @@ // Main Kanban board component -use leptos::prelude::*; -use leptos::task::spawn_local; -use log::warn; use crate::api::{ApiClient, Task, TaskStatus}; use crate::components::KanbanColumn; use crate::config::AppConfig; +use leptos::prelude::*; +use leptos::task::spawn_local; +use log::warn; /// Main Kanban board component #[component] diff --git a/crates/vapora-frontend/src/components/kanban/column.rs b/crates/vapora-frontend/src/components/kanban/column.rs index e045e03..c8483d5 100644 --- a/crates/vapora-frontend/src/components/kanban/column.rs +++ b/crates/vapora-frontend/src/components/kanban/column.rs @@ -1,8 +1,8 @@ // Kanban column component with drag & drop support -use leptos::prelude::*; use crate::api::Task; use crate::components::TaskCard; +use leptos::prelude::*; /// Kanban column component #[component] diff --git a/crates/vapora-frontend/src/components/kanban/task_card.rs b/crates/vapora-frontend/src/components/kanban/task_card.rs index 98b5c88..75060c7 100644 --- a/crates/vapora-frontend/src/components/kanban/task_card.rs +++ b/crates/vapora-frontend/src/components/kanban/task_card.rs @@ -1,8 +1,8 @@ // Task card component for Kanban board -use leptos::prelude::*; use crate::api::{Task, TaskPriority}; use crate::components::Badge; +use leptos::prelude::*; /// Task card component with drag support #[component] diff --git a/crates/vapora-frontend/src/components/mod.rs b/crates/vapora-frontend/src/components/mod.rs index 158cf8b..0cf3281 100644 --- a/crates/vapora-frontend/src/components/mod.rs +++ b/crates/vapora-frontend/src/components/mod.rs @@ -1,10 +1,10 @@ // Component modules for VAPORA frontend -pub mod primitives; pub mod kanban; pub mod layout; +pub mod primitives; // Re-export commonly used components -pub use primitives::*; pub use kanban::*; pub use layout::*; +pub use primitives::*; diff --git a/crates/vapora-frontend/src/components/primitives/badge.rs b/crates/vapora-frontend/src/components/primitives/badge.rs index f7cba93..93e05d9 100644 --- a/crates/vapora-frontend/src/components/primitives/badge.rs +++ b/crates/vapora-frontend/src/components/primitives/badge.rs @@ -4,10 +4,7 @@ use leptos::prelude::*; /// Badge component for displaying labels #[component] -pub fn Badge( - #[prop(default = "")] class: &'static str, - children: Children, -) -> impl IntoView { +pub fn Badge(#[prop(default = "")] class: &'static str, children: Children) -> impl IntoView { let combined_class = format!( "inline-block px-3 py-1 rounded-full bg-cyan-500/20 text-cyan-400 text-xs font-medium {}", class diff --git a/crates/vapora-frontend/src/components/primitives/button.rs b/crates/vapora-frontend/src/components/primitives/button.rs index aacac49..cba1c4b 100644 --- a/crates/vapora-frontend/src/components/primitives/button.rs +++ b/crates/vapora-frontend/src/components/primitives/button.rs @@ -1,7 +1,7 @@ // Button component with gradient styling -use leptos::prelude::*; use leptos::ev::MouseEvent; +use leptos::prelude::*; /// Button component with gradient background #[component] diff --git a/crates/vapora-frontend/src/components/primitives/input.rs b/crates/vapora-frontend/src/components/primitives/input.rs index 386c41b..9ab6cfc 100644 --- a/crates/vapora-frontend/src/components/primitives/input.rs +++ b/crates/vapora-frontend/src/components/primitives/input.rs @@ -1,7 +1,7 @@ // Input component with glassmorphism styling -use leptos::prelude::*; use leptos::ev::Event; +use leptos::prelude::*; /// Input field component with glassmorphism styling #[component] diff --git a/crates/vapora-frontend/src/components/primitives/mod.rs b/crates/vapora-frontend/src/components/primitives/mod.rs index 778b8fc..0ef3e15 100644 --- a/crates/vapora-frontend/src/components/primitives/mod.rs +++ b/crates/vapora-frontend/src/components/primitives/mod.rs @@ -1,11 +1,11 @@ // Primitive UI components with glassmorphism design -pub mod card; -pub mod button; pub mod badge; +pub mod button; +pub mod card; pub mod input; -pub use card::*; -pub use button::*; pub use badge::*; +pub use button::*; +pub use card::*; pub use input::*; diff --git a/crates/vapora-frontend/src/pages/agents.rs b/crates/vapora-frontend/src/pages/agents.rs index f5017e1..cc5c256 100644 --- a/crates/vapora-frontend/src/pages/agents.rs +++ b/crates/vapora-frontend/src/pages/agents.rs @@ -1,11 +1,11 @@ // Agents marketplace page +use crate::api::{Agent, ApiClient}; +use crate::components::{Badge, Button, Card, GlowColor, NavBar}; +use crate::config::AppConfig; use leptos::prelude::*; use leptos::task::spawn_local; use log::warn; -use crate::api::{ApiClient, Agent}; -use crate::components::{Button, Card, Badge, GlowColor, NavBar}; -use crate::config::AppConfig; /// Agents marketplace page #[component] diff --git a/crates/vapora-frontend/src/pages/home.rs b/crates/vapora-frontend/src/pages/home.rs index 69a4ecc..7f10ed3 100644 --- a/crates/vapora-frontend/src/pages/home.rs +++ b/crates/vapora-frontend/src/pages/home.rs @@ -1,8 +1,8 @@ // Home page / landing page +use crate::components::{Card, GlowColor, NavBar}; use leptos::prelude::*; use leptos_router::components::A; -use crate::components::{Card, GlowColor, NavBar}; /// Home page component #[component] diff --git a/crates/vapora-frontend/src/pages/mod.rs b/crates/vapora-frontend/src/pages/mod.rs index 0e39de2..861d7aa 100644 --- a/crates/vapora-frontend/src/pages/mod.rs +++ b/crates/vapora-frontend/src/pages/mod.rs @@ -1,15 +1,15 @@ // Page components for routing -pub mod home; -pub mod projects; -pub mod project_detail; pub mod agents; -pub mod workflows; +pub mod home; pub mod not_found; +pub mod project_detail; +pub mod projects; +pub mod workflows; -pub use home::*; -pub use projects::*; -pub use project_detail::*; pub use agents::*; -pub use workflows::*; +pub use home::*; pub use not_found::*; +pub use project_detail::*; +pub use projects::*; +pub use workflows::*; diff --git a/crates/vapora-frontend/src/pages/not_found.rs b/crates/vapora-frontend/src/pages/not_found.rs index 4d291fa..c6fd36d 100644 --- a/crates/vapora-frontend/src/pages/not_found.rs +++ b/crates/vapora-frontend/src/pages/not_found.rs @@ -1,8 +1,8 @@ // 404 Not Found page +use crate::components::NavBar; use leptos::prelude::*; use leptos_router::components::A; -use crate::components::NavBar; /// 404 Not Found page #[component] diff --git a/crates/vapora-frontend/src/pages/project_detail.rs b/crates/vapora-frontend/src/pages/project_detail.rs index 94810aa..c2effa9 100644 --- a/crates/vapora-frontend/src/pages/project_detail.rs +++ b/crates/vapora-frontend/src/pages/project_detail.rs @@ -1,8 +1,8 @@ // Project detail page with Kanban board +use crate::components::{KanbanBoard, NavBar}; use leptos::prelude::*; use leptos_router::hooks::use_params_map; -use crate::components::{KanbanBoard, NavBar}; /// Project detail page showing Kanban board #[component] diff --git a/crates/vapora-frontend/src/pages/projects.rs b/crates/vapora-frontend/src/pages/projects.rs index aa72c6f..04ccc29 100644 --- a/crates/vapora-frontend/src/pages/projects.rs +++ b/crates/vapora-frontend/src/pages/projects.rs @@ -1,12 +1,12 @@ // Projects list page +use crate::api::{ApiClient, Project}; +use crate::components::{Badge, Button, Card, NavBar}; +use crate::config::AppConfig; use leptos::prelude::*; use leptos::task::spawn_local; use leptos_router::components::A; use log::warn; -use crate::api::{ApiClient, Project}; -use crate::components::{Button, Card, Badge, NavBar}; -use crate::config::AppConfig; /// Projects list page #[component] diff --git a/crates/vapora-frontend/src/pages/workflows.rs b/crates/vapora-frontend/src/pages/workflows.rs index b7e6221..dbab73f 100644 --- a/crates/vapora-frontend/src/pages/workflows.rs +++ b/crates/vapora-frontend/src/pages/workflows.rs @@ -1,7 +1,7 @@ // Workflows page (placeholder for Phase 4) -use leptos::prelude::*; use crate::components::NavBar; +use leptos::prelude::*; /// Workflows page (to be implemented in Phase 4) #[component] diff --git a/crates/vapora-knowledge-graph/benches/kg_benchmarks.rs b/crates/vapora-knowledge-graph/benches/kg_benchmarks.rs index 4ef8cfe..3d58d09 100644 --- a/crates/vapora-knowledge-graph/benches/kg_benchmarks.rs +++ b/crates/vapora-knowledge-graph/benches/kg_benchmarks.rs @@ -1,6 +1,6 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use vapora_knowledge_graph::{TemporalKG, ExecutionRecord}; use chrono::Utc; +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use vapora_knowledge_graph::{ExecutionRecord, TemporalKG}; async fn setup_kg_with_records(count: usize) -> TemporalKG { let kg = TemporalKG::new("ws://localhost:8000", "root", "root") @@ -12,6 +12,7 @@ async fn setup_kg_with_records(count: usize) -> TemporalKG { id: format!("exec-{}", i), task_id: format!("task-{}", i), agent_id: format!("agent-{}", i % 10), + agent_role: None, task_type: match i % 3 { 0 => "coding".to_string(), 1 => "analysis".to_string(), @@ -21,8 +22,14 @@ async fn setup_kg_with_records(count: usize) -> TemporalKG { duration_ms: 1000 + (i as u64 * 100) % 5000, input_tokens: 100 + (i as u64 * 10), output_tokens: 200 + (i as u64 * 20), + cost_cents: (i as u32 % 100 + 1) * 10, + provider: "claude".to_string(), success: i % 10 != 0, - error: if i % 10 == 0 { Some("timeout".to_string()) } else { None }, + error: if i % 10 == 0 { + Some("timeout".to_string()) + } else { + None + }, solution: Some(format!("Solution for task {}", i)), root_cause: None, timestamp: Utc::now(), @@ -44,11 +51,14 @@ fn kg_record_execution(c: &mut Criterion) { id: "test-exec".to_string(), task_id: "test-task".to_string(), agent_id: "test-agent".to_string(), + agent_role: None, task_type: "coding".to_string(), description: "Test execution".to_string(), duration_ms: 1000, input_tokens: 100, output_tokens: 200, + cost_cents: 50, + provider: "claude".to_string(), success: true, error: None, solution: None, @@ -70,11 +80,8 @@ fn kg_query_similar(c: &mut Criterion) { }, |kg| async move { black_box( - kg.query_similar_tasks( - "coding", - "Write a function for processing data", - ) - .await, + kg.query_similar_tasks("coding", "Write a function for processing data") + .await, ) }, criterion::BatchSize::SmallInput, @@ -90,9 +97,7 @@ fn kg_get_statistics(c: &mut Criterion) { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(setup_kg_with_records(1000)) }, - |kg| async move { - black_box(kg.get_statistics().await) - }, + |kg| async move { black_box(kg.get_statistics().await) }, criterion::BatchSize::SmallInput, ); }); @@ -106,9 +111,7 @@ fn kg_get_agent_profile(c: &mut Criterion) { let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(setup_kg_with_records(500)) }, - |kg| async move { - black_box(kg.get_agent_profile("agent-1").await) - }, + |kg| async move { black_box(kg.get_agent_profile("agent-1").await) }, criterion::BatchSize::SmallInput, ); }); diff --git a/crates/vapora-knowledge-graph/src/analytics.rs b/crates/vapora-knowledge-graph/src/analytics.rs new file mode 100644 index 0000000..384ae65 --- /dev/null +++ b/crates/vapora-knowledge-graph/src/analytics.rs @@ -0,0 +1,590 @@ +// vapora-knowledge-graph: Analytics module for KG insights +// Phase 5.5: Advanced persistence analytics, reporting, and trends + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +use crate::metrics::{AnalyticsComputation, TimePeriod}; +use crate::persistence::PersistedExecution; + +/// Time-windowed performance metrics for an agent +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentPerformance { + pub agent_id: String, + pub period: TimePeriod, + pub tasks_executed: u32, + pub tasks_successful: u32, + pub success_rate: f64, + pub avg_duration_ms: f64, + pub min_duration_ms: u64, + pub max_duration_ms: u64, + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub avg_tokens_per_task: f64, +} + +/// Task type analytics with trend data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskTypeAnalytics { + pub task_type: String, + pub total_executions: u32, + pub successful_executions: u32, + pub success_rate: f64, + pub agents_involved: Vec, + pub avg_duration_ms: f64, + pub total_tokens_used: u64, + pub quality_score: f64, // Derived from success_rate +} + +/// Agent learning progress over time +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LearningProgress { + pub agent_id: String, + pub task_type: String, + pub initial_success_rate: f64, + pub current_success_rate: f64, + pub improvement: f64, // Percentage points + pub tasks_to_confidence: u32, // Executions needed for 95% confidence + pub trend: PerformanceTrend, +} + +/// Performance trend direction +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum PerformanceTrend { + Improving, + Stable, + Declining, +} + +/// Cost efficiency report for provider selection +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CostEfficiencyReport { + pub period: TimePeriod, + pub total_cost_cents: u32, + pub total_tasks: u32, + pub cost_per_task_cents: f32, + pub most_used_provider: String, + pub most_efficient_provider: String, + pub provider_costs: HashMap, +} + +/// Cost breakdown by provider +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderCostBreakdown { + pub provider: String, + pub tasks_executed: u32, + pub total_cost_cents: u32, + pub cost_per_task_cents: f32, + pub input_tokens: u64, + pub output_tokens: u64, +} + +/// System-wide analytics dashboard data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardMetrics { + pub period: TimePeriod, + pub total_tasks: u32, + pub successful_tasks: u32, + pub overall_success_rate: f64, + pub total_agents_active: u32, + pub total_task_types: u32, + pub avg_task_duration_ms: f64, + pub total_cost_cents: u32, + pub top_performing_agents: Vec<(String, f64)>, // (agent_id, success_rate) + pub top_task_types: Vec<(String, u32)>, // (task_type, count) +} + +/// Query service for KG analytics +pub struct KGAnalytics; + +impl KGAnalytics { + /// Calculate agent performance for a time period + pub fn calculate_agent_performance( + executions: Vec, + period: TimePeriod, + agent_id: &str, + ) -> AgentPerformance { + if executions.is_empty() { + return AgentPerformance { + agent_id: agent_id.to_string(), + period, + tasks_executed: 0, + tasks_successful: 0, + success_rate: 0.5, + avg_duration_ms: 0.0, + min_duration_ms: 0, + max_duration_ms: 0, + total_input_tokens: 0, + total_output_tokens: 0, + avg_tokens_per_task: 0.0, + }; + } + + let successful = executions.iter().filter(|e| e.outcome == "success").count() as u32; + + let total_duration: u64 = executions.iter().map(|e| e.duration_ms).sum(); + let avg_duration = total_duration as f64 / executions.len() as f64; + + let min_duration = executions.iter().map(|e| e.duration_ms).min().unwrap_or(0); + let max_duration = executions.iter().map(|e| e.duration_ms).max().unwrap_or(0); + + let total_input_tokens: u64 = executions.iter().map(|e| e.input_tokens).sum(); + let total_output_tokens: u64 = executions.iter().map(|e| e.output_tokens).sum(); + let avg_tokens = + (total_input_tokens + total_output_tokens) as f64 / executions.len() as f64; + + AgentPerformance { + agent_id: agent_id.to_string(), + period, + tasks_executed: executions.len() as u32, + tasks_successful: successful, + success_rate: successful as f64 / executions.len() as f64, + avg_duration_ms: avg_duration, + min_duration_ms: min_duration, + max_duration_ms: max_duration, + total_input_tokens, + total_output_tokens, + avg_tokens_per_task: avg_tokens, + } + } + + /// Analyze task type performance across agents + pub fn analyze_task_type( + executions: Vec, + task_type: &str, + ) -> TaskTypeAnalytics { + if executions.is_empty() { + return TaskTypeAnalytics { + task_type: task_type.to_string(), + total_executions: 0, + successful_executions: 0, + success_rate: 0.5, + agents_involved: Vec::new(), + avg_duration_ms: 0.0, + total_tokens_used: 0, + quality_score: 0.5, + }; + } + + let successful = executions.iter().filter(|e| e.outcome == "success").count() as u32; + + let total_duration: u64 = executions.iter().map(|e| e.duration_ms).sum(); + let avg_duration = total_duration as f64 / executions.len() as f64; + + let total_tokens: u64 = executions + .iter() + .map(|e| e.input_tokens + e.output_tokens) + .sum(); + + let mut agents = executions + .iter() + .map(|e| e.agent_id.clone()) + .collect::>(); + agents.sort(); + agents.dedup(); + + let success_rate = successful as f64 / executions.len() as f64; + + TaskTypeAnalytics { + task_type: task_type.to_string(), + total_executions: executions.len() as u32, + successful_executions: successful, + success_rate, + agents_involved: agents, + avg_duration_ms: avg_duration, + total_tokens_used: total_tokens, + quality_score: success_rate, // Quality derived from success rate + } + } + + /// Calculate learning progress for agent + task type + pub fn calculate_learning_progress( + recent_executions: Vec, + _historical_executions: Vec, + agent_id: &str, + task_type: &str, + ) -> LearningProgress { + if recent_executions.is_empty() { + return LearningProgress { + agent_id: agent_id.to_string(), + task_type: task_type.to_string(), + initial_success_rate: 0.5, + current_success_rate: 0.5, + improvement: 0.0, + tasks_to_confidence: 20, + trend: PerformanceTrend::Stable, + }; + } + + let recent_successful = recent_executions + .iter() + .filter(|e| e.outcome == "success") + .count() as u32; + + let current_success_rate = recent_successful as f64 / recent_executions.len() as f64; + + // Determine trend: compare first half vs second half of recent executions + let midpoint = recent_executions.len() / 2; + let trend = if midpoint > 0 { + let first_half_success = recent_executions[..midpoint] + .iter() + .filter(|e| e.outcome == "success") + .count() as f64 + / midpoint as f64; + + let second_half_success = recent_executions[midpoint..] + .iter() + .filter(|e| e.outcome == "success") + .count() as f64 + / (recent_executions.len() - midpoint) as f64; + + if second_half_success > first_half_success + 0.05 { + PerformanceTrend::Improving + } else if second_half_success < first_half_success - 0.05 { + PerformanceTrend::Declining + } else { + PerformanceTrend::Stable + } + } else { + PerformanceTrend::Stable + }; + + LearningProgress { + agent_id: agent_id.to_string(), + task_type: task_type.to_string(), + initial_success_rate: 0.5, + current_success_rate, + improvement: (current_success_rate - 0.5) * 100.0, + tasks_to_confidence: 20 - recent_executions.len().min(20) as u32, + trend, + } + } + + /// Generate cost efficiency report + pub fn generate_cost_report( + executions: Vec, + period: TimePeriod, + ) -> CostEfficiencyReport { + if executions.is_empty() { + return CostEfficiencyReport { + period, + total_cost_cents: 0, + total_tasks: 0, + cost_per_task_cents: 0.0, + most_used_provider: "unknown".to_string(), + most_efficient_provider: "unknown".to_string(), + provider_costs: HashMap::new(), + }; + } + + let mut provider_costs: HashMap = HashMap::new(); + let mut total_cost_cents = 0u32; + + for exec in &executions { + // Estimate cost (simplified: assume 1 cent per token for demo) + let cost_cents = (exec.input_tokens + exec.output_tokens) as u32 / 1000; + total_cost_cents = total_cost_cents.saturating_add(cost_cents); + + let entry = provider_costs + .entry("unknown_provider".to_string()) + .or_insert(ProviderCostBreakdown { + provider: "unknown_provider".to_string(), + tasks_executed: 0, + total_cost_cents: 0, + cost_per_task_cents: 0.0, + input_tokens: 0, + output_tokens: 0, + }); + + entry.tasks_executed += 1; + entry.total_cost_cents = entry.total_cost_cents.saturating_add(cost_cents); + entry.input_tokens += exec.input_tokens; + entry.output_tokens += exec.output_tokens; + } + + // Calculate cost per task for each provider + for breakdown in provider_costs.values_mut() { + breakdown.cost_per_task_cents = if breakdown.tasks_executed > 0 { + breakdown.total_cost_cents as f32 / breakdown.tasks_executed as f32 + } else { + 0.0 + }; + } + + let most_used_provider = provider_costs + .iter() + .max_by_key(|(_, b)| b.tasks_executed) + .map(|(p, _)| p.clone()) + .unwrap_or_else(|| "unknown".to_string()); + + let most_efficient_provider = provider_costs + .iter() + .min_by(|(_p1, b1), (_p2, b2)| { + b1.cost_per_task_cents + .partial_cmp(&b2.cost_per_task_cents) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(p, _)| p.clone()) + .unwrap_or_else(|| "unknown".to_string()); + + CostEfficiencyReport { + period, + total_cost_cents, + total_tasks: executions.len() as u32, + cost_per_task_cents: if executions.is_empty() { + 0.0 + } else { + total_cost_cents as f32 / executions.len() as f32 + }, + most_used_provider, + most_efficient_provider, + provider_costs, + } + } + + /// Generate overall system dashboard metrics + pub fn generate_dashboard_metrics( + executions: Vec, + period: TimePeriod, + ) -> DashboardMetrics { + if executions.is_empty() { + return DashboardMetrics { + period, + total_tasks: 0, + successful_tasks: 0, + overall_success_rate: 0.5, + total_agents_active: 0, + total_task_types: 0, + avg_task_duration_ms: 0.0, + total_cost_cents: 0, + top_performing_agents: Vec::new(), + top_task_types: Vec::new(), + }; + } + + let successful_tasks = executions.iter().filter(|e| e.outcome == "success").count() as u32; + + let total_duration: u64 = executions.iter().map(|e| e.duration_ms).sum(); + let avg_duration = total_duration as f64 / executions.len() as f64; + + // Collect unique agents and task types + let mut agents: Vec = executions.iter().map(|e| e.agent_id.clone()).collect(); + agents.sort(); + agents.dedup(); + + let mut task_types: HashMap = HashMap::new(); + for exec in &executions { + *task_types.entry(exec.task_type.clone()).or_insert(0) += 1; + } + + let task_type_count = task_types.len() as u32; + let mut top_task_types: Vec<(String, u32)> = task_types.into_iter().collect(); + top_task_types.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + top_task_types.truncate(5); + + // Calculate cost (simplified) + let total_cost_cents: u32 = executions + .iter() + .map(|e| ((e.input_tokens + e.output_tokens) / 1000) as u32) + .sum(); + + DashboardMetrics { + period, + total_tasks: executions.len() as u32, + successful_tasks, + overall_success_rate: successful_tasks as f64 / executions.len() as f64, + total_agents_active: agents.len() as u32, + total_task_types: task_type_count, + avg_task_duration_ms: avg_duration, + total_cost_cents, + top_performing_agents: Vec::new(), // Computed separately + top_task_types, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn create_test_execution( + agent_id: &str, + task_type: &str, + success: bool, + duration_ms: u64, + ) -> PersistedExecution { + PersistedExecution { + execution_id: uuid::Uuid::new_v4().to_string(), + task_description: "test".to_string(), + agent_id: agent_id.to_string(), + agent_role: None, + provider: "claude".to_string(), + cost_cents: 100, + outcome: if success { "success" } else { "failure" }.to_string(), + duration_ms, + input_tokens: 5000, + output_tokens: 2000, + task_type: task_type.to_string(), + error_message: None, + solution: None, + embedding: vec![0.1; 1536], + executed_at: Utc::now().to_rfc3339(), + created_at: Utc::now().to_rfc3339(), + } + } + + #[test] + fn test_agent_performance_calculation() { + let executions = vec![ + create_test_execution("agent-1", "coding", true, 1000), + create_test_execution("agent-1", "coding", true, 2000), + create_test_execution("agent-1", "coding", false, 1500), + ]; + + let perf = + KGAnalytics::calculate_agent_performance(executions, TimePeriod::LastDay, "agent-1"); + + assert_eq!(perf.tasks_executed, 3); + assert_eq!(perf.tasks_successful, 2); + assert!((perf.success_rate - 2.0 / 3.0).abs() < 0.01); + assert!((perf.avg_duration_ms - 1500.0).abs() < 0.01); + } + + #[test] + fn test_task_type_analysis() { + let executions = vec![ + create_test_execution("agent-1", "coding", true, 1000), + create_test_execution("agent-2", "coding", true, 1200), + create_test_execution("agent-1", "coding", false, 1100), + ]; + + let analysis = KGAnalytics::analyze_task_type(executions, "coding"); + + assert_eq!(analysis.task_type, "coding"); + assert_eq!(analysis.total_executions, 3); + assert_eq!(analysis.successful_executions, 2); + assert!((analysis.success_rate - 2.0 / 3.0).abs() < 0.01); + assert_eq!(analysis.agents_involved.len(), 2); + } + + #[test] + fn test_learning_progress_calculation() { + let executions = vec![ + create_test_execution("agent-1", "coding", false, 1000), + create_test_execution("agent-1", "coding", true, 1100), + create_test_execution("agent-1", "coding", true, 1200), + create_test_execution("agent-1", "coding", true, 1300), + ]; + + let progress = + KGAnalytics::calculate_learning_progress(executions, Vec::new(), "agent-1", "coding"); + + assert_eq!(progress.agent_id, "agent-1"); + assert!((progress.current_success_rate - 0.75).abs() < 0.01); + assert!(progress.improvement > 0.0); + } + + #[test] + fn test_dashboard_metrics() { + let executions = vec![ + create_test_execution("agent-1", "coding", true, 1000), + create_test_execution("agent-2", "review", true, 1100), + create_test_execution("agent-1", "coding", false, 1200), + ]; + + let metrics = KGAnalytics::generate_dashboard_metrics(executions, TimePeriod::LastDay); + + assert_eq!(metrics.total_tasks, 3); + assert_eq!(metrics.successful_tasks, 2); + assert!((metrics.overall_success_rate - 2.0 / 3.0).abs() < 0.01); + assert_eq!(metrics.total_agents_active, 2); + assert_eq!(metrics.total_task_types, 2); + } + + #[test] + fn test_cost_report_generation() { + let executions = vec![ + create_test_execution("agent-1", "coding", true, 1000), + create_test_execution("agent-1", "coding", true, 1100), + ]; + + let report = KGAnalytics::generate_cost_report(executions, TimePeriod::LastDay); + + assert_eq!(report.total_tasks, 2); + assert!(report.total_cost_cents > 0); + assert!(report.cost_per_task_cents > 0.0); + } + + #[test] + fn test_empty_executions() { + let perf = + KGAnalytics::calculate_agent_performance(Vec::new(), TimePeriod::LastDay, "agent-1"); + assert_eq!(perf.tasks_executed, 0); + assert_eq!(perf.success_rate, 0.5); + } + + #[test] + fn test_time_period_descriptions() { + assert_eq!(TimePeriod::LastHour.description(), "Last Hour"); + assert_eq!(TimePeriod::LastDay.description(), "Last 24 Hours"); + assert_eq!(TimePeriod::LastWeek.description(), "Last 7 Days"); + assert_eq!(TimePeriod::LastMonth.description(), "Last 30 Days"); + assert_eq!(TimePeriod::AllTime.description(), "All Time"); + } +} + +/// Implement AnalyticsComputation trait for KGAnalytics +#[async_trait] +impl AnalyticsComputation for KGAnalytics { + async fn compute_agent_performance( + &self, + executions: Vec, + period: crate::metrics::TimePeriod, + agent_id: &str, + ) -> anyhow::Result { + Ok(KGAnalytics::calculate_agent_performance( + executions, period, agent_id, + )) + } + + async fn compute_task_type_analytics( + &self, + executions: Vec, + task_type: &str, + ) -> anyhow::Result { + Ok(KGAnalytics::analyze_task_type(executions, task_type)) + } + + async fn compute_dashboard_metrics( + &self, + executions: Vec, + period: crate::metrics::TimePeriod, + ) -> anyhow::Result { + Ok(KGAnalytics::generate_dashboard_metrics(executions, period)) + } + + async fn compute_cost_report( + &self, + executions: Vec, + period: crate::metrics::TimePeriod, + ) -> anyhow::Result { + Ok(KGAnalytics::generate_cost_report(executions, period)) + } + + async fn compute_learning_progress( + &self, + recent_executions: Vec, + historical_executions: Vec, + agent_id: &str, + task_type: &str, + ) -> anyhow::Result { + Ok(KGAnalytics::calculate_learning_progress( + recent_executions, + historical_executions, + agent_id, + task_type, + )) + } +} diff --git a/crates/vapora-knowledge-graph/src/learning.rs b/crates/vapora-knowledge-graph/src/learning.rs index 839a4fb..efa63b4 100644 --- a/crates/vapora-knowledge-graph/src/learning.rs +++ b/crates/vapora-knowledge-graph/src/learning.rs @@ -45,10 +45,7 @@ pub fn calculate_learning_curve( /// Returns weighted success rate accounting for time decay. /// /// Formula: weight = 3.0 * e^(-days_ago / 7.0) for days_ago < 7, else e^(-days_ago / 7.0) -pub fn apply_recency_bias( - executions: Vec, - decay_days: u32, -) -> f64 { +pub fn apply_recency_bias(executions: Vec, decay_days: u32) -> f64 { if executions.is_empty() { return 0.5; } @@ -136,9 +133,7 @@ pub fn calculate_task_type_metrics( } /// Calculate average execution metrics (duration, etc). -pub fn calculate_execution_averages( - executions: Vec, -) -> (f64, f64, f64) { +pub fn calculate_execution_averages(executions: Vec) -> (f64, f64, f64) { if executions.is_empty() { return (0.0, 0.0, 0.0); } @@ -203,7 +198,10 @@ mod tests { ]; let biased = apply_recency_bias(executions, 7); - assert!(biased > 0.5, "Recent success should pull weighted average above 0.5"); + assert!( + biased > 0.5, + "Recent success should pull weighted average above 0.5" + ); } #[test] @@ -243,7 +241,10 @@ mod tests { let curve = calculate_learning_curve(executions, 1); assert!(curve.len() >= 2); for i in 1..curve.len() { - assert!(curve[i - 1].0 <= curve[i].0, "Curve must be chronologically sorted"); + assert!( + curve[i - 1].0 <= curve[i].0, + "Curve must be chronologically sorted" + ); } } diff --git a/crates/vapora-knowledge-graph/src/lib.rs b/crates/vapora-knowledge-graph/src/lib.rs index d462a6f..ca2ed1b 100644 --- a/crates/vapora-knowledge-graph/src/lib.rs +++ b/crates/vapora-knowledge-graph/src/lib.rs @@ -4,15 +4,22 @@ // Phase 5.3: Learning curve analytics with recency bias // Phase 5.5: Persistence layer for durable storage +pub mod analytics; pub mod error; pub mod learning; +pub mod metrics; pub mod models; pub mod persistence; pub mod reasoning; pub mod temporal_kg; +pub use analytics::{ + AgentPerformance, CostEfficiencyReport, DashboardMetrics, KGAnalytics, LearningProgress, + PerformanceTrend, TaskTypeAnalytics, +}; pub use error::{KGError, Result}; pub use learning::{apply_recency_bias, calculate_learning_curve}; +pub use metrics::{AnalyticsComputation, TimePeriod}; pub use models::*; pub use persistence::{KGPersistence, PersistedExecution}; pub use reasoning::ReasoningEngine; diff --git a/crates/vapora-knowledge-graph/src/metrics.rs b/crates/vapora-knowledge-graph/src/metrics.rs new file mode 100644 index 0000000..54c0c5c --- /dev/null +++ b/crates/vapora-knowledge-graph/src/metrics.rs @@ -0,0 +1,81 @@ +// Metrics computation trait for breaking persistence ↔ analytics circular dependency +// Phase 5.5: Abstraction layer for analytics computations + +use async_trait::async_trait; +use chrono::Duration; +use serde::{Deserialize, Serialize}; + +/// Time period for analytics grouping +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum TimePeriod { + LastHour, + LastDay, + LastWeek, + LastMonth, + AllTime, +} + +impl TimePeriod { + pub fn duration(&self) -> Duration { + match self { + TimePeriod::LastHour => Duration::hours(1), + TimePeriod::LastDay => Duration::days(1), + TimePeriod::LastWeek => Duration::days(7), + TimePeriod::LastMonth => Duration::days(30), + TimePeriod::AllTime => Duration::days(36500), // 100 years + } + } + + pub fn description(&self) -> &'static str { + match self { + TimePeriod::LastHour => "Last Hour", + TimePeriod::LastDay => "Last 24 Hours", + TimePeriod::LastWeek => "Last 7 Days", + TimePeriod::LastMonth => "Last 30 Days", + TimePeriod::AllTime => "All Time", + } + } +} + +/// Abstraction for computing analytics metrics from execution data. +/// Breaks the persistence ↔ analytics circular dependency by inverting control flow. +#[async_trait] +pub trait AnalyticsComputation: Send + Sync { + /// Compute agent performance metrics for a time period. + async fn compute_agent_performance( + &self, + executions: Vec, + period: TimePeriod, + agent_id: &str, + ) -> anyhow::Result; + + /// Analyze metrics by task type. + async fn compute_task_type_analytics( + &self, + executions: Vec, + task_type: &str, + ) -> anyhow::Result; + + /// Generate dashboard metrics snapshot. + async fn compute_dashboard_metrics( + &self, + executions: Vec, + period: TimePeriod, + ) -> anyhow::Result; + + /// Generate cost report for auditing. + async fn compute_cost_report( + &self, + executions: Vec, + period: TimePeriod, + ) -> anyhow::Result; + + /// Calculate learning progress for agent + task type + async fn compute_learning_progress( + &self, + recent_executions: Vec, + historical_executions: Vec, + agent_id: &str, + task_type: &str, + ) -> anyhow::Result; +} diff --git a/crates/vapora-knowledge-graph/src/models.rs b/crates/vapora-knowledge-graph/src/models.rs index f5abdd3..2707810 100644 --- a/crates/vapora-knowledge-graph/src/models.rs +++ b/crates/vapora-knowledge-graph/src/models.rs @@ -7,6 +7,7 @@ pub struct ExecutionRecord { pub id: String, pub task_id: String, pub agent_id: String, + pub agent_role: Option, pub task_type: String, pub description: String, pub root_cause: Option, @@ -14,6 +15,8 @@ pub struct ExecutionRecord { pub duration_ms: u64, pub input_tokens: u64, pub output_tokens: u64, + pub cost_cents: u32, + pub provider: String, pub success: bool, pub error: Option, pub timestamp: DateTime, @@ -36,9 +39,16 @@ pub enum ExecutionRelation { /// Record A caused/led to record B CausedBy { from_id: String, to_id: String }, /// Record A is similar to record B - SimilarTo { record_a_id: String, record_b_id: String, similarity_score: f64 }, + SimilarTo { + record_a_id: String, + record_b_id: String, + similarity_score: f64, + }, /// Record A resolved problem from record B - ResolvedBy { problem_id: String, solution_id: String }, + ResolvedBy { + problem_id: String, + solution_id: String, + }, } /// Query result with ranking @@ -81,3 +91,50 @@ pub struct CausalRelationship { pub confidence: f64, pub frequency: u32, } + +/// Provider analytics data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderAnalytics { + pub provider: String, + pub total_cost_cents: u32, + pub total_tasks: u64, + pub successful_tasks: u64, + pub failed_tasks: u64, + pub success_rate: f64, + pub avg_cost_per_task_cents: f64, + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub cost_per_1m_tokens: f64, +} + +/// Provider efficiency ranking +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderEfficiency { + pub provider: String, + pub quality_score: f64, + pub cost_score: f64, + pub efficiency_ratio: f64, + pub rank: u32, +} + +/// Provider performance by task type +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderTaskTypeMetrics { + pub provider: String, + pub task_type: String, + pub total_cost_cents: u32, + pub task_count: u64, + pub success_rate: f64, + pub avg_duration_ms: f64, +} + +/// Provider cost forecast +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderCostForecast { + pub provider: String, + pub current_daily_cost_cents: u32, + pub projected_weekly_cost_cents: u32, + pub projected_monthly_cost_cents: u32, + pub trend: String, + pub confidence: f64, +} diff --git a/crates/vapora-knowledge-graph/src/persistence.rs b/crates/vapora-knowledge-graph/src/persistence.rs index f33ae02..2e56080 100644 --- a/crates/vapora-knowledge-graph/src/persistence.rs +++ b/crates/vapora-knowledge-graph/src/persistence.rs @@ -1,10 +1,12 @@ // KG Persistence Layer // Phase 5.5: Persist execution history to SurrealDB for durability and analytics +use crate::metrics::{AnalyticsComputation, TimePeriod}; use crate::models::ExecutionRecord; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use surrealdb::engine::remote::ws::Client; use surrealdb::Surreal; use tracing::debug; @@ -13,6 +15,9 @@ pub struct PersistedExecution { pub execution_id: String, pub task_description: String, pub agent_id: String, + pub agent_role: Option, + pub provider: String, + pub cost_cents: u32, pub outcome: String, pub duration_ms: u64, pub input_tokens: u64, @@ -32,6 +37,9 @@ impl PersistedExecution { execution_id: record.id.clone(), task_description: record.description.clone(), agent_id: record.agent_id.clone(), + agent_role: record.agent_role.clone(), + provider: record.provider.clone(), + cost_cents: record.cost_cents, outcome: if record.success { "success".to_string() } else { @@ -50,15 +58,40 @@ impl PersistedExecution { } } -#[derive(Debug, Clone)] pub struct KGPersistence { - db: Arc>, + db: Arc>, + analytics: Option>, +} + +impl std::fmt::Debug for KGPersistence { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KGPersistence") + .field("db", &"") + .field("analytics", &"") + .finish() + } } impl KGPersistence { /// Create new persistence layer - pub fn new(db: Arc>) -> Self { - Self { db } + pub fn new(db: Surreal) -> Self { + Self { + db: Arc::new(db), + analytics: None, + } + } + + /// Create with analytics computation provider + pub fn with_analytics(db: Surreal, analytics: Arc) -> Self { + Self { + db: Arc::new(db), + analytics: Some(analytics), + } + } + + /// Set analytics computation provider + pub fn set_analytics(&mut self, analytics: Arc) { + self.analytics = Some(analytics); } /// Persist a single execution record @@ -78,7 +111,10 @@ impl KGPersistence { } /// Persist multiple execution records (batch operation) - pub async fn persist_executions(&self, executions: Vec) -> anyhow::Result<()> { + pub async fn persist_executions( + &self, + executions: Vec, + ) -> anyhow::Result<()> { if executions.is_empty() { return Ok(()); } @@ -102,7 +138,10 @@ impl KGPersistence { // SurrealDB vector similarity queries require different syntax // For now, return recent successful executions - let query = format!("SELECT * FROM kg_executions WHERE outcome = 'success' LIMIT {}", limit); + let query = format!( + "SELECT * FROM kg_executions WHERE outcome = 'success' LIMIT {}", + limit + ); let mut response = self.db.query(&query).await?; let results: Vec = response.take(0)?; @@ -221,7 +260,10 @@ impl KGPersistence { agent_id: &str, limit: usize, ) -> anyhow::Result> { - debug!("Fetching all executions for agent {} (limit: {})", agent_id, limit); + debug!( + "Fetching all executions for agent {} (limit: {})", + agent_id, limit + ); let query = format!( "SELECT * FROM kg_executions WHERE agent_id = '{}' ORDER BY executed_at DESC LIMIT {}", @@ -267,6 +309,134 @@ impl KGPersistence { Ok(()) } + + /// Get agent performance metrics for a specific time period + pub async fn get_agent_performance( + &self, + agent_id: &str, + period: TimePeriod, + ) -> anyhow::Result { + // Fetch executions for agent in the given period + let executions = self.get_agent_executions(agent_id, 1000).await?; + + // Filter by time period + let cutoff = Utc::now() - period.duration(); + let filtered: Vec = executions + .into_iter() + .filter(|e| { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&e.executed_at) { + dt.with_timezone(&Utc) > cutoff + } else { + false + } + }) + .collect(); + + if let Some(analytics) = &self.analytics { + analytics + .compute_agent_performance(filtered, period, agent_id) + .await + } else { + anyhow::bail!("Analytics computation provider not set") + } + } + + /// Get task type analytics across all agents + pub async fn get_task_type_analytics( + &self, + task_type: &str, + period: TimePeriod, + ) -> anyhow::Result { + // Fetch executions for task type + let query = format!( + "SELECT * FROM kg_executions WHERE task_type = '{}' ORDER BY executed_at DESC LIMIT 1000", + task_type + ); + + let mut response = self.db.query(&query).await?; + let executions: Vec = response.take(0)?; + + // Filter by time period + let cutoff = Utc::now() - period.duration(); + let filtered: Vec = executions + .into_iter() + .filter(|e| { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&e.executed_at) { + dt.with_timezone(&Utc) > cutoff + } else { + false + } + }) + .collect(); + + if let Some(analytics) = &self.analytics { + analytics + .compute_task_type_analytics(filtered, task_type) + .await + } else { + anyhow::bail!("Analytics computation provider not set") + } + } + + /// Get comprehensive dashboard metrics for the system + pub async fn get_dashboard_metrics( + &self, + period: TimePeriod, + ) -> anyhow::Result { + // Fetch all recent executions + let query = "SELECT * FROM kg_executions ORDER BY executed_at DESC LIMIT 5000"; + let mut response = self.db.query(query).await?; + let executions: Vec = response.take(0)?; + + // Filter by time period + let cutoff = Utc::now() - period.duration(); + let filtered: Vec = executions + .into_iter() + .filter(|e| { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&e.executed_at) { + dt.with_timezone(&Utc) > cutoff + } else { + false + } + }) + .collect(); + + if let Some(analytics) = &self.analytics { + analytics.compute_dashboard_metrics(filtered, period).await + } else { + anyhow::bail!("Analytics computation provider not set") + } + } + + /// Get cost efficiency report for the period + pub async fn get_cost_report( + &self, + period: TimePeriod, + ) -> anyhow::Result { + // Fetch all recent executions + let query = "SELECT * FROM kg_executions ORDER BY executed_at DESC LIMIT 5000"; + let mut response = self.db.query(query).await?; + let executions: Vec = response.take(0)?; + + // Filter by time period + let cutoff = Utc::now() - period.duration(); + let filtered: Vec = executions + .into_iter() + .filter(|e| { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&e.executed_at) { + dt.with_timezone(&Utc) > cutoff + } else { + false + } + }) + .collect(); + + if let Some(analytics) = &self.analytics { + analytics.compute_cost_report(filtered, period).await + } else { + anyhow::bail!("Analytics computation provider not set") + } + } } #[cfg(test)] @@ -279,11 +449,14 @@ mod tests { id: "exec-1".to_string(), task_id: "task-1".to_string(), agent_id: "agent-1".to_string(), + agent_role: None, task_type: "coding".to_string(), description: "Write code".to_string(), duration_ms: 5000, input_tokens: 100, output_tokens: 250, + cost_cents: 75, + provider: "claude".to_string(), success: true, error: None, solution: Some("Use async/await".to_string()), diff --git a/crates/vapora-knowledge-graph/src/reasoning.rs b/crates/vapora-knowledge-graph/src/reasoning.rs index 58654e4..e180265 100644 --- a/crates/vapora-knowledge-graph/src/reasoning.rs +++ b/crates/vapora-knowledge-graph/src/reasoning.rs @@ -74,10 +74,7 @@ impl ReasoningEngine { } /// Predict success probability for a task - pub fn predict_success( - task_type: &str, - similar_records: &[ExecutionRecord], - ) -> (f64, String) { + pub fn predict_success(task_type: &str, similar_records: &[ExecutionRecord]) -> (f64, String) { if similar_records.is_empty() { return (0.5, "No historical data available".to_string()); } @@ -109,7 +106,10 @@ impl ReasoningEngine { /// Estimate task duration pub fn estimate_duration(similar_records: &[ExecutionRecord]) -> (u64, String) { if similar_records.is_empty() { - return (300_000, "No historical data - using default 5 minutes".to_string()); + return ( + 300_000, + "No historical data - using default 5 minutes".to_string(), + ); } let mut durations: Vec = similar_records.iter().map(|r| r.duration_ms).collect(); @@ -164,7 +164,10 @@ impl ReasoningEngine { let mut chains: Vec> = Vec::new(); let mut visited = std::collections::HashSet::new(); - for record in records.iter().filter(|r| !r.success && r.root_cause.is_some()) { + for record in records + .iter() + .filter(|r| !r.success && r.root_cause.is_some()) + { if visited.contains(&record.id) { continue; } @@ -227,8 +230,8 @@ impl ReasoningEngine { )); // Insight 4: Agent expertise distribution - let avg_expertise = profiles.iter().map(|p| p.expertise_score).sum::() - / profiles.len().max(1) as f64; + let avg_expertise = + profiles.iter().map(|p| p.expertise_score).sum::() / profiles.len().max(1) as f64; if avg_expertise < 70.0 { insights.push(format!( "⚠ Average agent expertise is below target ({:.0}%)", @@ -255,6 +258,7 @@ mod tests { id: "1".to_string(), task_id: "t1".to_string(), agent_id: "a1".to_string(), + agent_role: None, task_type: "dev".to_string(), description: "test".to_string(), root_cause: None, @@ -262,6 +266,8 @@ mod tests { duration_ms: 1000, input_tokens: 100, output_tokens: 50, + cost_cents: 40, + provider: "claude".to_string(), success: true, error: None, timestamp: Utc::now(), @@ -270,6 +276,7 @@ mod tests { id: "2".to_string(), task_id: "t2".to_string(), agent_id: "a1".to_string(), + agent_role: None, task_type: "dev".to_string(), description: "test".to_string(), root_cause: None, @@ -277,6 +284,8 @@ mod tests { duration_ms: 1000, input_tokens: 100, output_tokens: 50, + cost_cents: 45, + provider: "claude".to_string(), success: true, error: None, timestamp: Utc::now(), @@ -289,23 +298,24 @@ mod tests { #[test] fn test_estimate_duration() { - let records = vec![ - ExecutionRecord { - id: "1".to_string(), - task_id: "t1".to_string(), - agent_id: "a1".to_string(), - task_type: "dev".to_string(), - description: "test".to_string(), - root_cause: None, - solution: None, - duration_ms: 1000, - input_tokens: 100, - output_tokens: 50, - success: true, - error: None, - timestamp: Utc::now(), - }, - ]; + let records = vec![ExecutionRecord { + id: "1".to_string(), + task_id: "t1".to_string(), + agent_id: "a1".to_string(), + agent_role: None, + task_type: "dev".to_string(), + description: "test".to_string(), + root_cause: None, + solution: None, + duration_ms: 1000, + input_tokens: 100, + output_tokens: 50, + cost_cents: 50, + provider: "claude".to_string(), + success: true, + error: None, + timestamp: Utc::now(), + }]; let (duration, _) = ReasoningEngine::estimate_duration(&records); assert_eq!(duration, 1000); diff --git a/crates/vapora-knowledge-graph/src/temporal_kg.rs b/crates/vapora-knowledge-graph/src/temporal_kg.rs index bf97fdd..57bac0d 100644 --- a/crates/vapora-knowledge-graph/src/temporal_kg.rs +++ b/crates/vapora-knowledge-graph/src/temporal_kg.rs @@ -96,7 +96,11 @@ impl TemporalKG { } /// Query similar tasks within 90 days (Phase 5.1: uses embeddings if available) - pub async fn query_similar_tasks(&self, task_type: &str, description: &str) -> Result> { + pub async fn query_similar_tasks( + &self, + task_type: &str, + description: &str, + ) -> Result> { let now = Utc::now(); let cutoff = now - Duration::days(90); @@ -129,9 +133,8 @@ impl TemporalKG { } // Sort by similarity descending - similar_with_scores.sort_by(|a, b| { - b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal) - }); + similar_with_scores + .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); Ok(similar_with_scores .into_iter() @@ -141,7 +144,11 @@ impl TemporalKG { } /// Get recommendations from similar successful tasks (Phase 5.1: embedding-based) - pub async fn get_recommendations(&self, task_type: &str, description: &str) -> Result> { + pub async fn get_recommendations( + &self, + task_type: &str, + description: &str, + ) -> Result> { let similar_tasks = self.query_similar_tasks(task_type, description).await?; let query_embedding = self.get_or_embed(description).await.ok().flatten(); @@ -227,11 +234,7 @@ impl TemporalKG { /// Get knowledge graph statistics pub async fn get_statistics(&self) -> Result { let total_records = self.records.len() as u64; - let successful = self - .records - .iter() - .filter(|e| e.value().success) - .count() as u64; + let successful = self.records.iter().filter(|e| e.value().success).count() as u64; let failed = total_records - successful; let mut avg_duration = 0.0; @@ -266,7 +269,10 @@ impl TemporalKG { } /// Find causal relationships (error patterns) - Phase 5.1: embedding-based - pub async fn find_causal_relationships(&self, cause_pattern: &str) -> Result> { + pub async fn find_causal_relationships( + &self, + cause_pattern: &str, + ) -> Result> { let mut relationships = Vec::new(); let threshold = 0.5; let pattern_embedding = self.get_or_embed(cause_pattern).await.ok().flatten(); @@ -288,7 +294,10 @@ impl TemporalKG { if similarity >= threshold { relationships.push(CausalRelationship { cause: error.clone(), - effect: record.solution.clone().unwrap_or_else(|| "unknown".to_string()), + effect: record + .solution + .clone() + .unwrap_or_else(|| "unknown".to_string()), confidence: similarity, frequency: 1, }); @@ -298,9 +307,11 @@ impl TemporalKG { } // Deduplicate and count occurrences - let mut deduped: std::collections::HashMap = std::collections::HashMap::new(); + let mut deduped: std::collections::HashMap = + std::collections::HashMap::new(); for rel in relationships { - deduped.entry(rel.cause.clone()) + deduped + .entry(rel.cause.clone()) .and_modify(|r| r.frequency += 1) .or_insert(rel); } @@ -369,11 +380,14 @@ mod tests { id: "exec-1".to_string(), task_id: "task-1".to_string(), agent_id: "agent-1".to_string(), + agent_role: None, task_type: "coding".to_string(), description: "Write a Rust function".to_string(), duration_ms: 5000, input_tokens: 100, output_tokens: 250, + cost_cents: 50, + provider: "claude".to_string(), success: true, error: None, solution: Some("Use async/await pattern".to_string()), @@ -397,11 +411,14 @@ mod tests { id: "exec-1".to_string(), task_id: "task-1".to_string(), agent_id: "agent-1".to_string(), + agent_role: None, task_type: "coding".to_string(), description: "Write a Rust function for data processing".to_string(), duration_ms: 5000, input_tokens: 100, output_tokens: 250, + cost_cents: 60, + provider: "claude".to_string(), success: true, error: None, solution: Some("Use async/await".to_string()), @@ -429,11 +446,14 @@ mod tests { id: "exec-1".to_string(), task_id: "task-1".to_string(), agent_id: "agent-1".to_string(), + agent_role: None, task_type: "coding".to_string(), description: "Write code".to_string(), duration_ms: 5000, input_tokens: 100, output_tokens: 250, + cost_cents: 55, + provider: "claude".to_string(), success: true, error: None, solution: None, diff --git a/crates/vapora-llm-router/src/budget.rs b/crates/vapora-llm-router/src/budget.rs index e352c18..2092ac8 100644 --- a/crates/vapora-llm-router/src/budget.rs +++ b/crates/vapora-llm-router/src/budget.rs @@ -115,19 +115,22 @@ impl BudgetConfig { for (role, budget) in &self.budgets { if budget.monthly_limit_cents == 0 { - return Err(BudgetConfigError::ValidationError( - format!("Role {} has zero monthly limit", role), - )); + return Err(BudgetConfigError::ValidationError(format!( + "Role {} has zero monthly limit", + role + ))); } if budget.weekly_limit_cents == 0 { - return Err(BudgetConfigError::ValidationError( - format!("Role {} has zero weekly limit", role), - )); + return Err(BudgetConfigError::ValidationError(format!( + "Role {} has zero weekly limit", + role + ))); } if budget.alert_threshold < 0.0 || budget.alert_threshold > 1.0 { - return Err(BudgetConfigError::ValidationError( - format!("Role {} has invalid alert_threshold: {}", role, budget.alert_threshold), - )); + return Err(BudgetConfigError::ValidationError(format!( + "Role {} has invalid alert_threshold: {}", + role, budget.alert_threshold + ))); } } @@ -172,7 +175,9 @@ impl BudgetManager { let budgets = self.budgets.read().await; let mut spending = self.spending.write().await; - let budget = budgets.get(role).ok_or_else(|| format!("Unknown role: {}", role))?; + let budget = budgets + .get(role) + .ok_or_else(|| format!("Unknown role: {}", role))?; let spending_entry = spending .entry(role.to_string()) .or_insert_with(|| RoleSpending { @@ -305,8 +310,9 @@ impl BudgetManager { let monthly_remaining = budget .monthly_limit_cents .saturating_sub(sp.current_month.spent_cents); - let weekly_remaining = - budget.weekly_limit_cents.saturating_sub(sp.current_week.spent_cents); + let weekly_remaining = budget + .weekly_limit_cents + .saturating_sub(sp.current_week.spent_cents); let monthly_utilization = if budget.monthly_limit_cents > 0 { sp.current_month.spent_cents as f32 / budget.monthly_limit_cents as f32 @@ -321,9 +327,8 @@ impl BudgetManager { }; let exceeded = monthly_remaining == 0 || weekly_remaining == 0; - let near_threshold = - monthly_utilization >= budget.alert_threshold - || weekly_utilization >= budget.alert_threshold; + let near_threshold = monthly_utilization >= budget.alert_threshold + || weekly_utilization >= budget.alert_threshold; BudgetStatus { role: role.clone(), diff --git a/crates/vapora-llm-router/src/config.rs b/crates/vapora-llm-router/src/config.rs index a501aef..b395484 100644 --- a/crates/vapora-llm-router/src/config.rs +++ b/crates/vapora-llm-router/src/config.rs @@ -128,9 +128,9 @@ impl LLMRouterConfig { /// Find routing rule matching conditions pub fn find_rule(&self, conditions: &HashMap) -> Option<&RoutingRule> { self.routing_rules.iter().find(|rule| { - rule.condition.iter().all(|(key, value)| { - conditions.get(key).map(|v| v == value).unwrap_or(false) - }) + rule.condition + .iter() + .all(|(key, value)| conditions.get(key).map(|v| v == value).unwrap_or(false)) }) } } @@ -163,10 +163,7 @@ mod tests { std::env::set_var("TEST_VAR", "test_value"); assert_eq!(expand_env_var("${TEST_VAR}"), "test_value"); assert_eq!(expand_env_var("plain_text"), "plain_text"); - assert_eq!( - expand_env_var("${NONEXISTENT:-default}"), - "default" - ); + assert_eq!(expand_env_var("${NONEXISTENT:-default}"), "default"); } #[test] diff --git a/crates/vapora-llm-router/src/cost_ranker.rs b/crates/vapora-llm-router/src/cost_ranker.rs index b6e5611..eb35772 100644 --- a/crates/vapora-llm-router/src/cost_ranker.rs +++ b/crates/vapora-llm-router/src/cost_ranker.rs @@ -22,11 +22,7 @@ impl CostRanker { /// Estimate cost in cents for token usage on provider. /// Formula: (input_tokens * rate_in + output_tokens * rate_out) / 1M * 100 /// Costs are stored in dollars, converted to cents for calculation. - pub fn estimate_cost( - config: &ProviderConfig, - input_tokens: u64, - output_tokens: u64, - ) -> u32 { + pub fn estimate_cost(config: &ProviderConfig, input_tokens: u64, output_tokens: u64) -> u32 { // Convert dollar rates to cents let input_cost_cents = config.cost_per_1m_input * 100.0; let output_cost_cents = config.cost_per_1m_output * 100.0; @@ -42,11 +38,11 @@ impl CostRanker { pub fn get_quality_score(provider: &str, task_type: &str, _quality_data: Option) -> f64 { // Default quality scores until KG integration provides actual metrics match (provider, task_type) { - ("claude", _) => 0.95, // Highest quality - ("gpt4", _) => 0.92, // Very good - ("gemini", _) => 0.88, // Good - ("ollama", _) => 0.75, // Decent for local - (_, _) => 0.5, // Unknown + ("claude", _) => 0.95, // Highest quality + ("gpt4", _) => 0.92, // Very good + ("gemini", _) => 0.88, // Good + ("ollama", _) => 0.75, // Decent for local + (_, _) => 0.5, // Unknown } } @@ -126,7 +122,13 @@ impl CostRanker { let ranked = Self::rank_by_efficiency(providers, task_type, input_tokens, output_tokens); ranked .into_iter() - .map(|score| (score.provider, score.estimated_cost_cents, score.cost_efficiency)) + .map(|score| { + ( + score.provider, + score.estimated_cost_cents, + score.cost_efficiency, + ) + }) .collect() } } diff --git a/crates/vapora-llm-router/src/embeddings.rs b/crates/vapora-llm-router/src/embeddings.rs index 8c8ae73..64fe48f 100644 --- a/crates/vapora-llm-router/src/embeddings.rs +++ b/crates/vapora-llm-router/src/embeddings.rs @@ -317,9 +317,7 @@ impl EmbeddingProvider for HuggingFaceEmbedding { // Factory function to create providers from environment/config // ============================================================================ -pub async fn create_embedding_provider( - provider_name: &str, -) -> Result> { +pub async fn create_embedding_provider(provider_name: &str) -> Result> { match provider_name.to_lowercase().as_str() { "ollama" => { let endpoint = std::env::var("OLLAMA_ENDPOINT") @@ -332,9 +330,8 @@ pub async fn create_embedding_provider( } "openai" => { - let api_key = std::env::var("OPENAI_API_KEY").map_err(|_| { - EmbeddingError::ConfigError("OPENAI_API_KEY not set".to_string()) - })?; + let api_key = std::env::var("OPENAI_API_KEY") + .map_err(|_| EmbeddingError::ConfigError("OPENAI_API_KEY not set".to_string()))?; let model = std::env::var("OPENAI_EMBEDDING_MODEL") .unwrap_or_else(|_| "text-embedding-3-small".to_string()); @@ -377,7 +374,8 @@ mod tests { #[test] fn test_openai_provider_creation() { - let openai = OpenAIEmbedding::new("test-key".to_string(), "text-embedding-3-small".to_string()); + let openai = + OpenAIEmbedding::new("test-key".to_string(), "text-embedding-3-small".to_string()); assert_eq!(openai.provider_name(), "openai"); assert_eq!(openai.model_name(), "text-embedding-3-small"); assert_eq!(openai.embedding_dim(), 1536); @@ -385,10 +383,8 @@ mod tests { #[test] fn test_huggingface_provider_creation() { - let hf = HuggingFaceEmbedding::new( - "test-key".to_string(), - "BAAI/bge-small-en-v1.5".to_string(), - ); + let hf = + HuggingFaceEmbedding::new("test-key".to_string(), "BAAI/bge-small-en-v1.5".to_string()); assert_eq!(hf.provider_name(), "huggingface"); assert_eq!(hf.model_name(), "BAAI/bge-small-en-v1.5"); assert_eq!(hf.embedding_dim(), 1536); diff --git a/crates/vapora-llm-router/src/lib.rs b/crates/vapora-llm-router/src/lib.rs index 349901a..3091ddf 100644 --- a/crates/vapora-llm-router/src/lib.rs +++ b/crates/vapora-llm-router/src/lib.rs @@ -10,7 +10,6 @@ pub mod cost_tracker; pub mod embeddings; pub mod providers; pub mod router; -pub mod typedialog_adapter; // Re-exports pub use budget::{BudgetConfig, BudgetConfigError, BudgetManager, BudgetStatus, RoleBudget}; @@ -23,8 +22,6 @@ pub use embeddings::{ OllamaEmbedding, OpenAIEmbedding, }; pub use providers::{ - ClaudeClient, CompletionResponse, LLMClient, OllamaClient, OpenAIClient, - ProviderError, + ClaudeClient, CompletionResponse, LLMClient, OllamaClient, OpenAIClient, ProviderError, }; pub use router::{LLMRouter, ProviderStats, RouterError}; -pub use typedialog_adapter::TypeDialogAdapter; diff --git a/crates/vapora-llm-router/src/providers.rs b/crates/vapora-llm-router/src/providers.rs index 5028f5b..41d070c 100644 --- a/crates/vapora-llm-router/src/providers.rs +++ b/crates/vapora-llm-router/src/providers.rs @@ -1,7 +1,6 @@ // vapora-llm-router: LLM Provider implementations // Phase 3: Real providers via typedialog-ai -use crate::typedialog_adapter::TypeDialogAdapter; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -263,8 +262,8 @@ impl OllamaClient { #[cfg(feature = "ollama")] { use typedialog_ai::llm::providers::OllamaProvider; - let provider = OllamaProvider::new(&model) - .map_err(|e| ProviderError::LlmError(e.to_string()))?; + let provider = + OllamaProvider::new(&model).map_err(|e| ProviderError::LlmError(e.to_string()))?; let adapter = TypeDialogAdapter::new( Arc::new(provider), "ollama".to_string(), @@ -324,11 +323,258 @@ impl LLMClient for OllamaClient { } } +// ============================================================================ +// TypeDialog AI Adapter (Private module, breaks circular dependency) +// ============================================================================ + +/// Adapter wrapping typedialog-ai LlmProvider for VAPORA LLMClient trait +/// PRIVATE: Only used internally by provider implementations +pub(crate) struct TypeDialogAdapter { + provider: Arc, + provider_name: String, + cost_per_1m_input: f64, + cost_per_1m_output: f64, +} + +impl TypeDialogAdapter { + /// Create new adapter wrapping a typedialog-ai provider + pub fn new( + provider: Arc, + provider_name: String, + cost_per_1m_input: f64, + cost_per_1m_output: f64, + ) -> Self { + Self { + provider, + provider_name, + cost_per_1m_input, + cost_per_1m_output, + } + } + + /// Estimate tokens from text (fallback for providers without token counting) + fn estimate_tokens(text: &str) -> u64 { + // Rough estimate: 4 characters ≈ 1 token (works well for English/code) + (text.len() as u64).div_ceil(4) + } + + /// Build message list from prompt and optional context + fn build_messages(prompt: &str, context: Option<&str>) -> Vec { + use typedialog_ai::llm::{Message, Role}; + let mut messages = Vec::new(); + + if let Some(ctx) = context { + messages.push(Message { + role: Role::System, + content: ctx.to_string(), + }); + } + + messages.push(Message { + role: Role::User, + content: prompt.to_string(), + }); + + messages + } +} + +#[async_trait] +impl LLMClient for TypeDialogAdapter { + /// Send completion request to underlying LLM provider + async fn complete( + &self, + prompt: String, + context: Option, + ) -> Result { + use tracing::error; + let messages = Self::build_messages(&prompt, context.as_deref()); + + // Create default generation options + let options = typedialog_ai::llm::GenerationOptions { + temperature: 0.7, + max_tokens: Some(4096), + stop_sequences: vec![], + top_p: None, + top_k: None, + presence_penalty: None, + frequency_penalty: None, + }; + + let text = self + .provider + .generate(&messages, &options) + .await + .map_err(|e| { + error!("LLM generation failed: {}", e); + ProviderError::RequestFailed(e.to_string()) + })?; + + let input_tokens = Self::estimate_tokens(&prompt); + let output_tokens = Self::estimate_tokens(&text); + + Ok(CompletionResponse { + text, + input_tokens, + output_tokens, + finish_reason: "stop".to_string(), + }) + } + + /// Stream completion response token-by-token + async fn stream( + &self, + prompt: String, + ) -> Result, ProviderError> { + use futures::StreamExt; + use tracing::error; + let messages = Self::build_messages(&prompt, None); + + let options = typedialog_ai::llm::GenerationOptions { + temperature: 0.7, + max_tokens: Some(4096), + stop_sequences: vec![], + top_p: None, + top_k: None, + presence_penalty: None, + frequency_penalty: None, + }; + + let mut stream = self + .provider + .stream(&messages, &options) + .await + .map_err(|e| { + error!("LLM stream failed: {}", e); + ProviderError::RequestFailed(e.to_string()) + })?; + + let (tx, rx) = tokio::sync::mpsc::channel(100); + + tokio::spawn(async move { + while let Some(token_result) = stream.next().await { + match token_result { + Ok(token) => { + if tx.send(token).await.is_err() { + // Receiver dropped, stop streaming + break; + } + } + Err(e) => { + error!("Stream error: {}", e); + break; + } + } + } + }); + + Ok(rx) + } + + /// Cost per 1k tokens (combined estimate) + fn cost_per_1k_tokens(&self) -> f64 { + (self.cost_per_1m_input + self.cost_per_1m_output) / 1000.0 + } + + /// Average latency in milliseconds + fn latency_ms(&self) -> u32 { + match self.provider_name.as_str() { + "claude" => 300, + "openai" => 250, + "gemini" => 400, + "ollama" => 150, // Local, typically faster + _ => 250, + } + } + + /// Check if provider is available + fn available(&self) -> bool { + true // typedialog-ai providers handle availability internally + } + + /// Get provider name + fn provider_name(&self) -> String { + self.provider_name.clone() + } + + /// Get model name + fn model_name(&self) -> String { + self.provider.model().to_string() + } + + /// Calculate cost for token usage + fn calculate_cost(&self, input_tokens: u64, output_tokens: u64) -> u32 { + let input_cost = (input_tokens as f64 / 1_000_000.0) * self.cost_per_1m_input; + let output_cost = (output_tokens as f64 / 1_000_000.0) * self.cost_per_1m_output; + ((input_cost + output_cost) * 100.0) as u32 // Convert to cents + } +} + #[cfg(test)] mod tests { + use super::*; + #[test] fn test_llm_client_trait_exists() { // Tests compile-time verification that LLMClient trait is properly defined // with required methods for all implementations } + + #[test] + fn test_token_estimation() { + assert_eq!(TypeDialogAdapter::estimate_tokens("hello"), 2); // ~4 chars per token + assert_eq!(TypeDialogAdapter::estimate_tokens("hello world"), 3); + assert_eq!(TypeDialogAdapter::estimate_tokens("a"), 1); + } + + #[test] + fn test_cost_calculation() { + let adapter = TypeDialogAdapter::new( + Arc::new(MockProvider), + "test".to_string(), + 0.80, // $0.80 per 1M input tokens + 1.60, // $1.60 per 1M output tokens + ); + + // 1000 input tokens + 1000 output tokens + let cost = adapter.calculate_cost(1000, 1000); + // (1000 / 1M * 0.80) + (1000 / 1M * 1.60) = 0.0008 + 0.0016 = 0.0024 = 0.24 cents + assert_eq!(cost, 0); + } + + #[derive(Debug)] + struct MockProvider; + + #[async_trait] + impl typedialog_ai::llm::LlmProvider for MockProvider { + async fn generate( + &self, + _messages: &[typedialog_ai::llm::Message], + _options: &typedialog_ai::llm::GenerationOptions, + ) -> typedialog_ai::llm::Result { + Ok("mock response".to_string()) + } + + async fn stream( + &self, + _messages: &[typedialog_ai::llm::Message], + _options: &typedialog_ai::llm::GenerationOptions, + ) -> typedialog_ai::llm::Result { + use futures::stream; + let stream = stream::iter(vec![Ok("mock".to_string())]); + Ok(Box::pin(stream)) + } + + fn name(&self) -> &str { + "mock" + } + + fn model(&self) -> &str { + "mock-model" + } + + async fn is_available(&self) -> bool { + true + } + } } diff --git a/crates/vapora-llm-router/src/router.rs b/crates/vapora-llm-router/src/router.rs index 85fc4ce..3741e95 100644 --- a/crates/vapora-llm-router/src/router.rs +++ b/crates/vapora-llm-router/src/router.rs @@ -1,10 +1,10 @@ // vapora-llm-router: Routing engine for task-optimal LLM selection // Phase 2: Complete implementation with fallback support -use crate::config::{LLMRouterConfig, ProviderConfig}; -use crate::cost_tracker::CostTracker; -use crate::cost_ranker::CostRanker; use crate::budget::BudgetManager; +use crate::config::{LLMRouterConfig, ProviderConfig}; +use crate::cost_ranker::CostRanker; +use crate::cost_tracker::CostTracker; use crate::providers::*; use std::collections::HashMap; use std::sync::Arc; @@ -76,10 +76,9 @@ impl LLMRouter { ) -> Result, RouterError> { match name { "claude" => { - let api_key = config - .api_key - .clone() - .ok_or_else(|| RouterError::ConfigError("Claude API key missing".to_string()))?; + let api_key = config.api_key.clone().ok_or_else(|| { + RouterError::ConfigError("Claude API key missing".to_string()) + })?; let client = ClaudeClient::new( api_key, @@ -88,7 +87,8 @@ impl LLMRouter { config.temperature, config.cost_per_1m_input, config.cost_per_1m_output, - ).map_err(|e| RouterError::ConfigError(e.to_string()))?; + ) + .map_err(|e| RouterError::ConfigError(e.to_string()))?; Ok(Box::new(client)) } @@ -104,7 +104,8 @@ impl LLMRouter { config.temperature, config.cost_per_1m_input, config.cost_per_1m_output, - ).map_err(|e| RouterError::ConfigError(e.to_string()))?; + ) + .map_err(|e| RouterError::ConfigError(e.to_string()))?; Ok(Box::new(client)) } @@ -119,7 +120,8 @@ impl LLMRouter { config.model.clone(), config.max_tokens, config.temperature, - ).map_err(|e| RouterError::ConfigError(e.to_string()))?; + ) + .map_err(|e| RouterError::ConfigError(e.to_string()))?; Ok(Box::new(client)) } @@ -185,24 +187,36 @@ impl LLMRouter { debug!("Found routing rule: {}", rule.name); if self.is_provider_available(&rule.provider) { - info!("Routing {} to {} via rule {}", task_type, rule.provider, rule.name); + info!( + "Routing {} to {} via rule {}", + task_type, rule.provider, rule.name + ); return Ok(rule.provider.clone()); } - warn!("Primary provider {} unavailable, falling back", rule.provider); + warn!( + "Primary provider {} unavailable, falling back", + rule.provider + ); } // Use default provider let default_provider = &self.config.routing.default_provider; if self.is_provider_available(default_provider) { - info!("Routing {} to default provider {}", task_type, default_provider); + info!( + "Routing {} to default provider {}", + task_type, default_provider + ); return Ok(default_provider.clone()); } // Fallback to any available provider if self.config.routing.fallback_enabled { if let Some(provider_name) = self.find_available_provider() { - warn!("Using fallback provider {} for {}", provider_name, task_type); + warn!( + "Using fallback provider {} for {}", + provider_name, task_type + ); return Ok(provider_name); } } @@ -296,7 +310,8 @@ impl LLMRouter { Ok(response) => { // Track cost if self.config.routing.cost_tracking_enabled { - let cost = provider.calculate_cost(response.input_tokens, response.output_tokens); + let cost = + provider.calculate_cost(response.input_tokens, response.output_tokens); self.cost_tracker.log_usage( &provider_name, task_type, @@ -322,7 +337,9 @@ impl LLMRouter { // Try fallback if enabled if self.config.routing.fallback_enabled { - return self.try_fallback_with_budget(task_type, &provider_name, agent_role).await; + return self + .try_fallback_with_budget(task_type, &provider_name, agent_role) + .await; } Err(RouterError::AllProvidersFailed) @@ -338,11 +355,10 @@ impl LLMRouter { _agent_role: Option<&str>, ) -> Result { // Build fallback chain excluding failed provider - let fallback_chain: Vec = self.providers + let fallback_chain: Vec = self + .providers .iter() - .filter(|(name, provider)| { - *name != failed_provider && provider.available() - }) + .filter(|(name, provider)| *name != failed_provider && provider.available()) .map(|(name, _)| name.clone()) .collect(); @@ -350,7 +366,10 @@ impl LLMRouter { return Err(RouterError::AllProvidersFailed); } - warn!("Primary provider {} failed for {}, trying fallback chain", failed_provider, task_type); + warn!( + "Primary provider {} failed for {}, trying fallback chain", + failed_provider, task_type + ); // Try each fallback provider (placeholder implementation) // In production, you would retry the original prompt with each fallback provider @@ -364,7 +383,6 @@ impl LLMRouter { Err(RouterError::AllProvidersFailed) } - /// Get cost tracker reference pub fn cost_tracker(&self) -> Arc { Arc::clone(&self.cost_tracker) diff --git a/crates/vapora-llm-router/src/typedialog_adapter.rs b/crates/vapora-llm-router/src/typedialog_adapter.rs deleted file mode 100644 index 72fc55e..0000000 --- a/crates/vapora-llm-router/src/typedialog_adapter.rs +++ /dev/null @@ -1,250 +0,0 @@ -// TypeDialog AI adapter: Wraps typedialog-ai LlmProvider in VAPORA's LLMClient interface -// Provides unified access to Claude, OpenAI, Gemini, Ollama via typedialog-ai - -use crate::providers::{CompletionResponse, LLMClient, ProviderError}; -use async_trait::async_trait; -use futures::StreamExt; -use std::sync::Arc; -use typedialog_ai::llm::{GenerationOptions, LlmProvider, Message, Role}; -use tracing::error; - -/// Adapter wrapping typedialog-ai LlmProvider for VAPORA LLMClient trait -pub struct TypeDialogAdapter { - provider: Arc, - provider_name: String, - cost_per_1m_input: f64, - cost_per_1m_output: f64, -} - -impl TypeDialogAdapter { - /// Create new adapter wrapping a typedialog-ai provider - pub fn new( - provider: Arc, - provider_name: String, - cost_per_1m_input: f64, - cost_per_1m_output: f64, - ) -> Self { - Self { - provider, - provider_name, - cost_per_1m_input, - cost_per_1m_output, - } - } - - /// Estimate tokens from text (fallback for providers without token counting) - fn estimate_tokens(text: &str) -> u64 { - // Rough estimate: 4 characters ≈ 1 token (works well for English/code) - (text.len() as u64).div_ceil(4) - } - - /// Build message list from prompt and optional context - fn build_messages(prompt: &str, context: Option<&str>) -> Vec { - let mut messages = Vec::new(); - - if let Some(ctx) = context { - messages.push(Message { - role: Role::System, - content: ctx.to_string(), - }); - } - - messages.push(Message { - role: Role::User, - content: prompt.to_string(), - }); - - messages - } -} - -#[async_trait] -impl LLMClient for TypeDialogAdapter { - /// Send completion request to underlying LLM provider - async fn complete( - &self, - prompt: String, - context: Option, - ) -> Result { - let messages = Self::build_messages(&prompt, context.as_deref()); - - // Create default generation options - let options = GenerationOptions { - temperature: 0.7, - max_tokens: Some(4096), - stop_sequences: vec![], - top_p: None, - top_k: None, - presence_penalty: None, - frequency_penalty: None, - }; - - let text = self - .provider - .generate(&messages, &options) - .await - .map_err(|e| { - error!("LLM generation failed: {}", e); - ProviderError::RequestFailed(e.to_string()) - })?; - - let input_tokens = Self::estimate_tokens(&prompt); - let output_tokens = Self::estimate_tokens(&text); - - Ok(CompletionResponse { - text, - input_tokens, - output_tokens, - finish_reason: "stop".to_string(), - }) - } - - /// Stream completion response token-by-token - async fn stream( - &self, - prompt: String, - ) -> Result, ProviderError> { - let messages = Self::build_messages(&prompt, None); - - let options = GenerationOptions { - temperature: 0.7, - max_tokens: Some(4096), - stop_sequences: vec![], - top_p: None, - top_k: None, - presence_penalty: None, - frequency_penalty: None, - }; - - let mut stream = self - .provider - .stream(&messages, &options) - .await - .map_err(|e| { - error!("LLM stream failed: {}", e); - ProviderError::RequestFailed(e.to_string()) - })?; - - let (tx, rx) = tokio::sync::mpsc::channel(100); - - tokio::spawn(async move { - while let Some(token_result) = stream.next().await { - match token_result { - Ok(token) => { - if tx.send(token).await.is_err() { - // Receiver dropped, stop streaming - break; - } - } - Err(e) => { - error!("Stream error: {}", e); - break; - } - } - } - }); - - Ok(rx) - } - - /// Cost per 1k tokens (combined estimate) - fn cost_per_1k_tokens(&self) -> f64 { - (self.cost_per_1m_input + self.cost_per_1m_output) / 1000.0 - } - - /// Average latency in milliseconds - fn latency_ms(&self) -> u32 { - match self.provider_name.as_str() { - "claude" => 300, - "openai" => 250, - "gemini" => 400, - "ollama" => 150, // Local, typically faster - _ => 250, - } - } - - /// Check if provider is available - fn available(&self) -> bool { - true // typedialog-ai providers handle availability internally - } - - /// Get provider name - fn provider_name(&self) -> String { - self.provider_name.clone() - } - - /// Get model name - fn model_name(&self) -> String { - self.provider.model().to_string() - } - - /// Calculate cost for token usage - fn calculate_cost(&self, input_tokens: u64, output_tokens: u64) -> u32 { - let input_cost = (input_tokens as f64 / 1_000_000.0) * self.cost_per_1m_input; - let output_cost = (output_tokens as f64 / 1_000_000.0) * self.cost_per_1m_output; - ((input_cost + output_cost) * 100.0) as u32 // Convert to cents - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_token_estimation() { - assert_eq!(TypeDialogAdapter::estimate_tokens("hello"), 2); // ~4 chars per token - assert_eq!(TypeDialogAdapter::estimate_tokens("hello world"), 3); - assert_eq!(TypeDialogAdapter::estimate_tokens("a"), 1); - } - - #[test] - fn test_cost_calculation() { - let adapter = TypeDialogAdapter::new( - Arc::new(MockProvider), - "test".to_string(), - 0.80, // $0.80 per 1M input tokens - 1.60, // $1.60 per 1M output tokens - ); - - // 1000 input tokens + 1000 output tokens - let cost = adapter.calculate_cost(1000, 1000); - // (1000 / 1M * 0.80) + (1000 / 1M * 1.60) = 0.0008 + 0.0016 = 0.0024 = 0.24 cents - assert_eq!(cost, 0); - } - - #[derive(Debug)] - struct MockProvider; - - #[async_trait] - impl LlmProvider for MockProvider { - async fn generate( - &self, - _messages: &[Message], - _options: &GenerationOptions, - ) -> typedialog_ai::llm::Result { - Ok("mock response".to_string()) - } - - async fn stream( - &self, - _messages: &[Message], - _options: &GenerationOptions, - ) -> typedialog_ai::llm::Result { - use futures::stream; - let stream = stream::iter(vec![Ok("mock".to_string())]); - Ok(Box::pin(stream)) - } - - fn name(&self) -> &str { - "mock" - } - - fn model(&self) -> &str { - "mock-model" - } - - async fn is_available(&self) -> bool { - true - } - } -} diff --git a/crates/vapora-llm-router/tests/budget_test.rs b/crates/vapora-llm-router/tests/budget_test.rs index 6b47ff7..4bfc259 100644 --- a/crates/vapora-llm-router/tests/budget_test.rs +++ b/crates/vapora-llm-router/tests/budget_test.rs @@ -8,8 +8,8 @@ fn create_test_budgets() -> HashMap { "architect".to_string(), RoleBudget { role: "architect".to_string(), - monthly_limit_cents: 50000, // $500 - weekly_limit_cents: 12500, // $125 + monthly_limit_cents: 50000, // $500 + weekly_limit_cents: 12500, // $125 fallback_provider: "gemini".to_string(), alert_threshold: 0.8, }, @@ -75,7 +75,10 @@ async fn test_alert_threshold_near() { // Spend 81% of weekly budget (12500 * 0.81 = 10125) to trigger near_threshold // This keeps us under both monthly and weekly limits while triggering alert let spend_amount = (12500.0 * 0.81) as u32; // 10125 - manager.record_spend("architect", spend_amount).await.unwrap(); + manager + .record_spend("architect", spend_amount) + .await + .unwrap(); let status = manager.check_budget("architect").await.unwrap(); assert!(!status.exceeded); @@ -157,16 +160,10 @@ async fn test_get_all_budgets() { let all_statuses = manager.get_all_budgets().await; assert_eq!(all_statuses.len(), 2); - let arch_status = all_statuses - .iter() - .find(|s| s.role == "architect") - .unwrap(); + let arch_status = all_statuses.iter().find(|s| s.role == "architect").unwrap(); assert_eq!(arch_status.monthly_remaining_cents, 45000); - let dev_status = all_statuses - .iter() - .find(|s| s.role == "developer") - .unwrap(); + let dev_status = all_statuses.iter().find(|s| s.role == "developer").unwrap(); assert_eq!(dev_status.monthly_remaining_cents, 27000); } diff --git a/crates/vapora-llm-router/tests/cost_optimization_test.rs b/crates/vapora-llm-router/tests/cost_optimization_test.rs index 0bac824..a93055c 100644 --- a/crates/vapora-llm-router/tests/cost_optimization_test.rs +++ b/crates/vapora-llm-router/tests/cost_optimization_test.rs @@ -138,7 +138,10 @@ fn test_cost_benefit_ratio_ordering() { // First item should have best efficiency let best = &ratios[0]; let worst = &ratios[ratios.len() - 1]; - assert!(best.2 >= worst.2, "First should have better efficiency than last"); + assert!( + best.2 >= worst.2, + "First should have better efficiency than last" + ); } #[test] @@ -176,7 +179,10 @@ fn test_efficiency_with_fallback_strategy() { let budget = CostRanker::rank_by_cost(configs.clone(), 1000, 500); // Ollama should be in the zero-cost group (first position or tied for first) let ollama_index = budget.iter().position(|s| s.provider == "ollama").unwrap(); - assert!(ollama_index == 0 || budget[0].estimated_cost_cents == budget[ollama_index].estimated_cost_cents); + assert!( + ollama_index == 0 + || budget[0].estimated_cost_cents == budget[ollama_index].estimated_cost_cents + ); } #[test] diff --git a/crates/vapora-mcp-server/src/main.rs b/crates/vapora-mcp-server/src/main.rs index 0ea6f6a..57bd8a6 100644 --- a/crates/vapora-mcp-server/src/main.rs +++ b/crates/vapora-mcp-server/src/main.rs @@ -310,8 +310,7 @@ async fn main() -> anyhow::Result<()> { .route("/mcp/prompts", get(list_prompts)); // Bind address - let addr = format!("{}:{}", args.host, args.port) - .parse::()?; + let addr = format!("{}:{}", args.host, args.port).parse::()?; let listener = TcpListener::bind(&addr).await?; diff --git a/crates/vapora-shared/src/lib.rs b/crates/vapora-shared/src/lib.rs index 0f430be..c4b760a 100644 --- a/crates/vapora-shared/src/lib.rs +++ b/crates/vapora-shared/src/lib.rs @@ -1,7 +1,7 @@ // vapora-shared: Shared types and utilities for VAPORA v1.0 // Foundation: Minimal skeleton with core types -pub mod models; pub mod error; +pub mod models; -pub use error::{VaporaError, Result}; +pub use error::{Result, VaporaError}; diff --git a/crates/vapora-swarm/benches/coordinator_benchmarks.rs b/crates/vapora-swarm/benches/coordinator_benchmarks.rs index 0682ca3..b688731 100644 --- a/crates/vapora-swarm/benches/coordinator_benchmarks.rs +++ b/crates/vapora-swarm/benches/coordinator_benchmarks.rs @@ -1,5 +1,5 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use vapora_swarm::{SwarmCoordinator, AgentProfile}; +use vapora_swarm::{AgentProfile, SwarmCoordinator}; fn setup_swarm_with_agents(count: usize) -> SwarmCoordinator { let coordinator = SwarmCoordinator::new(); @@ -7,13 +7,11 @@ fn setup_swarm_with_agents(count: usize) -> SwarmCoordinator { for i in 0..count { let profile = AgentProfile { id: format!("agent-{}", i), - roles: vec![ - match i % 3 { - 0 => "developer".to_string(), - 1 => "reviewer".to_string(), - _ => "architect".to_string(), - }, - ], + roles: vec![match i % 3 { + 0 => "developer".to_string(), + 1 => "reviewer".to_string(), + _ => "architect".to_string(), + }], capabilities: vec![ "coding".to_string(), "analysis".to_string(), @@ -98,13 +96,12 @@ fn coordinator_update_status(c: &mut Criterion) { || setup_swarm_with_agents(200), |coordinator| async move { for i in 0..50 { - black_box( - coordinator.update_agent_status( - black_box(&format!("agent-{}", i)), - black_box(0.5 + (i as f64 * 0.01) % 0.4), - black_box(i % 10 != 0), - ), - ).ok(); + black_box(coordinator.update_agent_status( + black_box(&format!("agent-{}", i)), + black_box(0.5 + (i as f64 * 0.01) % 0.4), + black_box(i % 10 != 0), + )) + .ok(); } coordinator }, @@ -142,9 +139,7 @@ fn coordinator_get_stats(c: &mut Criterion) { c.bench_function("get_swarm_stats_500_agents", |b| { b.iter_batched( || setup_swarm_with_agents(500), - |coordinator| { - black_box(coordinator.get_swarm_stats()) - }, + |coordinator| black_box(coordinator.get_swarm_stats()), criterion::BatchSize::SmallInput, ); }); diff --git a/crates/vapora-swarm/src/coordinator.rs b/crates/vapora-swarm/src/coordinator.rs index 6d50721..a29bd34 100644 --- a/crates/vapora-swarm/src/coordinator.rs +++ b/crates/vapora-swarm/src/coordinator.rs @@ -218,15 +218,27 @@ impl SwarmCoordinator { self.coalitions .get(coalition_id) .map(|entry| entry.value().clone()) - .ok_or_else(|| SwarmError::CoalitionError(format!("Coalition not found: {}", coalition_id))) + .ok_or_else(|| { + SwarmError::CoalitionError(format!("Coalition not found: {}", coalition_id)) + }) } /// Update agent status/load - pub fn update_agent_status(&self, agent_id: &str, current_load: f64, available: bool) -> Result<()> { + pub fn update_agent_status( + &self, + agent_id: &str, + current_load: f64, + available: bool, + ) -> Result<()> { if let Some(mut agent) = self.agents.get_mut(agent_id) { agent.current_load = current_load; agent.availability = available; - debug!("Updated agent {} status: load={:.2}%, available={}", agent_id, current_load * 100.0, available); + debug!( + "Updated agent {} status: load={:.2}%, available={}", + agent_id, + current_load * 100.0, + available + ); } Ok(()) } diff --git a/crates/vapora-swarm/src/messages.rs b/crates/vapora-swarm/src/messages.rs index cc09304..11c3c35 100644 --- a/crates/vapora-swarm/src/messages.rs +++ b/crates/vapora-swarm/src/messages.rs @@ -110,10 +110,7 @@ impl Bid { } impl Coalition { - pub fn new( - coordinator_id: String, - required_roles: Vec, - ) -> Self { + pub fn new(coordinator_id: String, required_roles: Vec) -> Self { Self { id: format!("coal_{}", uuid::Uuid::new_v4()), coordinator_id, diff --git a/crates/vapora-swarm/src/metrics.rs b/crates/vapora-swarm/src/metrics.rs index f2107b3..343878b 100644 --- a/crates/vapora-swarm/src/metrics.rs +++ b/crates/vapora-swarm/src/metrics.rs @@ -1,7 +1,7 @@ // Prometheus metrics for swarm coordination // Phase 5.2: Monitor assignment latency, coalition formation, and consensus voting -use prometheus::{HistogramVec, IntCounterVec, Registry, IntCounter, IntGauge}; +use prometheus::{HistogramVec, IntCounter, IntCounterVec, IntGauge, Registry}; use std::sync::Arc; /// Swarm metrics collection for Prometheus monitoring @@ -126,17 +126,13 @@ impl SwarmMetrics { self.assignment_latency .with_label_values(&[complexity]) .observe(latency_secs); - self.assignments_total - .with_label_values(&["success"]) - .inc(); + self.assignments_total.with_label_values(&["success"]).inc(); } /// Record failed assignment pub fn record_assignment_failure(&self) { self.assignment_failures.inc(); - self.assignments_total - .with_label_values(&["failure"]) - .inc(); + self.assignments_total.with_label_values(&["failure"]).inc(); } /// Update agent count metrics @@ -161,7 +157,6 @@ impl SwarmMetrics { } } - #[cfg(test)] mod tests { use super::*; @@ -193,6 +188,9 @@ mod tests { // Verify metrics were recorded by gathering them let metric_families = prometheus::gather(); - assert!(!metric_families.is_empty(), "Should have some metrics registered"); + assert!( + !metric_families.is_empty(), + "Should have some metrics registered" + ); } } diff --git a/crates/vapora-telemetry/benches/metrics_benchmarks.rs b/crates/vapora-telemetry/benches/metrics_benchmarks.rs index 82d5c91..bc36ec1 100644 --- a/crates/vapora-telemetry/benches/metrics_benchmarks.rs +++ b/crates/vapora-telemetry/benches/metrics_benchmarks.rs @@ -40,9 +40,7 @@ fn metrics_get_task_metrics(c: &mut Criterion) { } collector }, - |collector| { - black_box(collector.get_task_metrics()) - }, + |collector| black_box(collector.get_task_metrics()), criterion::BatchSize::SmallInput, ); }); @@ -68,9 +66,7 @@ fn metrics_get_provider_metrics(c: &mut Criterion) { } collector }, - |collector| { - black_box(collector.get_provider_metrics()) - }, + |collector| black_box(collector.get_provider_metrics()), criterion::BatchSize::SmallInput, ); }); @@ -119,9 +115,7 @@ fn metrics_get_system_metrics(c: &mut Criterion) { collector }, - |collector| { - black_box(collector.get_system_metrics()) - }, + |collector| black_box(collector.get_system_metrics()), criterion::BatchSize::SmallInput, ); }); diff --git a/crates/vapora-telemetry/src/lib.rs b/crates/vapora-telemetry/src/lib.rs index 6d8dc08..db7917a 100644 --- a/crates/vapora-telemetry/src/lib.rs +++ b/crates/vapora-telemetry/src/lib.rs @@ -2,16 +2,14 @@ // Phase 4 Sprint 4: OpenTelemetry integration with Jaeger pub mod error; -pub mod tracer; -pub mod spans; pub mod metrics; +pub mod spans; +pub mod tracer; pub use error::{Result, TelemetryError}; +pub use metrics::{MetricsCollector, ProviderMetrics, SystemMetrics, TaskMetrics, TokenMetrics}; +pub use spans::{AgentSpan, AnalyticsSpan, KGSpan, RoutingSpan, SwarmSpan, TaskSpan}; pub use tracer::{TelemetryConfig, TelemetryInitializer}; -pub use spans::{ - TaskSpan, AgentSpan, RoutingSpan, SwarmSpan, AnalyticsSpan, KGSpan, -}; -pub use metrics::{MetricsCollector, TaskMetrics, ProviderMetrics, SystemMetrics, TokenMetrics}; /// Initialize telemetry system with default configuration pub fn init() -> Result<()> { diff --git a/crates/vapora-telemetry/src/metrics.rs b/crates/vapora-telemetry/src/metrics.rs index eb4fed0..5dedc34 100644 --- a/crates/vapora-telemetry/src/metrics.rs +++ b/crates/vapora-telemetry/src/metrics.rs @@ -1,7 +1,7 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::collections::HashMap; use parking_lot::RwLock; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; /// Metrics collector for system observability pub struct MetricsCollector { @@ -66,13 +66,15 @@ impl MetricsCollector { /// Record successful task completion pub fn record_task_success(&self, duration_ms: u64) { self.task_success_count.fetch_add(1, Ordering::Relaxed); - self.task_duration_total.fetch_add(duration_ms, Ordering::Relaxed); + self.task_duration_total + .fetch_add(duration_ms, Ordering::Relaxed); } /// Record task failure pub fn record_task_failure(&self, duration_ms: u64, error_type: &str) { self.task_failure_count.fetch_add(1, Ordering::Relaxed); - self.task_duration_total.fetch_add(duration_ms, Ordering::Relaxed); + self.task_duration_total + .fetch_add(duration_ms, Ordering::Relaxed); let mut errors = self.errors.write(); *errors.entry(error_type.to_string()).or_insert(0) += 1; diff --git a/crates/vapora-telemetry/src/spans.rs b/crates/vapora-telemetry/src/spans.rs index c29d969..c84931f 100644 --- a/crates/vapora-telemetry/src/spans.rs +++ b/crates/vapora-telemetry/src/spans.rs @@ -1,5 +1,5 @@ -use tracing::{info_span, warn_span, Span}; use std::time::Instant; +use tracing::{info_span, warn_span, Span}; /// Span context for task execution tracing pub struct TaskSpan { diff --git a/crates/vapora-telemetry/src/tracer.rs b/crates/vapora-telemetry/src/tracer.rs index 6ec51b4..6487cd8 100644 --- a/crates/vapora-telemetry/src/tracer.rs +++ b/crates/vapora-telemetry/src/tracer.rs @@ -62,9 +62,7 @@ impl TelemetryInitializer { .map_err(|e| TelemetryError::TracerInitFailed(e.to_string()))?; // Build subscriber with OpenTelemetry layer - let registry = Registry::default() - .with(env_filter) - .with(otel_layer); + let registry = Registry::default().with(env_filter).with(otel_layer); if config.console_output { if config.json_output { @@ -72,9 +70,7 @@ impl TelemetryInitializer { .with(tracing_subscriber::fmt::layer().json()) .init(); } else { - registry - .with(tracing_subscriber::fmt::layer()) - .init(); + registry.with(tracing_subscriber::fmt::layer()).init(); } } else { registry.init(); diff --git a/crates/vapora-tracking/benches/storage_bench.rs b/crates/vapora-tracking/benches/storage_bench.rs index b85837c..d6fa446 100644 --- a/crates/vapora-tracking/benches/storage_bench.rs +++ b/crates/vapora-tracking/benches/storage_bench.rs @@ -9,8 +9,5 @@ fn storage_placeholder(_c: &mut Criterion) { // This can be extended in the future with criterion 0.5+ async support } -criterion_group!( - benches, - storage_placeholder, -); +criterion_group!(benches, storage_placeholder,); criterion_main!(benches); diff --git a/crates/vapora-tracking/src/lib.rs b/crates/vapora-tracking/src/lib.rs index 430a0b6..fa2322b 100644 --- a/crates/vapora-tracking/src/lib.rs +++ b/crates/vapora-tracking/src/lib.rs @@ -90,12 +90,11 @@ pub mod events { pub mod prelude { //! Prelude for common imports - pub use tracking_core::{ - TrackingEntry, TrackingSource, EntryType, - Impact, Priority, Estimate, TodoStatus, - TrackingDb, TrackingError, Result, - }; pub use crate::plugin::TrackingPlugin; + pub use tracking_core::{ + EntryType, Estimate, Impact, Priority, Result, TodoStatus, TrackingDb, TrackingEntry, + TrackingError, TrackingSource, + }; } #[cfg(test)] diff --git a/crates/vapora-worktree/src/handle.rs b/crates/vapora-worktree/src/handle.rs index e5c2ad0..ce4c31e 100644 --- a/crates/vapora-worktree/src/handle.rs +++ b/crates/vapora-worktree/src/handle.rs @@ -42,9 +42,10 @@ impl WorktreeHandle { /// Check if worktree is still active for modifications pub fn can_modify(&self) -> Result<()> { if !self.is_active { - return Err(crate::error::WorktreeError::InvalidState( - format!("Worktree {} is no longer active", self.id), - )); + return Err(crate::error::WorktreeError::InvalidState(format!( + "Worktree {} is no longer active", + self.id + ))); } Ok(()) } diff --git a/crates/vapora-worktree/src/manager.rs b/crates/vapora-worktree/src/manager.rs index c5d9a51..b421cf3 100644 --- a/crates/vapora-worktree/src/manager.rs +++ b/crates/vapora-worktree/src/manager.rs @@ -177,7 +177,9 @@ impl WorktreeManager { .current_dir(&self.repo_path) .args(["worktree", "remove", worktree.path.to_str().unwrap()]) .output() - .map_err(|e| WorktreeError::RemovalFailed(format!("Failed to remove worktree: {}", e)))?; + .map_err(|e| { + WorktreeError::RemovalFailed(format!("Failed to remove worktree: {}", e)) + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -212,7 +214,9 @@ impl WorktreeManager { .current_dir(&self.repo_path) .args(["worktree", "remove", "-f", worktree.path.to_str().unwrap()]) .output() - .map_err(|e| WorktreeError::RemovalFailed(format!("Failed to remove worktree: {}", e)))?; + .map_err(|e| { + WorktreeError::RemovalFailed(format!("Failed to remove worktree: {}", e)) + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -280,10 +284,7 @@ impl WorktreeManager { if let Some(path_str) = line.split_whitespace().next() { let path = PathBuf::from(path_str); if path.starts_with(&self.worktree_base) { - warn!( - "Removing orphaned worktree: {}", - path.display() - ); + warn!("Removing orphaned worktree: {}", path.display()); // Remove forcefully let _ = Command::new("git") @@ -316,10 +317,8 @@ mod tests { .output() .map_err(|e| WorktreeError::GitError(e.to_string()))?; - let manager = WorktreeManager::new( - repo_dir.path().to_path_buf(), - wt_dir.path().to_path_buf(), - )?; + let manager = + WorktreeManager::new(repo_dir.path().to_path_buf(), wt_dir.path().to_path_buf())?; assert!(manager.list_active().await?.is_empty()); Ok(()) @@ -337,10 +336,8 @@ mod tests { .output() .map_err(|e| WorktreeError::GitError(e.to_string()))?; - let manager = WorktreeManager::new( - repo_dir.path().to_path_buf(), - wt_dir.path().to_path_buf(), - )?; + let manager = + WorktreeManager::new(repo_dir.path().to_path_buf(), wt_dir.path().to_path_buf())?; let handle = manager.create_for_agent("agent-001").await?;