添加qiming-rcoder模块
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
//! Agent 模块
|
||||
|
||||
pub mod scanner;
|
||||
pub mod status_checker;
|
||||
|
||||
pub use scanner::AgentScanner;
|
||||
pub use status_checker::AgentStatusChecker;
|
||||
202
qiming-rcoder/crates/rcoder/src/cleanup_task/agent/scanner.rs
Normal file
202
qiming-rcoder/crates/rcoder/src/cleanup_task/agent/scanner.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! Agent 扫描器
|
||||
//!
|
||||
//! 扫描并识别需要清理的闲置 agent
|
||||
|
||||
use crate::AgentStatus;
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Agent 扫描器
|
||||
pub struct AgentScanner {
|
||||
pub state: Arc<crate::router::AppState>,
|
||||
pub config: crate::cleanup_task::config::CleanupConfig,
|
||||
pub status_checker: super::AgentStatusChecker,
|
||||
}
|
||||
|
||||
impl AgentScanner {
|
||||
pub fn new(
|
||||
state: Arc<crate::router::AppState>,
|
||||
config: crate::cleanup_task::config::CleanupConfig,
|
||||
) -> Self {
|
||||
use crate::cleanup_task::agent::AgentStatusChecker;
|
||||
let status_checker = AgentStatusChecker::new(state.grpc_pool.clone());
|
||||
Self {
|
||||
state,
|
||||
config,
|
||||
status_checker,
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描需要清理的 agent
|
||||
pub async fn scan_idle_agents(&self) -> Result<Vec<String>> {
|
||||
let mut idle_agents = Vec::new();
|
||||
let current_time = Utc::now();
|
||||
|
||||
info!("[scanner] Starting agent scan");
|
||||
|
||||
// 收集所有项目 ID
|
||||
let project_ids: Vec<String> = self.state.projects.iter().map(|(id, _)| id).collect();
|
||||
|
||||
for project_id in project_ids {
|
||||
if let Some(agent) = self.state.get_project(&project_id) {
|
||||
if self.should_cleanup_agent(&agent, current_time).await {
|
||||
idle_agents.push(project_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"🎯 [scanner] Scan completed: found {} idle agents",
|
||||
idle_agents.len()
|
||||
);
|
||||
Ok(idle_agents)
|
||||
}
|
||||
|
||||
async fn should_cleanup_agent(
|
||||
&self,
|
||||
agent: &shared_types::ProjectAndContainerInfo,
|
||||
current_time: chrono::DateTime<Utc>,
|
||||
) -> bool {
|
||||
// 状态检查
|
||||
let status = agent.status();
|
||||
match status {
|
||||
Some(AgentStatus::Idle) => {
|
||||
debug!("[scanner] status=Idle: {}", agent.project_id());
|
||||
}
|
||||
Some(AgentStatus::Pending) | Some(AgentStatus::Active) => {
|
||||
// 🔧 修复:即使是 Active/Pending 状态,也要检查是否真的活跃
|
||||
// 如果状态卡住(比如 gRPC 服务异常),仍需要清理
|
||||
debug!("⏸️ [scanner] status={:?}, checking", status);
|
||||
// 继续检查,不要直接返回 false
|
||||
}
|
||||
None => {
|
||||
// 状态为 None,检查保护期
|
||||
let age = current_time - agent.created_at();
|
||||
if age.num_seconds() < self.config.container_protection_duration.as_secs() as i64 {
|
||||
debug!("⏸️ [scanner] status=None, in protection period");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Some(AgentStatus::Terminating) => {
|
||||
// 🔧 修复:Terminating 状态不应该持续很久(最多 30 秒)
|
||||
// 如果长时间停留在 Terminating,说明操作卡住了,应该清理
|
||||
let terminating_duration = current_time - agent.last_activity();
|
||||
let terminating_stuck_secs = terminating_duration.num_seconds();
|
||||
let max_terminating_secs = 30; // docker_stop_timeout 默认 30 秒
|
||||
|
||||
if terminating_stuck_secs > max_terminating_secs {
|
||||
warn!(
|
||||
"⚠️ [scanner] Terminating status stuck for more than {} seconds, forcing cleanup: project_id={}, stuck_duration={}s",
|
||||
max_terminating_secs,
|
||||
agent.project_id(),
|
||||
terminating_stuck_secs
|
||||
);
|
||||
// 继续检查,不要返回 false
|
||||
} else {
|
||||
debug!("⏸️ [scanner] status=Terminating, waiting...");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超时检查
|
||||
let idle_duration = current_time - agent.last_activity();
|
||||
let is_timeout = idle_duration
|
||||
> chrono::Duration::from_std(self.config.idle_timeout).unwrap_or_default();
|
||||
|
||||
if !is_timeout {
|
||||
// 未超时,但如果状态是 Active/Pending,仍需要通过 gRPC 确认
|
||||
if matches!(
|
||||
status,
|
||||
Some(AgentStatus::Active) | Some(AgentStatus::Pending)
|
||||
) {
|
||||
debug!(
|
||||
"⏸️ [scanner] Not timeout, status active, skip: {:?}",
|
||||
status
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 保护期检查
|
||||
if self.should_skip_cleanup_due_to_protection(agent.created_at(), &agent.project_id()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🆕 gRPC 二次确认:查询容器内 agent 的真实状态
|
||||
if let Some(container) = agent.container() {
|
||||
// 从 service_url 提取 gRPC 地址
|
||||
let grpc_addr = match crate::handler::utils::extract_grpc_addr_with_port(
|
||||
&container.service_url,
|
||||
shared_types::GRPC_DEFAULT_PORT,
|
||||
) {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"⚠️ [scanner] Failed to parse gRPC address: project_id={}, error={}",
|
||||
agent.project_id(),
|
||||
e
|
||||
);
|
||||
return true; // 解析失败,允许清理
|
||||
}
|
||||
};
|
||||
|
||||
let project_id = agent.project_id();
|
||||
// 根据 service_type 获取正确的容器标识符
|
||||
// - RCoder: 使用 project_id
|
||||
// - ComputerAgentRunner: 使用 user_id(如果存在),否则使用 project_id
|
||||
let user_id = agent.user_id().unwrap_or(project_id);
|
||||
|
||||
match self
|
||||
.status_checker
|
||||
.is_container_active(&grpc_addr, user_id, project_id)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
info!(
|
||||
"🔄 [scanner] gRPC secondary confirmation: agent in container is still active, skipping cleanup: project_id={}, user_id={}",
|
||||
project_id, user_id
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Ok(false) => {
|
||||
debug!(
|
||||
"💤 [scanner] gRPC secondary confirmation: agent in container is idle, can cleanup: project_id={}, user_id={}",
|
||||
project_id, user_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"⚠️ [scanner] gRPC secondary confirmation failed, allowing cleanup: project_id={}, user_id={}, error={}",
|
||||
project_id, user_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn should_skip_cleanup_due_to_protection(
|
||||
&self,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
project_id: &str,
|
||||
) -> bool {
|
||||
let current_time = Utc::now();
|
||||
let age = current_time.signed_duration_since(created_at);
|
||||
|
||||
if age.num_seconds() < self.config.container_protection_duration.as_secs() as i64 {
|
||||
info!(
|
||||
"🛡️ [scanner] Container in protection period, skipping cleanup: project_id={}, age={}s",
|
||||
project_id,
|
||||
age.num_seconds()
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Agent 状态检查器
|
||||
//!
|
||||
//! 通过 gRPC 查询容器内 agent 的真实状态
|
||||
|
||||
use anyhow::Result;
|
||||
use shared_types::grpc::GetContainerStatusRequest;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tracing::debug;
|
||||
|
||||
/// Agent 状态检查器
|
||||
pub struct AgentStatusChecker {
|
||||
pub grpc_pool: Arc<crate::grpc::GrpcChannelPool>,
|
||||
}
|
||||
|
||||
impl AgentStatusChecker {
|
||||
pub fn new(grpc_pool: Arc<crate::grpc::GrpcChannelPool>) -> Self {
|
||||
Self { grpc_pool }
|
||||
}
|
||||
|
||||
/// 查询容器内 agent 是否正在执行任务
|
||||
///
|
||||
/// 返回 true 表示活跃(不应清理),false 表示空闲(可以清理)
|
||||
pub async fn is_container_active(
|
||||
&self,
|
||||
grpc_addr: &str,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
) -> Result<bool> {
|
||||
let timeout_duration = Duration::from_secs(3);
|
||||
|
||||
match timeout(
|
||||
timeout_duration,
|
||||
self.query_container_status(grpc_addr, user_id, project_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(is_active)) => Ok(is_active),
|
||||
Ok(Err(e)) => {
|
||||
debug!("⚠️ [status_checker] gRPC Query failed: {}", e);
|
||||
Ok(false) // Query failed,允许清理
|
||||
}
|
||||
Err(_) => {
|
||||
debug!("⏰ [status_checker] gRPC timeout");
|
||||
Ok(false) // 超时,允许清理
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn query_container_status(
|
||||
&self,
|
||||
grpc_addr: &str,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
) -> Result<bool> {
|
||||
let mut client = self.grpc_pool.get_client(grpc_addr).await?;
|
||||
|
||||
let request = tonic::Request::new(GetContainerStatusRequest {
|
||||
user_id: user_id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
});
|
||||
|
||||
let response = client.get_container_status(request).await?;
|
||||
let status = response.into_inner();
|
||||
|
||||
debug!(
|
||||
"📊 [status_checker] Container status: is_active={}, active_tasks={}",
|
||||
status.is_active, status.active_tasks
|
||||
);
|
||||
|
||||
Ok(status.is_active || status.active_tasks > 0)
|
||||
}
|
||||
}
|
||||
250
qiming-rcoder/crates/rcoder/src/cleanup_task/cleaner.rs
Normal file
250
qiming-rcoder/crates/rcoder/src/cleanup_task/cleaner.rs
Normal file
@@ -0,0 +1,250 @@
|
||||
//! 主清理器
|
||||
//!
|
||||
//! 协调各模块完成清理任务
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use shared_types::ServiceType;
|
||||
use std::sync::Arc;
|
||||
use tokio::time::interval;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// 主清理器(协调各模块)
|
||||
pub struct AgentCleaner {
|
||||
config: super::config::CleanupConfig,
|
||||
stats: super::config::CleanupStats,
|
||||
state: Arc<crate::router::AppState>,
|
||||
|
||||
// 策略
|
||||
rcoder_strategy: super::strategies::rcoder::RCoderStrategy,
|
||||
computer_runner_strategy: super::strategies::computer_runner::ComputerRunnerStrategy,
|
||||
|
||||
// 组件
|
||||
container_destroyer: super::container::ContainerDestroyer,
|
||||
agent_scanner: super::agent::AgentScanner,
|
||||
log_cleaner: super::logs::LogCleaner,
|
||||
}
|
||||
|
||||
impl AgentCleaner {
|
||||
pub fn new(
|
||||
config: super::config::CleanupConfig,
|
||||
state: Arc<crate::router::AppState>,
|
||||
docker_manager: Arc<docker_manager::DockerManager>,
|
||||
pingora_service: Option<Arc<rcoder_proxy::PingoraProxyService>>,
|
||||
) -> Self {
|
||||
let config_clone = config.clone();
|
||||
let state_clone = state.clone();
|
||||
let grpc_pool = state.grpc_pool.clone();
|
||||
|
||||
// 创建日志清理器(使用配置)
|
||||
let log_cleaner = super::logs::LogCleaner::new(
|
||||
config.log_dir.clone(),
|
||||
config.log_retention_duration.as_secs() / 24 / 60 / 60,
|
||||
);
|
||||
|
||||
Self {
|
||||
config,
|
||||
stats: super::config::CleanupStats::default(),
|
||||
state: state_clone,
|
||||
rcoder_strategy: super::strategies::rcoder::RCoderStrategy,
|
||||
computer_runner_strategy: super::strategies::computer_runner::ComputerRunnerStrategy,
|
||||
container_destroyer: super::container::ContainerDestroyer::new(
|
||||
docker_manager.clone(),
|
||||
grpc_pool,
|
||||
pingora_service,
|
||||
),
|
||||
agent_scanner: {
|
||||
use crate::cleanup_task::agent::AgentScanner;
|
||||
AgentScanner::new(state.clone(), config_clone)
|
||||
},
|
||||
log_cleaner,
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一次清理
|
||||
pub async fn cleanup_once(&mut self) -> Result<super::config::CleanupStats> {
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// 重置本次清理的统计
|
||||
let mut current_stats = super::config::CleanupStats::default();
|
||||
|
||||
// 1. 清理过期日志文件
|
||||
match self.log_cleaner.cleanup_once().await {
|
||||
Ok(log_stats) => {
|
||||
if log_stats.files_deleted > 0 || log_stats.failed_deletions > 0 {
|
||||
info!(
|
||||
"🗑️ [cleaner] Cleanup completed: {}",
|
||||
log_stats.summary()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[cleaner] Cleanup failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 扫描需要清理的 agent
|
||||
let idle_agents = self.agent_scanner.scan_idle_agents().await?;
|
||||
info!("[cleaner] Found {} idle agents to clean", idle_agents.len());
|
||||
|
||||
// 3. 清理每个 agent
|
||||
// 记录已销毁的容器名称,避免共享容器被重复销毁(ComputerAgentRunner 场景)
|
||||
let mut destroyed_containers: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
|
||||
for project_id in idle_agents {
|
||||
// 检查该项目的容器是否已被本轮销毁(共享容器场景)
|
||||
let container_name: Option<String> = self
|
||||
.state
|
||||
.get_project(&project_id)
|
||||
.and_then(|info| info.container().map(|c| c.container_name.clone()));
|
||||
|
||||
if let Some(ref name) = container_name {
|
||||
if destroyed_containers.contains(name) {
|
||||
// 容器已被销毁,只需删除项目记录
|
||||
self.state.remove_project(&project_id);
|
||||
current_stats.total_cleaned += 1;
|
||||
current_stats.success_cleaned += 1;
|
||||
info!(
|
||||
"[cleaner] Container already destroyed, removed project record only: project_id={}",
|
||||
project_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current_stats.total_cleaned += 1;
|
||||
|
||||
match self.cleanup_agent(&project_id).await {
|
||||
Ok(destroyed) => {
|
||||
current_stats.success_cleaned += 1;
|
||||
if destroyed {
|
||||
current_stats.containers_destroyed += 1;
|
||||
// 记录已销毁的容器名称,跳过后续同容器的销毁
|
||||
if let Some(name) = container_name {
|
||||
destroyed_containers.insert(name);
|
||||
}
|
||||
}
|
||||
info!("[cleaner] Agent cleanupsucceeded: {}", project_id);
|
||||
}
|
||||
Err(e) => {
|
||||
current_stats.failed_cleaned += 1;
|
||||
warn!("[cleaner] Agent cleanupfailed: {} - {}", project_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新累计统计
|
||||
current_stats.last_cleanup = Some(Utc::now());
|
||||
self.stats.total_cleaned += current_stats.total_cleaned;
|
||||
self.stats.success_cleaned += current_stats.success_cleaned;
|
||||
self.stats.failed_cleaned += current_stats.failed_cleaned;
|
||||
self.stats.containers_destroyed += current_stats.containers_destroyed;
|
||||
self.stats.last_cleanup = current_stats.last_cleanup;
|
||||
|
||||
let duration = start_time.elapsed();
|
||||
info!(
|
||||
"✅ [cleaner] Cleanup completed, duration: {:.2}s, this run: {}",
|
||||
duration.as_secs_f64(),
|
||||
current_stats.summary()
|
||||
);
|
||||
info!("[cleaner] stats: {}", self.stats.summary());
|
||||
|
||||
Ok(current_stats)
|
||||
}
|
||||
|
||||
/// 清理单个 agent
|
||||
/// 返回 Ok(true) 表示销毁了容器,Ok(false) 表示只删除了记录
|
||||
async fn cleanup_agent(&self, project_id: &str) -> Result<bool> {
|
||||
info!("[cleaner] startingcleanup agent: {}", project_id);
|
||||
|
||||
// 1. 获取项目信息
|
||||
let agent_info = self
|
||||
.state
|
||||
.get_project(project_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Agent does not exist: {}", project_id))?;
|
||||
|
||||
let service_type = agent_info.service_type().unwrap_or(ServiceType::RCoder);
|
||||
|
||||
// 2. 选择策略
|
||||
let strategy: &dyn super::strategies::CleanupStrategy = match service_type {
|
||||
ServiceType::RCoder => &self.rcoder_strategy,
|
||||
ServiceType::ComputerAgentRunner => &self.computer_runner_strategy,
|
||||
};
|
||||
|
||||
// 3. 检查是否需要销毁容器,并获取销毁原因
|
||||
let context = super::strategies::CleanupContext {
|
||||
state: self.state.clone(),
|
||||
config: self.config.clone(),
|
||||
};
|
||||
|
||||
let destroy_reason = strategy
|
||||
.should_destroy_container(project_id, &context)
|
||||
.await?;
|
||||
|
||||
// 4. 如果需要销毁容器
|
||||
let mut container_destroyed = false;
|
||||
if let Some(reason) = destroy_reason {
|
||||
if let Some(container_info) = agent_info.container() {
|
||||
let project_info = super::strategies::ProjectInfo {
|
||||
project_id: agent_info.project_id().to_string(),
|
||||
user_id: agent_info.user_id().map(|s| s.to_string()),
|
||||
pod_id: agent_info.pod_id().map(|s| s.to_string()),
|
||||
last_activity: agent_info.last_activity(),
|
||||
};
|
||||
|
||||
let container_identifier = strategy.get_container_identifier(&project_info)?;
|
||||
|
||||
// 🔧 使用容器名称而不是 container_id 来销毁容器
|
||||
// 容器名称更稳定,不会因为容器重启而改变
|
||||
// Docker API 的 remove_container 既接受 ID 也接受名称
|
||||
self.container_destroyer
|
||||
.destroy_with_reason(
|
||||
&container_info.container_name,
|
||||
&service_type,
|
||||
&container_identifier,
|
||||
&reason,
|
||||
)
|
||||
.await?;
|
||||
|
||||
container_destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 从存储中移除项目记录(始终执行)
|
||||
self.state.remove_project(project_id);
|
||||
info!(
|
||||
"[cleaner] already removed project: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
Ok(container_destroyed)
|
||||
}
|
||||
|
||||
/// 运行清理任务(定时)
|
||||
pub async fn run(&mut self) {
|
||||
info!("[cleaner] cleanup task already started");
|
||||
|
||||
let mut interval = interval(self.config.cleanup_interval);
|
||||
let mut memory_log_counter = 0u32;
|
||||
const MEMORY_LOG_INTERVAL: u32 = 12; // 每 12 次清理(大约 1 小时)输出一次内存日志
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match self.cleanup_once().await {
|
||||
Ok(_) => debug!("[cleaner] Cleanup completed"),
|
||||
Err(e) => warn!("[cleaner] Cleanup failed: {}", e),
|
||||
}
|
||||
|
||||
// 定期输出 DuckDB 内存使用统计
|
||||
memory_log_counter += 1;
|
||||
if memory_log_counter >= MEMORY_LOG_INTERVAL {
|
||||
memory_log_counter = 0;
|
||||
if let Ok(stats) = self.state.projects.get_memory_stats() {
|
||||
debug!("[cleaner] DuckDB Memory Stats:\n{}", stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
76
qiming-rcoder/crates/rcoder/src/cleanup_task/config.rs
Normal file
76
qiming-rcoder/crates/rcoder/src/cleanup_task/config.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! 清理任务配置和统计
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::time::Duration;
|
||||
|
||||
/// 清理配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CleanupConfig {
|
||||
/// 闲置超时时间(默认30分钟)
|
||||
pub idle_timeout: Duration,
|
||||
/// 清理检查间隔(默认5分钟)
|
||||
pub cleanup_interval: Duration,
|
||||
/// Docker容器停止超时时间(默认30秒)
|
||||
#[allow(dead_code)] // 保留用于未来的超时配置
|
||||
pub docker_stop_timeout: Duration,
|
||||
/// 容器最小保护时间(默认5分钟)
|
||||
pub container_protection_duration: Duration,
|
||||
/// Agent 活跃判断时间窗口(默认5分钟)
|
||||
pub active_window: Duration,
|
||||
/// 日志目录路径
|
||||
pub log_dir: String,
|
||||
/// 日志保留时长
|
||||
pub log_retention_duration: Duration,
|
||||
}
|
||||
|
||||
impl Default for CleanupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
idle_timeout: Duration::from_secs(30 * 60),
|
||||
cleanup_interval: Duration::from_secs(5 * 60),
|
||||
docker_stop_timeout: Duration::from_secs(30),
|
||||
container_protection_duration: Duration::from_secs(5 * 60),
|
||||
active_window: Duration::from_secs(5 * 60),
|
||||
log_dir: "/app/logs/container".to_string(),
|
||||
log_retention_duration: Duration::from_secs(7 * 24 * 60 * 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理任务统计信息
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CleanupStats {
|
||||
/// 总共清理的agent数量
|
||||
pub total_cleaned: u64,
|
||||
/// 成功清理的agent数量
|
||||
pub success_cleaned: u64,
|
||||
/// 清理失败的agent数量
|
||||
pub failed_cleaned: u64,
|
||||
/// 销毁的容器数量
|
||||
pub containers_destroyed: u64,
|
||||
/// 最后清理时间
|
||||
pub last_cleanup: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl CleanupStats {
|
||||
/// 获取清理成功率
|
||||
pub fn success_rate(&self) -> f64 {
|
||||
if self.total_cleaned == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(self.success_cleaned as f64 / self.total_cleaned as f64) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取格式化的统计摘要
|
||||
pub fn summary(&self) -> String {
|
||||
format!(
|
||||
"Total cleanup: {}, success: {}, failed: {}, containers destroyed: {}, success rate: {:.1}%",
|
||||
self.total_cleaned,
|
||||
self.success_cleaned,
|
||||
self.failed_cleaned,
|
||||
self.containers_destroyed,
|
||||
self.success_rate()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! 容器销毁器
|
||||
//!
|
||||
//! 销毁容器并清理相关资源(gRPC 连接池、Pingora VNC 后端)
|
||||
|
||||
use anyhow::Result;
|
||||
use shared_types::ServiceType;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::cleanup_task::strategies::DestroyReason;
|
||||
|
||||
/// 容器销毁器
|
||||
pub struct ContainerDestroyer {
|
||||
pub docker_manager: Arc<docker_manager::DockerManager>,
|
||||
pub grpc_pool: Arc<crate::grpc::GrpcChannelPool>,
|
||||
pub pingora_service: Option<Arc<rcoder_proxy::PingoraProxyService>>,
|
||||
}
|
||||
|
||||
impl ContainerDestroyer {
|
||||
pub fn new(
|
||||
docker_manager: Arc<docker_manager::DockerManager>,
|
||||
grpc_pool: Arc<crate::grpc::GrpcChannelPool>,
|
||||
pingora_service: Option<Arc<rcoder_proxy::PingoraProxyService>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
docker_manager,
|
||||
grpc_pool,
|
||||
pingora_service,
|
||||
}
|
||||
}
|
||||
|
||||
/// 销毁容器并清理相关资源(带原因)
|
||||
///
|
||||
/// # 参数
|
||||
/// * `container_name` - 容器名称(稳定不变,优先使用)
|
||||
/// * `service_type` - 服务类型(用于决定是否清理 VNC 后端)
|
||||
/// * `container_identifier` - 容器标识符(project_id 或 user_id)
|
||||
/// * `reason` - 销毁原因
|
||||
pub async fn destroy_with_reason(
|
||||
&self,
|
||||
container_name: &str,
|
||||
service_type: &ServiceType,
|
||||
container_identifier: &str,
|
||||
reason: &DestroyReason,
|
||||
) -> Result<()> {
|
||||
info!(
|
||||
"🔥 [destroyer] Starting container destruction: container_name={}, service_type={:?}, identifier={}, reason={}",
|
||||
container_name,
|
||||
service_type,
|
||||
container_identifier,
|
||||
reason.as_str()
|
||||
);
|
||||
|
||||
// 输出详细原因
|
||||
debug!("📋 [destroyer] destroy reason: {}", reason.description());
|
||||
|
||||
// 1. 🔍 通过容器名称实时查询最新的容器信息
|
||||
// 这样可以获取最新的 container_id,避免使用缓存中过期的 ID
|
||||
let (actual_container_id, container_ip) = match self
|
||||
.docker_manager
|
||||
.find_container_realtime(container_name)
|
||||
.await
|
||||
{
|
||||
Ok(Some(result)) => {
|
||||
debug!(
|
||||
"✅ [destroyer] Found container: name={}, id={}, ip={}",
|
||||
container_name, result.container_id, result.container_ip
|
||||
);
|
||||
(result.container_id, result.container_ip)
|
||||
}
|
||||
Ok(None) => {
|
||||
// 容器不存在,可能已经被删除了,这不是错误
|
||||
info!(
|
||||
"⚠️ [destroyer] Container does not exist, may have been deleted: name={}",
|
||||
container_name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
// 查询出错,返回错误
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to query container info: name={}, error={}",
|
||||
container_name,
|
||||
e
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 执行物理销毁(使用最新的 container_id)
|
||||
docker_manager::container_stop::runtime_cleanup_container(
|
||||
&self.docker_manager,
|
||||
&actual_container_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to stop container: {}", e))?;
|
||||
|
||||
// 3. 清理 DockerManager 内存缓存(防止缓存残留导致孤立容器无法被清理)
|
||||
let _: Option<_> = self
|
||||
.docker_manager
|
||||
.remove_container_cache(container_identifier)
|
||||
.await;
|
||||
debug!(
|
||||
"🧹 [destroyer] DockerManager memory cache cleaned: identifier={}",
|
||||
container_identifier
|
||||
);
|
||||
|
||||
// 4. 清理关联资源
|
||||
// 清理 gRPC 连接池中的旧连接(避免复用已失效的 TCP 连接)
|
||||
if !container_ip.is_empty() {
|
||||
let old_grpc_addr = format!(
|
||||
"{}:{}",
|
||||
container_ip,
|
||||
shared_types::GRPC_DEFAULT_PORT
|
||||
);
|
||||
self.grpc_pool.remove(&old_grpc_addr);
|
||||
}
|
||||
|
||||
if *service_type == ServiceType::ComputerAgentRunner {
|
||||
// 清理 Pingora VNC 后端
|
||||
if let Some(ref pingora_service) = self.pingora_service {
|
||||
let _: Option<String> = pingora_service.remove_vnc_backend(container_identifier);
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"✅ [destroyer] Container destruction completed: container_name={}, actual_id={}, reason={}",
|
||||
container_name,
|
||||
actual_container_id,
|
||||
reason.as_str()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 销毁容器并清理相关资源(兼容旧接口)
|
||||
///
|
||||
/// # 参数
|
||||
/// * `container_name` - 容器名称
|
||||
/// * `service_type` - 服务类型(用于决定是否清理 VNC 后端)
|
||||
/// * `container_identifier` - 容器标识符(project_id 或 user_id)
|
||||
pub async fn destroy(
|
||||
&self,
|
||||
container_name: &str,
|
||||
service_type: &ServiceType,
|
||||
container_identifier: &str,
|
||||
) -> Result<()> {
|
||||
// 使用默认原因
|
||||
let reason = DestroyReason::ManualStop {
|
||||
source: "unknown".to_string(),
|
||||
};
|
||||
self.destroy_with_reason(container_name, service_type, container_identifier, &reason)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! 容器操作模块
|
||||
|
||||
pub mod destroyer;
|
||||
|
||||
pub use destroyer::ContainerDestroyer;
|
||||
@@ -0,0 +1,413 @@
|
||||
//! 清理任务集成测试
|
||||
//!
|
||||
//! 测试核心业务逻辑:
|
||||
//! 1. ComputerAgentRunner 引用计数检查(核心)
|
||||
//! 2. 活跃窗口边界条件
|
||||
//! 3. 容器标识符获取策略
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
use crate::cleanup_task::strategies::CleanupStrategy;
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
use duckdb_manager::ProjectRecord;
|
||||
use shared_types::ServiceType;
|
||||
use std::time::Duration;
|
||||
|
||||
/// 创建测试用的 ProjectRecord
|
||||
fn create_test_project(
|
||||
project_id: &str,
|
||||
user_id: &str,
|
||||
service_type: ServiceType,
|
||||
last_activity_seconds_ago: i64,
|
||||
) -> ProjectRecord {
|
||||
ProjectRecord {
|
||||
project_id: project_id.to_string(),
|
||||
session_id: None,
|
||||
service_type,
|
||||
container_id: format!("container_{}", project_id),
|
||||
user_id: Some(user_id.to_string()),
|
||||
pod_id: None,
|
||||
agent_status_code: None,
|
||||
agent_status_name: None,
|
||||
request_id: None,
|
||||
model_provider_json: None,
|
||||
created_at: Utc::now() - ChronoDuration::hours(2),
|
||||
last_activity: Utc::now() - ChronoDuration::seconds(last_activity_seconds_ago),
|
||||
session_created_at: None,
|
||||
session_last_activity: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带 pod_id 的测试用 ProjectRecord(RCoder 共享容器模式)
|
||||
fn create_test_project_with_pod(
|
||||
project_id: &str,
|
||||
user_id: &str,
|
||||
pod_id: &str,
|
||||
service_type: ServiceType,
|
||||
last_activity_seconds_ago: i64,
|
||||
) -> ProjectRecord {
|
||||
ProjectRecord {
|
||||
project_id: project_id.to_string(),
|
||||
session_id: None,
|
||||
service_type,
|
||||
container_id: format!("container_{}", pod_id),
|
||||
user_id: Some(user_id.to_string()),
|
||||
pod_id: Some(pod_id.to_string()),
|
||||
agent_status_code: None,
|
||||
agent_status_name: None,
|
||||
request_id: None,
|
||||
model_provider_json: None,
|
||||
created_at: Utc::now() - ChronoDuration::hours(2),
|
||||
last_activity: Utc::now() - ChronoDuration::seconds(last_activity_seconds_ago),
|
||||
session_created_at: None,
|
||||
session_last_activity: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 核心测试:ComputerAgentRunner 引用计数逻辑
|
||||
// ========================================================================
|
||||
|
||||
/// 测试场景:有活跃项目时,不应该销毁容器
|
||||
#[test]
|
||||
fn test_computer_runner_ref_count_with_active_projects() {
|
||||
// 场景:user_1 有 3 个项目
|
||||
// - proj_A: 闲置30分钟(准备清理)
|
||||
// - proj_B: 活跃2分钟(仍在使用)
|
||||
// - proj_C: 闲置30分钟
|
||||
//
|
||||
// 预期:清理 proj_A 时,因为 proj_B 仍活跃,容器应该被保留
|
||||
|
||||
let proj_a =
|
||||
create_test_project("proj_A", "user_1", ServiceType::ComputerAgentRunner, 1800);
|
||||
let proj_b = create_test_project("proj_B", "user_1", ServiceType::ComputerAgentRunner, 120);
|
||||
let proj_c =
|
||||
create_test_project("proj_C", "user_1", ServiceType::ComputerAgentRunner, 1800);
|
||||
|
||||
let config = CleanupConfig {
|
||||
active_window: Duration::from_secs(300), // 5分钟窗口
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// proj_B 应该是活跃的
|
||||
assert!(
|
||||
strategies::computer_runner::is_project_active(&proj_b, &config),
|
||||
"proj_B (2分钟前活动) 应该被认为是活跃的"
|
||||
);
|
||||
|
||||
// proj_A 和 proj_C 应该是闲置的
|
||||
assert!(
|
||||
!strategies::computer_runner::is_project_active(&proj_a, &config),
|
||||
"proj_A (30分钟前活动) 应该被认为是闲置的"
|
||||
);
|
||||
|
||||
// 验证引用计数逻辑:存在活跃引用,不应该销毁容器
|
||||
let related_projects = vec![proj_a.clone(), proj_b.clone(), proj_c.clone()];
|
||||
let has_active_refs = related_projects.iter().any(|p| {
|
||||
p.project_id != "proj_A" && strategies::computer_runner::is_project_active(p, &config)
|
||||
});
|
||||
|
||||
assert!(
|
||||
has_active_refs,
|
||||
"应该存在活跃的引用项目 (proj_B),因此不应该销毁容器"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试场景:所有项目都闲置时,应该销毁容器
|
||||
#[test]
|
||||
fn test_computer_runner_ref_count_all_idle() {
|
||||
// 场景:user_2 有 3 个项目,全部闲置
|
||||
// - proj_D: 闲置1小时
|
||||
// - proj_E: 闲置2小时
|
||||
// - proj_F: 闲置30分钟
|
||||
//
|
||||
// 预期:清理 proj_D 时,因为所有项目都闲置,应该销毁容器
|
||||
|
||||
let proj_d =
|
||||
create_test_project("proj_D", "user_2", ServiceType::ComputerAgentRunner, 3600);
|
||||
let proj_e =
|
||||
create_test_project("proj_E", "user_2", ServiceType::ComputerAgentRunner, 7200);
|
||||
let proj_f =
|
||||
create_test_project("proj_F", "user_2", ServiceType::ComputerAgentRunner, 1800);
|
||||
|
||||
let config = CleanupConfig {
|
||||
active_window: Duration::from_secs(300),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let related_projects = vec![proj_d.clone(), proj_e.clone(), proj_f.clone()];
|
||||
|
||||
// 所有项目都应该是闲置的
|
||||
for proj in &related_projects {
|
||||
assert!(
|
||||
!strategies::computer_runner::is_project_active(proj, &config),
|
||||
"{} 应该被认为是闲置的",
|
||||
proj.project_id
|
||||
);
|
||||
}
|
||||
|
||||
// 没有活跃引用,应该销毁容器
|
||||
let has_active_refs = related_projects.iter().any(|p| {
|
||||
p.project_id != "proj_D" && strategies::computer_runner::is_project_active(p, &config)
|
||||
});
|
||||
|
||||
assert!(!has_active_refs, "不存在活跃的引用项目,因此应该销毁容器");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 测试:活跃窗口边界条件
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_active_window_boundary_conditions() {
|
||||
// is_project_active 使用 idle_timeout 作为判断标准(与 scanner 一致)
|
||||
let config = CleanupConfig {
|
||||
idle_timeout: Duration::from_secs(600), // 10分钟
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 测试边界:(描述, 距离上次活动秒数, 是否应该活跃)
|
||||
let test_cases = vec![
|
||||
("刚刚活动", 0, true),
|
||||
("1分钟前", 60, true),
|
||||
("9分59秒前", 599, true),
|
||||
("恰好在边界", 600, false),
|
||||
("10分1秒前", 601, false),
|
||||
("30分钟前", 1800, false),
|
||||
];
|
||||
|
||||
for (desc, seconds_ago, expected_active) in test_cases {
|
||||
let project = create_test_project(
|
||||
&format!("project_{}", desc.replace(' ', "_")),
|
||||
"test_user",
|
||||
ServiceType::ComputerAgentRunner,
|
||||
seconds_ago,
|
||||
);
|
||||
|
||||
let is_active = strategies::computer_runner::is_project_active(&project, &config);
|
||||
|
||||
assert_eq!(
|
||||
is_active, expected_active,
|
||||
"{}: {}秒前活动, 预期={}, 实际={}",
|
||||
desc, seconds_ago, expected_active, is_active
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 测试:容器标识符获取策略
|
||||
// ========================================================================
|
||||
|
||||
#[test]
|
||||
fn test_container_identifier_extraction() {
|
||||
let rcoder_strategy = strategies::rcoder::RCoderStrategy;
|
||||
let computer_runner_strategy = strategies::computer_runner::ComputerRunnerStrategy;
|
||||
|
||||
// RCoder 无 pod_id: 使用 project_id
|
||||
let rcoder_info = strategies::ProjectInfo {
|
||||
project_id: "project_abc".to_string(),
|
||||
user_id: Some("user_xyz".to_string()),
|
||||
pod_id: None,
|
||||
last_activity: Utc::now(),
|
||||
};
|
||||
|
||||
let rcoder_id = rcoder_strategy
|
||||
.get_container_identifier(&rcoder_info)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rcoder_id, "project_abc",
|
||||
"RCoder 无 pod_id 时应该使用 project_id 作为容器标识符"
|
||||
);
|
||||
|
||||
// RCoder 有 pod_id: 使用 pod_id(共享容器模式)
|
||||
let rcoder_pod_info = strategies::ProjectInfo {
|
||||
project_id: "project_jkl".to_string(),
|
||||
user_id: Some("user_xyz".to_string()),
|
||||
pod_id: Some("pod_123".to_string()),
|
||||
last_activity: Utc::now(),
|
||||
};
|
||||
|
||||
let rcoder_pod_id = rcoder_strategy
|
||||
.get_container_identifier(&rcoder_pod_info)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rcoder_pod_id, "pod_123",
|
||||
"RCoder 有 pod_id 时应该使用 pod_id 作为容器标识符"
|
||||
);
|
||||
|
||||
// ComputerAgentRunner: 使用 user_id
|
||||
let runner_info = strategies::ProjectInfo {
|
||||
project_id: "project_def".to_string(),
|
||||
user_id: Some("user_123".to_string()),
|
||||
pod_id: None,
|
||||
last_activity: Utc::now(),
|
||||
};
|
||||
|
||||
let runner_id = computer_runner_strategy
|
||||
.get_container_identifier(&runner_info)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
runner_id, "user_123",
|
||||
"ComputerAgentRunner 应该使用 user_id 作为容器标识符"
|
||||
);
|
||||
|
||||
// ComputerAgentRunner 缺少 user_id 应该返回错误
|
||||
let runner_info_missing_user = strategies::ProjectInfo {
|
||||
project_id: "project_ghi".to_string(),
|
||||
user_id: None,
|
||||
pod_id: None,
|
||||
last_activity: Utc::now(),
|
||||
};
|
||||
|
||||
let result = computer_runner_strategy.get_container_identifier(&runner_info_missing_user);
|
||||
assert!(result.is_err(), "缺少 user_id 时应该返回错误");
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 测试:RCoder pod_id 共享容器引用计数逻辑
|
||||
// ========================================================================
|
||||
|
||||
/// 测试场景:RCoder 有 pod_id 时,有活跃项目不应该销毁容器
|
||||
#[test]
|
||||
fn test_rcoder_pod_id_ref_count_with_active_projects() {
|
||||
// 场景:pod_1 下有 3 个 RCoder 项目
|
||||
// - proj_A: 闲置30分钟(准备清理)
|
||||
// - proj_B: 活跃2分钟(仍在使用)
|
||||
// - proj_C: 闲置30分钟
|
||||
//
|
||||
// 预期:清理 proj_A 时,因为 proj_B 仍活跃,容器应该被保留
|
||||
|
||||
let proj_a = create_test_project_with_pod(
|
||||
"proj_A",
|
||||
"user_1",
|
||||
"pod_1",
|
||||
ServiceType::RCoder,
|
||||
1800,
|
||||
);
|
||||
let proj_b = create_test_project_with_pod(
|
||||
"proj_B",
|
||||
"user_1",
|
||||
"pod_1",
|
||||
ServiceType::RCoder,
|
||||
120,
|
||||
);
|
||||
let proj_c = create_test_project_with_pod(
|
||||
"proj_C",
|
||||
"user_1",
|
||||
"pod_1",
|
||||
ServiceType::RCoder,
|
||||
1800,
|
||||
);
|
||||
|
||||
let config = CleanupConfig {
|
||||
idle_timeout: Duration::from_secs(600), // 10分钟
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// proj_B 应该是活跃的
|
||||
assert!(
|
||||
strategies::computer_runner::is_project_active(&proj_b, &config),
|
||||
"proj_B (2分钟前活动) 应该被认为是活跃的"
|
||||
);
|
||||
|
||||
// proj_A 和 proj_C 应该是闲置的
|
||||
assert!(
|
||||
!strategies::computer_runner::is_project_active(&proj_a, &config),
|
||||
"proj_A (30分钟前活动) 应该被认为是闲置的"
|
||||
);
|
||||
|
||||
// 验证引用计数逻辑:存在活跃引用,不应该销毁容器
|
||||
let related_projects = vec![proj_a.clone(), proj_b.clone(), proj_c.clone()];
|
||||
let has_active_refs = related_projects.iter().any(|p| {
|
||||
p.project_id != "proj_A" && strategies::computer_runner::is_project_active(p, &config)
|
||||
});
|
||||
|
||||
assert!(
|
||||
has_active_refs,
|
||||
"应该存在活跃的引用项目 (proj_B),因此不应该销毁容器"
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试场景:RCoder 有 pod_id 时,所有项目都闲置应该销毁容器
|
||||
#[test]
|
||||
fn test_rcoder_pod_id_ref_count_all_idle() {
|
||||
// 场景:pod_2 下有 3 个 RCoder 项目,全部闲置
|
||||
// - proj_D: 闲置1小时
|
||||
// - proj_E: 闲置2小时
|
||||
// - proj_F: 闲置30分钟
|
||||
//
|
||||
// 预期:清理 proj_D 时,因为所有项目都闲置,应该销毁容器
|
||||
|
||||
let proj_d = create_test_project_with_pod(
|
||||
"proj_D",
|
||||
"user_2",
|
||||
"pod_2",
|
||||
ServiceType::RCoder,
|
||||
3600,
|
||||
);
|
||||
let proj_e = create_test_project_with_pod(
|
||||
"proj_E",
|
||||
"user_2",
|
||||
"pod_2",
|
||||
ServiceType::RCoder,
|
||||
7200,
|
||||
);
|
||||
let proj_f = create_test_project_with_pod(
|
||||
"proj_F",
|
||||
"user_2",
|
||||
"pod_2",
|
||||
ServiceType::RCoder,
|
||||
1800,
|
||||
);
|
||||
|
||||
let config = CleanupConfig {
|
||||
idle_timeout: Duration::from_secs(600),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let related_projects = vec![proj_d.clone(), proj_e.clone(), proj_f.clone()];
|
||||
|
||||
// 所有项目都应该是闲置的
|
||||
for proj in &related_projects {
|
||||
assert!(
|
||||
!strategies::computer_runner::is_project_active(proj, &config),
|
||||
"{} 应该被认为是闲置的",
|
||||
proj.project_id
|
||||
);
|
||||
}
|
||||
|
||||
// 没有活跃引用,应该销毁容器
|
||||
let has_active_refs = related_projects.iter().any(|p| {
|
||||
p.project_id != "proj_D" && strategies::computer_runner::is_project_active(p, &config)
|
||||
});
|
||||
|
||||
assert!(!has_active_refs, "不存在活跃的引用项目,因此应该销毁容器");
|
||||
}
|
||||
|
||||
/// 测试场景:RCoder 无 pod_id 时,始终应该销毁容器(1:1 模式)
|
||||
#[test]
|
||||
fn test_rcoder_no_pod_id_always_destroy() {
|
||||
// 场景:无 pod_id 的 RCoder 项目,1容器=1项目
|
||||
// 预期:无论其他项目如何,清理时始终销毁容器
|
||||
|
||||
let proj = create_test_project("proj_solo", "user_3", ServiceType::RCoder, 1800);
|
||||
|
||||
let config = CleanupConfig {
|
||||
idle_timeout: Duration::from_secs(600),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 确认没有 pod_id
|
||||
assert!(
|
||||
proj.pod_id.is_none(),
|
||||
"无 pod_id 的项目应该直接销毁容器"
|
||||
);
|
||||
|
||||
// 项目闲置
|
||||
assert!(
|
||||
!strategies::computer_runner::is_project_active(&proj, &config),
|
||||
"proj_solo (30分钟前活动) 应该被认为是闲置的"
|
||||
);
|
||||
}
|
||||
}
|
||||
274
qiming-rcoder/crates/rcoder/src/cleanup_task/logs/mod.rs
Normal file
274
qiming-rcoder/crates/rcoder/src/cleanup_task/logs/mod.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
//! 日志清理模块
|
||||
//!
|
||||
//! 负责清理 /app/logs/container 目录下的过期日志文件
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use std::vec::Vec;
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// 日志清理器
|
||||
pub struct LogCleaner {
|
||||
/// 日志目录路径
|
||||
log_dir: String,
|
||||
/// 日志保留时长(默认 7 天)
|
||||
retention_duration: Duration,
|
||||
}
|
||||
|
||||
impl LogCleaner {
|
||||
/// 创建新的日志清理器
|
||||
pub fn new(log_dir: impl Into<String>, retention_days: u64) -> Self {
|
||||
Self {
|
||||
log_dir: log_dir.into(),
|
||||
retention_duration: Duration::from_secs(retention_days * 24 * 60 * 60),
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一次日志清理
|
||||
///
|
||||
/// # 返回
|
||||
/// 返回清理的文件数量和释放的字节数
|
||||
pub async fn cleanup_once(&self) -> Result<LogCleanupStats, std::io::Error> {
|
||||
let log_path = Path::new(&self.log_dir);
|
||||
|
||||
// 检查目录是否存在
|
||||
if !log_path.exists() {
|
||||
debug!(
|
||||
"📋 [log_cleaner] Log directory does not exist, skipping cleanup: {}",
|
||||
self.log_dir
|
||||
);
|
||||
return Ok(LogCleanupStats::default());
|
||||
}
|
||||
|
||||
// 检查是否是目录
|
||||
if !log_path.is_dir() {
|
||||
warn!(
|
||||
"📋 [log_cleaner] Path is not a directory, skip cleanup: {}",
|
||||
self.log_dir
|
||||
);
|
||||
return Ok(LogCleanupStats::default());
|
||||
}
|
||||
|
||||
info!(
|
||||
"🧹 [log_cleaner] Starting log directory cleanup: {}, retention: {} days",
|
||||
self.log_dir,
|
||||
self.retention_duration.as_secs() / 86400
|
||||
);
|
||||
|
||||
let mut stats = LogCleanupStats::default();
|
||||
let cutoff_time = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_secs(),
|
||||
Err(e) => {
|
||||
warn!("📋 [log_cleaner] Invalid timestamp, skip cleanup: {}", e);
|
||||
return Ok(LogCleanupStats::default());
|
||||
}
|
||||
};
|
||||
let cutoff_time = cutoff_time.saturating_sub(self.retention_duration.as_secs());
|
||||
|
||||
// 读取目录内容
|
||||
let mut entries = match fs::read_dir(log_path).await {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
warn!("📋 [log_cleaner] Failed to read directory: {}", e);
|
||||
return Ok(LogCleanupStats::default());
|
||||
}
|
||||
};
|
||||
|
||||
// 遍历目录中的文件和子目录
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
let metadata = match entry.metadata().await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"📋 [log_cleaner] get file failed: {:?} - {}",
|
||||
path, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取修改时间(更可靠,所有文件系统都支持)
|
||||
let modified = match metadata.modified() {
|
||||
Ok(time) => time,
|
||||
Err(e) => {
|
||||
debug!("📋 [log_cleaner] get modified time failed: {:?} - {}", path, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 转换为 Unix 时间戳
|
||||
let modified_secs = match modified.duration_since(std::time::UNIX_EPOCH) {
|
||||
Ok(duration) => duration.as_secs(),
|
||||
Err(_) => {
|
||||
debug!("📋 [log_cleaner] skip: {:?}", path);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 判断是否过期(基于修改时间)
|
||||
if modified_secs < cutoff_time {
|
||||
if metadata.is_file() {
|
||||
// 删除过期文件
|
||||
let file_size = metadata.len();
|
||||
|
||||
match fs::remove_file(&path).await {
|
||||
Ok(_) => {
|
||||
stats.files_deleted += 1;
|
||||
stats.bytes_freed += file_size;
|
||||
debug!(
|
||||
"🗑️ [log_cleaner] Deleting expired file: {:?} ({:.2} MB)",
|
||||
path,
|
||||
file_size as f64 / 1024.0 / 1024.0
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
stats.failed_deletions += 1;
|
||||
warn!("📋 [log_cleaner] Failed to delete file: {:?} - {}", path, e);
|
||||
}
|
||||
}
|
||||
} else if metadata.is_dir() {
|
||||
// 删除过期目录(递归删除整个目录)
|
||||
match fs::remove_dir_all(&path).await {
|
||||
Ok(_) => {
|
||||
stats.dirs_deleted += 1;
|
||||
debug!("🗑️ [log_cleaner] Deleted directory: {:?}", path);
|
||||
}
|
||||
Err(e) => {
|
||||
stats.failed_deletions += 1;
|
||||
warn!(
|
||||
"📋 [log_cleaner] Failed to delete directory: {:?} - {}",
|
||||
path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if stats.files_deleted > 0 || stats.dirs_deleted > 0 {
|
||||
info!(
|
||||
"✅ [log_cleaner] Log cleanup completed: deleted {} files, {} dirs, freed {:.2} MB",
|
||||
stats.files_deleted,
|
||||
stats.dirs_deleted,
|
||||
stats.bytes_freed as f64 / 1024.0 / 1024.0
|
||||
);
|
||||
} else {
|
||||
info!("[log_cleaner] Cleanup completed");
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// 获取日志目录路径
|
||||
pub fn log_dir(&self) -> &str {
|
||||
&self.log_dir
|
||||
}
|
||||
|
||||
/// 获取保留时长(秒)
|
||||
pub fn retention_duration(&self) -> Duration {
|
||||
self.retention_duration
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志清理统计
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LogCleanupStats {
|
||||
/// 删除的文件数量
|
||||
pub files_deleted: u64,
|
||||
/// 删除的目录数量
|
||||
pub dirs_deleted: u64,
|
||||
/// 释放的字节数
|
||||
pub bytes_freed: u64,
|
||||
/// 删除失败的文件/目录数量
|
||||
pub failed_deletions: u64,
|
||||
}
|
||||
|
||||
impl LogCleanupStats {
|
||||
/// 获取格式化的统计摘要
|
||||
pub fn summary(&self) -> String {
|
||||
if self.files_deleted == 0 && self.dirs_deleted == 0 && self.failed_deletions == 0 {
|
||||
"No expired logs".to_string()
|
||||
} else {
|
||||
let mut parts = Vec::new();
|
||||
if self.files_deleted > 0 {
|
||||
parts.push(format!(
|
||||
"Deleted: {} files, freed: {:.2} MB",
|
||||
self.files_deleted,
|
||||
self.bytes_freed as f64 / 1024.0 / 1024.0
|
||||
));
|
||||
}
|
||||
if self.dirs_deleted > 0 {
|
||||
parts.push(format!("deleted: {} dirs", self.dirs_deleted));
|
||||
}
|
||||
if self.failed_deletions > 0 {
|
||||
parts.push(format!("failed: {}", self.failed_deletions));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_cleanup_basic() {
|
||||
// 创建临时测试目录
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let log_dir = temp_dir.path().join("logs");
|
||||
|
||||
// 创建日志目录
|
||||
fs::create_dir_all(&log_dir).await.unwrap();
|
||||
|
||||
// 创建一些测试日志文件
|
||||
let test_files = vec![
|
||||
("old_log_1.txt", "old content 1"),
|
||||
("old_log_2.txt", "old content 2"),
|
||||
("recent_log.txt", "recent content"),
|
||||
];
|
||||
|
||||
for (filename, content) in &test_files {
|
||||
let file_path = log_dir.join(filename);
|
||||
let mut file = File::create(&file_path).unwrap();
|
||||
file.write_all(content.as_bytes()).unwrap();
|
||||
}
|
||||
|
||||
// 设置旧文件的修改时间为 15 天前
|
||||
let old_time =
|
||||
std::time::SystemTime::now() - std::time::Duration::from_secs(15 * 24 * 60 * 60);
|
||||
for (filename, _) in &test_files[..2] {
|
||||
let file_path = log_dir.join(filename);
|
||||
if let Err(e) = filetime::set_file_mtime(&file_path, old_time.into()) {
|
||||
// 系统不支持设置修改时间,跳过此测试
|
||||
println!(" set mtime not supported, skip: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建日志清理器,保留 10 天
|
||||
let cleaner = LogCleaner::new(log_dir.to_str().unwrap(), 10);
|
||||
|
||||
// 执行清理
|
||||
let stats = cleaner.cleanup_once().await.unwrap();
|
||||
|
||||
// 验证结果
|
||||
assert_eq!(stats.files_deleted, 2);
|
||||
assert!(log_dir.join("recent_log.txt").exists());
|
||||
assert!(!log_dir.join("old_log_1.txt").exists());
|
||||
assert!(!log_dir.join("old_log_2.txt").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cleanup_nonexistent_dir() {
|
||||
let cleaner = LogCleaner::new("/nonexistent/path", 10);
|
||||
let stats = cleaner.cleanup_once().await.unwrap();
|
||||
assert_eq!(stats.files_deleted, 0);
|
||||
assert_eq!(stats.failed_deletions, 0);
|
||||
}
|
||||
}
|
||||
132
qiming-rcoder/crates/rcoder/src/cleanup_task/mod.rs
Normal file
132
qiming-rcoder/crates/rcoder/src/cleanup_task/mod.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! 清理任务模块
|
||||
//!
|
||||
//! 重构后的清理任务,修复 ComputerAgentRunner 引用计数问题并模块化拆分
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
pub mod agent;
|
||||
pub mod cleaner;
|
||||
pub mod config;
|
||||
pub mod container;
|
||||
pub mod logs;
|
||||
pub mod storage;
|
||||
pub mod strategies;
|
||||
|
||||
// 集成测试
|
||||
#[cfg(test)]
|
||||
mod integration_tests;
|
||||
|
||||
pub use cleaner::AgentCleaner;
|
||||
pub use config::CleanupConfig;
|
||||
#[allow(unused_imports)] // CleanupStats 用于类型导出
|
||||
pub use config::CleanupStats;
|
||||
|
||||
/// 启动清理任务
|
||||
///
|
||||
/// # Errors
|
||||
/// 如果Failed to get DockerManager,返回错误而不是静默失败
|
||||
pub async fn start_cleanup_task(
|
||||
config: CleanupConfig,
|
||||
state: Arc<crate::router::AppState>,
|
||||
) -> anyhow::Result<tokio::task::JoinHandle<()>> {
|
||||
let docker_manager = match docker_manager::global::get_global_docker_manager().await {
|
||||
Ok(dm) => Some(dm),
|
||||
Err(e) => {
|
||||
if matches!(
|
||||
docker_manager::runtime::RuntimeManager::runtime_type(),
|
||||
docker_manager::runtime_selection::RuntimeType::Kubernetes
|
||||
) {
|
||||
tracing::warn!(
|
||||
"⚠️ [CLEANUP_TASK] DockerManager unavailable in Kubernetes mode, starting lightweight cleanup task: {}",
|
||||
e
|
||||
);
|
||||
None
|
||||
} else {
|
||||
tracing::error!(
|
||||
"🚨 [CLEANUP_TASK] Failed to get DockerManager: {}, cleanup task cannot start",
|
||||
e
|
||||
);
|
||||
return Err(anyhow::anyhow!("Failed to get DockerManager: {}", e));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if docker_manager.is_none() {
|
||||
let state_for_k8s = state.clone();
|
||||
return Ok(tokio::task::spawn(async move {
|
||||
let mut interval = tokio::time::interval(config.cleanup_interval);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let runtime = match docker_manager::runtime::RuntimeManager::get().await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::warn!("[CLEANUP_TASK] failed to get runtime: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let idle_threshold = match chrono::Duration::from_std(config.idle_timeout) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("[CLEANUP_TASK] invalid idle_timeout config: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let now = chrono::Utc::now();
|
||||
let projects: Vec<(String, Arc<shared_types::ProjectAndContainerInfo>)> =
|
||||
state_for_k8s.projects.iter().collect();
|
||||
|
||||
for (project_id, project_info) in projects {
|
||||
let idle = now.signed_duration_since(project_info.last_activity());
|
||||
if idle < idle_threshold {
|
||||
continue;
|
||||
}
|
||||
|
||||
let service_type = project_info
|
||||
.service_type()
|
||||
.unwrap_or(shared_types::ServiceType::RCoder);
|
||||
let identifier = match service_type {
|
||||
shared_types::ServiceType::ComputerAgentRunner => project_info
|
||||
.user_id()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_else(|| project_id.clone()),
|
||||
shared_types::ServiceType::RCoder => project_id.clone(),
|
||||
};
|
||||
|
||||
if let Err(e) = runtime
|
||||
.stop_container_by_identifier(&identifier, &service_type)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"[CLEANUP_TASK] failed to stop runtime container: identifier={}, service_type={:?}, error={}",
|
||||
identifier,
|
||||
service_type,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
state_for_k8s.remove_project(&project_id);
|
||||
tracing::info!(
|
||||
"[CLEANUP_TASK] cleaned idle runtime container: project_id={}, identifier={}, service_type={:?}",
|
||||
project_id,
|
||||
identifier,
|
||||
service_type
|
||||
);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let pingora_service = state.pingora_service.clone();
|
||||
|
||||
let mut cleaner = AgentCleaner::new(
|
||||
config,
|
||||
state,
|
||||
docker_manager.expect("docker_manager checked above"),
|
||||
pingora_service,
|
||||
);
|
||||
|
||||
Ok(tokio::task::spawn(async move {
|
||||
cleaner.run().await;
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
//! 存储操作辅助模块
|
||||
@@ -0,0 +1,100 @@
|
||||
//! ComputerAgentRunner 清理策略
|
||||
//!
|
||||
//! ComputerAgentRunner 模式: 1容器 = N项目,需要引用计数检查
|
||||
//!
|
||||
//! 核心修复:只有当容器的所有项目都闲置时才销毁容器
|
||||
|
||||
use super::{CleanupContext, CleanupStrategy, DestroyReason, ProjectInfo};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use duckdb_manager::ProjectRecord;
|
||||
use tracing::info;
|
||||
|
||||
/// ComputerAgentRunner 清理策略
|
||||
///
|
||||
/// 在 ComputerAgentRunner 模式中,一个用户对应一个容器
|
||||
/// 该容器可能被多个 project_id 共享
|
||||
///
|
||||
/// 清理时需要检查:
|
||||
/// 1. 获取 user_id
|
||||
/// 2. 查询该用户的所有项目
|
||||
/// 3. 检查是否还有其他活跃项目
|
||||
/// 4. 只有所有项目都闲置时才销毁容器
|
||||
pub struct ComputerRunnerStrategy;
|
||||
|
||||
#[async_trait]
|
||||
impl CleanupStrategy for ComputerRunnerStrategy {
|
||||
async fn should_destroy_container(
|
||||
&self,
|
||||
project_id: &str,
|
||||
context: &CleanupContext,
|
||||
) -> Result<Option<DestroyReason>> {
|
||||
// 获取 user_id
|
||||
let user_id = context
|
||||
.state
|
||||
.get_project(project_id)
|
||||
.and_then(|p| p.user_id().map(|s| s.to_string()))
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get user_id: {}", project_id))?;
|
||||
|
||||
// 查询该用户的所有项目
|
||||
let related_projects = context.state.projects.find_projects_by_user_id(&user_id);
|
||||
|
||||
// 检查是否还有其他活跃项目(排除当前项目)
|
||||
let has_active_refs = related_projects
|
||||
.iter()
|
||||
.any(|p| p.project_id != project_id && is_project_active(p, &context.config));
|
||||
|
||||
if has_active_refs {
|
||||
// 还有其他活跃项目,不销毁容器
|
||||
info!(
|
||||
"🛡️ [cleanup] 容器还被其他项目使用,只删除项目记录: project_id={}, user_id={}",
|
||||
project_id, user_id
|
||||
);
|
||||
Ok(None)
|
||||
} else {
|
||||
// 所有项目都闲置,计算最大闲置时间
|
||||
let now = Utc::now();
|
||||
let max_idle_duration = related_projects
|
||||
.iter()
|
||||
.map(|p| (now - p.last_activity).num_seconds())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
|
||||
let timeout_secs = context.config.idle_timeout.as_secs();
|
||||
|
||||
info!(
|
||||
"🔥 [cleanup] 容器所有项目都已闲置,可以销毁: project_id={}, user_id={}",
|
||||
project_id, user_id
|
||||
);
|
||||
|
||||
Ok(Some(DestroyReason::IdleTimeout {
|
||||
idle_duration_secs: max_idle_duration,
|
||||
timeout_secs,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn get_container_identifier(&self, project_info: &ProjectInfo) -> Result<String> {
|
||||
// ComputerAgentRunner: 容器标识符是 user_id
|
||||
project_info
|
||||
.user_id
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("user_id is missing"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断项目是否活跃
|
||||
///
|
||||
/// 使用 idle_timeout 作为判断标准:如果项目的闲置时间小于 idle_timeout,
|
||||
/// 则认为项目仍然活跃,不应销毁其关联的容器。
|
||||
/// 这与 scanner 的 idle 判断标准一致,避免出现 scanner 认为项目未超时
|
||||
/// 但策略却认为项目不活跃的矛盾情况。
|
||||
pub fn is_project_active(
|
||||
project: &ProjectRecord,
|
||||
config: &crate::cleanup_task::config::CleanupConfig,
|
||||
) -> bool {
|
||||
let now = Utc::now();
|
||||
let idle_duration = now - project.last_activity;
|
||||
idle_duration.num_seconds() < config.idle_timeout.as_secs() as i64
|
||||
}
|
||||
123
qiming-rcoder/crates/rcoder/src/cleanup_task/strategies/mod.rs
Normal file
123
qiming-rcoder/crates/rcoder/src/cleanup_task/strategies/mod.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! 清理策略模块
|
||||
//!
|
||||
//! 定义不同服务类型的清理策略 trait
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 容器销毁原因
|
||||
///
|
||||
/// 记录容器被销毁的具体原因,便于日志追踪和问题排查
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub enum DestroyReason {
|
||||
/// 闲置超时 - 所有项目都超过闲置时间限制
|
||||
/// - RCoder: 项目闲置超时
|
||||
/// - ComputerAgentRunner: 容器下所有项目都闲置
|
||||
IdleTimeout {
|
||||
/// 闲置时长(秒)
|
||||
idle_duration_secs: i64,
|
||||
/// 超时阈值(秒)
|
||||
timeout_secs: u64,
|
||||
},
|
||||
|
||||
/// 孤立容器 - DuckDB 中没有对应记录
|
||||
/// - 容器存在但状态管理系统中没有记录
|
||||
/// - 可能是由于系统重启、异常退出等原因导致
|
||||
Orphaned {
|
||||
/// 容器创建时间
|
||||
created_at: DateTime<Utc>,
|
||||
/// 是否在保护期内
|
||||
was_protected: bool,
|
||||
},
|
||||
|
||||
/// 手动停止 - 用户主动停止或重启
|
||||
/// - 通过 API 调用停止
|
||||
/// - 通过 Agent 生命周期管理停止
|
||||
ManualStop {
|
||||
/// 触发来源
|
||||
source: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl DestroyReason {
|
||||
/// 获取销毁原因的简短描述(用于日志)
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
DestroyReason::IdleTimeout { .. } => "Idle timeout",
|
||||
DestroyReason::Orphaned { .. } => "Orphaned container",
|
||||
DestroyReason::ManualStop { .. } => "Manual stop",
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取销毁原因的详细描述
|
||||
pub fn description(&self) -> String {
|
||||
match self {
|
||||
DestroyReason::IdleTimeout {
|
||||
idle_duration_secs,
|
||||
timeout_secs,
|
||||
} => {
|
||||
format!(
|
||||
"Idle timeout (idle {}s / timeout {}s)",
|
||||
idle_duration_secs, timeout_secs
|
||||
)
|
||||
}
|
||||
DestroyReason::Orphaned {
|
||||
created_at,
|
||||
was_protected,
|
||||
} => {
|
||||
format!(
|
||||
"Orphaned container (created at {}, protected:{})",
|
||||
created_at.format("%Y-%m-%d %H:%M:%S"),
|
||||
was_protected
|
||||
)
|
||||
}
|
||||
DestroyReason::ManualStop { source } => {
|
||||
format!("manual stop (source:{})", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理策略 trait
|
||||
///
|
||||
/// 不同服务类型有不同的容器管理策略:
|
||||
/// - RCoder: 1容器 = 1项目,直接销毁
|
||||
/// - ComputerAgentRunner: 1容器 = N项目,需要引用计数检查
|
||||
#[async_trait]
|
||||
pub trait CleanupStrategy: Send + Sync {
|
||||
/// 检查容器是否应该被销毁
|
||||
///
|
||||
/// 返回 `Some(DestroyReason)` 表示应该销毁容器,并附带原因
|
||||
/// 返回 `None` 表示不应该销毁容器
|
||||
async fn should_destroy_container(
|
||||
&self,
|
||||
project_id: &str,
|
||||
context: &CleanupContext,
|
||||
) -> Result<Option<DestroyReason>>;
|
||||
|
||||
/// 获取容器的唯一标识符(用于查找容器)
|
||||
///
|
||||
/// RCoder 返回 project_id
|
||||
/// ComputerAgentRunner 返回 user_id
|
||||
fn get_container_identifier(&self, project_info: &ProjectInfo) -> Result<String>;
|
||||
}
|
||||
|
||||
/// 清理上下文
|
||||
pub struct CleanupContext {
|
||||
pub state: Arc<crate::router::AppState>,
|
||||
pub config: super::config::CleanupConfig,
|
||||
}
|
||||
|
||||
/// 项目信息摘要
|
||||
pub struct ProjectInfo {
|
||||
pub project_id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub pod_id: Option<String>,
|
||||
pub last_activity: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub mod computer_runner;
|
||||
pub mod rcoder;
|
||||
@@ -0,0 +1,91 @@
|
||||
//! RCoder 清理策略
|
||||
//!
|
||||
//! RCoder 模式支持两种容器关系:
|
||||
//! - 无 pod_id: 1容器 = 1项目,直接销毁
|
||||
//! - 有 pod_id: N容器 = 1项目(共享容器),需要引用计数检查
|
||||
|
||||
use super::{CleanupContext, CleanupStrategy, DestroyReason, ProjectInfo};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use duckdb_manager::ProjectRecord;
|
||||
use tracing::info;
|
||||
|
||||
/// RCoder 清理策略
|
||||
///
|
||||
/// 在 RCoder 模式中:
|
||||
/// - 当 pod_id 为空时,每个项目对应一个独立的容器,清理时直接销毁
|
||||
/// - 当 pod_id 有值时,多个项目共享同一个容器,需要检查其他项目是否仍然活跃
|
||||
pub struct RCoderStrategy;
|
||||
|
||||
#[async_trait]
|
||||
impl CleanupStrategy for RCoderStrategy {
|
||||
async fn should_destroy_container(
|
||||
&self,
|
||||
project_id: &str,
|
||||
context: &CleanupContext,
|
||||
) -> Result<Option<DestroyReason>> {
|
||||
let project = context
|
||||
.state
|
||||
.get_project(project_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Project does not exist: {}", project_id))?;
|
||||
|
||||
let now = Utc::now();
|
||||
let idle_duration = (now - project.last_activity()).num_seconds();
|
||||
let timeout_secs = context.config.idle_timeout.as_secs();
|
||||
|
||||
// 检查是否有 pod_id(共享容器模式)
|
||||
let effective_idle_duration = if let Some(pod_id) = project.pod_id() {
|
||||
// 共享容器模式:检查同 pod_id 下是否有其他活跃项目
|
||||
let related_projects = context.state.projects.find_projects_by_pod_id(pod_id);
|
||||
|
||||
let has_active_refs = related_projects.iter().any(|p| {
|
||||
p.project_id != project_id
|
||||
&& super::computer_runner::is_project_active(p, &context.config)
|
||||
});
|
||||
|
||||
if has_active_refs {
|
||||
info!(
|
||||
"🛡️ [cleanup] RCoder 容器还被其他项目使用,只删除项目记录: project_id={}, pod_id={}",
|
||||
project_id, pod_id
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 计算所有相关项目的最大闲置时间(与 ComputerRunnerStrategy 一致)
|
||||
let max_idle = compute_max_idle_duration(&related_projects);
|
||||
|
||||
info!(
|
||||
"🔥 [cleanup] RCoder 共享容器所有项目都已闲置,可以销毁: project_id={}, pod_id={}, max_idle={}s",
|
||||
project_id, pod_id, max_idle
|
||||
);
|
||||
|
||||
max_idle
|
||||
} else {
|
||||
idle_duration
|
||||
};
|
||||
|
||||
Ok(Some(DestroyReason::IdleTimeout {
|
||||
idle_duration_secs: effective_idle_duration,
|
||||
timeout_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
fn get_container_identifier(&self, project_info: &ProjectInfo) -> Result<String> {
|
||||
// RCoder: 有 pod_id 时使用 pod_id(共享容器),否则使用 project_id
|
||||
Ok(project_info
|
||||
.pod_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| project_info.project_id.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算一组项目的最大闲置时间(秒)
|
||||
fn compute_max_idle_duration(projects: &[ProjectRecord]) -> i64 {
|
||||
let now = Utc::now();
|
||||
projects
|
||||
.iter()
|
||||
.map(|p| (now - p.last_activity).num_seconds())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
678
qiming-rcoder/crates/rcoder/src/config.rs
Normal file
678
qiming-rcoder/crates/rcoder/src/config.rs
Normal file
@@ -0,0 +1,678 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// 命令行参数
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "rcoder")]
|
||||
#[command(about = "RCoder - Rust-based AI Agent Framework")]
|
||||
#[command(version)]
|
||||
pub struct CliArgs {
|
||||
/// 主服务端口
|
||||
#[arg(short = 'p', long)]
|
||||
pub port: Option<u16>,
|
||||
|
||||
/// 项目工作目录
|
||||
#[arg(short = 'd', long, default_value = "./project_workspace")]
|
||||
pub projects_dir: Option<String>,
|
||||
|
||||
/// 启用反向代理
|
||||
#[arg(short, long)]
|
||||
pub enable_proxy: bool,
|
||||
|
||||
/// 代理服务端口
|
||||
#[arg(long = "proxy-port")]
|
||||
pub proxy_port: Option<u16>,
|
||||
|
||||
/// 默认后端服务端口
|
||||
#[arg(long = "backend-port")]
|
||||
pub default_backend_port: Option<u16>,
|
||||
}
|
||||
|
||||
// 从 shared_types 导入 API Key 鉴权配置
|
||||
pub use shared_types::ApiKeyAuthConfig;
|
||||
|
||||
/// 应用程序配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
/// 默认使用的 Agent ID
|
||||
#[serde(default = "default_agent_id", alias = "default_agent")]
|
||||
pub default_agent_id: String,
|
||||
/// 项目工作目录
|
||||
pub projects_dir: PathBuf,
|
||||
/// 主服务端口
|
||||
pub port: u16,
|
||||
/// 反向代理配置
|
||||
pub proxy_config: Option<ProxyConfig>,
|
||||
/// Docker 配置
|
||||
pub docker_config: Option<DockerConfig>,
|
||||
/// 容器清理配置
|
||||
#[serde(default)]
|
||||
pub cleanup_config: CleanupConfigSettings,
|
||||
/// API Key 鉴权配置
|
||||
#[serde(default)]
|
||||
pub api_key_auth: ApiKeyAuthConfig,
|
||||
}
|
||||
|
||||
fn default_agent_id() -> String {
|
||||
"claude-code-acp-ts".to_string()
|
||||
}
|
||||
|
||||
/// 生成随机 API Key
|
||||
/// 使用 UUID v4 生成随机密钥,格式:sk-{uuid}
|
||||
fn generate_random_api_key() -> String {
|
||||
use uuid::Uuid;
|
||||
let uuid = Uuid::new_v4();
|
||||
format!("sk-{}", uuid.simple())
|
||||
}
|
||||
|
||||
/// 健康检查配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthCheckConfig {
|
||||
/// 是否启用健康检查
|
||||
pub enabled: bool,
|
||||
/// 检查间隔(秒)
|
||||
pub interval_seconds: u64,
|
||||
/// 超时时间(秒)
|
||||
pub timeout_seconds: u64,
|
||||
/// 健康阈值
|
||||
pub healthy_threshold: u32,
|
||||
/// 不健康阈值
|
||||
pub unhealthy_threshold: u32,
|
||||
}
|
||||
|
||||
/// 反向代理配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxyConfig {
|
||||
/// 代理服务监听端口
|
||||
pub listen_port: u16,
|
||||
/// 默认后端服务端口
|
||||
pub default_backend_port: u16,
|
||||
/// 后端服务主机地址
|
||||
pub backend_host: String,
|
||||
/// 端口参数名称
|
||||
pub port_param: String,
|
||||
/// 健康检查配置
|
||||
pub health_check: HealthCheckConfig,
|
||||
}
|
||||
|
||||
/// 日志清理配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogCleanupConfig {
|
||||
/// 日志目录路径
|
||||
#[serde(default = "default_log_dir")]
|
||||
pub log_dir: String,
|
||||
/// 日志保留天数,默认10天
|
||||
#[serde(default = "default_log_retention_days")]
|
||||
pub log_retention_days: u64,
|
||||
}
|
||||
|
||||
fn default_log_dir() -> String {
|
||||
"/app/logs/container".to_string()
|
||||
}
|
||||
|
||||
fn default_log_retention_days() -> u64 {
|
||||
10
|
||||
}
|
||||
|
||||
impl Default for LogCleanupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_dir: default_log_dir(),
|
||||
log_retention_days: default_log_retention_days(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 容器清理配置(配置文件格式,使用秒作为单位)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CleanupConfigSettings {
|
||||
/// 是否启用容器清理功能(默认禁用,更安全)
|
||||
#[serde(default = "default_cleanup_enabled")]
|
||||
pub enabled: bool,
|
||||
/// 闲置超时时间(秒),默认600秒(10分钟)
|
||||
#[serde(default = "default_idle_timeout_seconds")]
|
||||
pub idle_timeout_seconds: u64,
|
||||
/// 清理检查间隔(秒),默认300秒(5分钟)
|
||||
#[serde(default = "default_cleanup_interval_seconds")]
|
||||
pub cleanup_interval_seconds: u64,
|
||||
/// Docker容器停止超时时间(秒),默认30秒
|
||||
#[serde(default = "default_docker_stop_timeout_seconds")]
|
||||
pub docker_stop_timeout_seconds: u64,
|
||||
/// 容器最小保护时间(秒),默认300秒(5分钟)
|
||||
#[serde(default = "default_container_protection_seconds")]
|
||||
pub container_protection_seconds: u64,
|
||||
/// 日志清理配置
|
||||
#[serde(default)]
|
||||
pub log_cleanup: LogCleanupConfig,
|
||||
}
|
||||
|
||||
fn default_cleanup_enabled() -> bool {
|
||||
true // 默认启用容器清理功能
|
||||
}
|
||||
|
||||
fn default_idle_timeout_seconds() -> u64 {
|
||||
600 // 10分钟
|
||||
}
|
||||
|
||||
fn default_cleanup_interval_seconds() -> u64 {
|
||||
300 // 5分钟
|
||||
}
|
||||
|
||||
fn default_docker_stop_timeout_seconds() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_container_protection_seconds() -> u64 {
|
||||
300 // 5分钟
|
||||
}
|
||||
|
||||
impl Default for CleanupConfigSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_cleanup_enabled(),
|
||||
idle_timeout_seconds: default_idle_timeout_seconds(),
|
||||
cleanup_interval_seconds: default_cleanup_interval_seconds(),
|
||||
docker_stop_timeout_seconds: default_docker_stop_timeout_seconds(),
|
||||
container_protection_seconds: default_container_protection_seconds(),
|
||||
log_cleanup: LogCleanupConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Docker 配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DockerConfig {
|
||||
/// 多镜像配置
|
||||
pub multi_image_config: Option<shared_types::MultiImageConfig>,
|
||||
/// 网络模式
|
||||
pub network_mode: Option<String>,
|
||||
/// 工作目录
|
||||
pub work_dir: Option<String>,
|
||||
/// 自动清理
|
||||
pub auto_cleanup: Option<bool>,
|
||||
/// 容器存活时间(秒)
|
||||
pub container_ttl_seconds: Option<u64>,
|
||||
/// 网络基础名称(不含 project name 前缀)
|
||||
/// Docker Compose 会自动添加 project name 前缀,实际网络名称为 {project_name}_{network_base_name}
|
||||
/// 例如: network_base_name="agent-network" 时,实际网络为 "rcoder_agent-network"
|
||||
pub network_base_name: Option<String>,
|
||||
/// 🔧 Docker API 调用超时时间(秒)
|
||||
pub api_timeout_seconds: Option<u64>,
|
||||
/// 🔧 快速操作超时时间(秒)
|
||||
pub api_timeout_quick_seconds: Option<u64>,
|
||||
/// 🔧 状态缓存 TTL(秒)
|
||||
pub cache_status_ttl_seconds: Option<u64>,
|
||||
/// 🔧 网络缓存 TTL(秒)
|
||||
pub cache_network_ttl_seconds: Option<u64>,
|
||||
/// 🔧 缓存最大容量
|
||||
pub cache_max_capacity: Option<u64>,
|
||||
}
|
||||
|
||||
pub const CONFIG_FILE: &str = "config.yml";
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_agent_id: default_agent_id(),
|
||||
projects_dir: PathBuf::from("./project_workspace"),
|
||||
port: 8087,
|
||||
proxy_config: Some(ProxyConfig::default()),
|
||||
docker_config: Some(DockerConfig::default()),
|
||||
cleanup_config: CleanupConfigSettings::default(),
|
||||
api_key_auth: ApiKeyAuthConfig {
|
||||
enabled: false,
|
||||
api_key: generate_random_api_key(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProxyConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
listen_port: 8088,
|
||||
default_backend_port: 8086,
|
||||
backend_host: "127.0.0.1".to_string(),
|
||||
port_param: "port".to_string(),
|
||||
health_check: HealthCheckConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HealthCheckConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
interval_seconds: 5,
|
||||
timeout_seconds: 1,
|
||||
healthy_threshold: 2,
|
||||
unhealthy_threshold: 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DockerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
multi_image_config: Some(shared_types::create_default_multi_image_config()),
|
||||
network_mode: Some("bridge".to_string()),
|
||||
work_dir: Some("/app".to_string()),
|
||||
auto_cleanup: Some(true),
|
||||
container_ttl_seconds: Some(3600),
|
||||
network_base_name: Some("agent-network".to_string()),
|
||||
// 🔧 新增字段默认值
|
||||
api_timeout_seconds: Some(10),
|
||||
api_timeout_quick_seconds: Some(5),
|
||||
cache_status_ttl_seconds: Some(10),
|
||||
cache_network_ttl_seconds: Some(15),
|
||||
cache_max_capacity: Some(10000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DockerConfig {
|
||||
/// 获取多镜像配置,如果没有配置多镜像配置,会从传统配置自动转换
|
||||
pub fn get_multi_image_config(&self) -> shared_types::MultiImageConfig {
|
||||
if let Some(ref multi_config) = self.multi_image_config {
|
||||
multi_config.clone()
|
||||
} else {
|
||||
// 从传统配置创建多镜像配置
|
||||
self.create_legacy_multi_config()
|
||||
}
|
||||
}
|
||||
|
||||
/// 从传统配置创建多镜像配置
|
||||
fn create_legacy_multi_config(&self) -> shared_types::MultiImageConfig {
|
||||
info!("Config created from legacy config");
|
||||
|
||||
// 创建基于传统配置的多镜像配置
|
||||
let mut services = std::collections::HashMap::new();
|
||||
|
||||
// 为 RCoder 服务使用默认配置
|
||||
let rcoder_service = {
|
||||
info!("Using default config");
|
||||
shared_types::service_config::default_rcoder_service_config()
|
||||
};
|
||||
|
||||
services.insert("rcoder".to_string(), rcoder_service);
|
||||
|
||||
// 为 AgentRunner 服务使用默认配置
|
||||
services.insert(
|
||||
"agent-runner".to_string(),
|
||||
shared_types::service_config::default_agent_runner_service_config(),
|
||||
);
|
||||
|
||||
shared_types::MultiImageConfig {
|
||||
services,
|
||||
global_defaults: shared_types::GlobalImageDefaults {
|
||||
image: None,
|
||||
arm64_image: None,
|
||||
amd64_image: None,
|
||||
default_image: None,
|
||||
registry_prefix: None,
|
||||
},
|
||||
selection_strategy: shared_types::ImageSelectionStrategy::ServiceOnly,
|
||||
cache_config: shared_types::ImageCacheConfig {
|
||||
enabled: true,
|
||||
ttl_seconds: 3600,
|
||||
max_entries: 100,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证多镜像配置
|
||||
pub fn validate_multi_image_config(&self) -> Result<(), String> {
|
||||
let multi_config = self.get_multi_image_config();
|
||||
match multi_config.validate() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否使用多镜像配置
|
||||
pub fn is_using_multi_image_config(&self) -> bool {
|
||||
self.multi_image_config.is_some()
|
||||
}
|
||||
|
||||
/// 应用环境变量覆盖
|
||||
pub fn apply_env_overrides(&mut self) -> anyhow::Result<()> {
|
||||
// 应用网络模式
|
||||
if let Ok(val) = std::env::var("RCODER_NETWORK_MODE") {
|
||||
info!("RCODER_NETWORK_MODE overridden");
|
||||
self.network_mode = Some(val);
|
||||
}
|
||||
|
||||
// 应用网络基础名称
|
||||
if let Ok(val) = std::env::var("RCODER_NETWORK_BASE_NAME") {
|
||||
info!("RCODER_NETWORK_BASE_NAME: {}", val);
|
||||
self.network_base_name = Some(val);
|
||||
}
|
||||
|
||||
// 应用工作目录
|
||||
if let Ok(val) = std::env::var("RCODER_WORK_DIR") {
|
||||
info!("RCODER_WORK_DIR overridden");
|
||||
self.work_dir = Some(val);
|
||||
}
|
||||
|
||||
// 应用自动清理
|
||||
if let Ok(val) = std::env::var("RCODER_AUTO_CLEANUP") {
|
||||
info!("RCODER_AUTO_CLEANUP overridden");
|
||||
self.auto_cleanup = Some(val.parse().unwrap_or(true));
|
||||
}
|
||||
|
||||
// 应用容器存活时间
|
||||
if let Ok(val) = std::env::var("RCODER_CONTAINER_TTL") {
|
||||
info!("RCODER_CONTAINER_TTL overridden");
|
||||
match val.parse() {
|
||||
Ok(seconds) => self.container_ttl_seconds = Some(seconds),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"⚠️ [CONFIG] Failed to parse RCODER_CONTAINER_TTL '{}': {}, using default",
|
||||
val,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 应用 API 超时配置
|
||||
if let Ok(val) = std::env::var("RCODER_API_TIMEOUT_SECONDS") {
|
||||
info!("RCODER_API_TIMEOUT_SECONDS overridden");
|
||||
match val.parse() {
|
||||
Ok(seconds) => self.api_timeout_seconds = Some(seconds),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"⚠️ [CONFIG] Failed to parse RCODER_API_TIMEOUT_SECONDS '{}': {}, using default",
|
||||
val,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 应用快速操作超时配置
|
||||
if let Ok(val) = std::env::var("RCODER_API_TIMEOUT_QUICK_SECONDS") {
|
||||
info!("RCODER_API_TIMEOUT_QUICK_SECONDS overridden");
|
||||
match val.parse() {
|
||||
Ok(seconds) => self.api_timeout_quick_seconds = Some(seconds),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"⚠️ [CONFIG] Failed to parse RCODER_API_TIMEOUT_QUICK_SECONDS '{}': {}, using default",
|
||||
val,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 应用状态缓存 TTL 配置
|
||||
if let Ok(val) = std::env::var("RCODER_CACHE_STATUS_TTL_SECONDS") {
|
||||
info!("RCODER_CACHE_STATUS_TTL_SECONDS overridden");
|
||||
match val.parse() {
|
||||
Ok(seconds) => self.cache_status_ttl_seconds = Some(seconds),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"⚠️ [CONFIG] Failed to parse RCODER_CACHE_STATUS_TTL_SECONDS '{}': {}, using default",
|
||||
val,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 应用网络缓存 TTL 配置
|
||||
if let Ok(val) = std::env::var("RCODER_CACHE_NETWORK_TTL_SECONDS") {
|
||||
info!("RCODER_CACHE_NETWORK_TTL_SECONDS overridden");
|
||||
match val.parse() {
|
||||
Ok(seconds) => self.cache_network_ttl_seconds = Some(seconds),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"⚠️ [CONFIG] Failed to parse RCODER_CACHE_NETWORK_TTL_SECONDS '{}': {}, using default",
|
||||
val,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 应用缓存最大容量配置
|
||||
if let Ok(val) = std::env::var("RCODER_CACHE_MAX_CAPACITY") {
|
||||
info!("RCODER_CACHE_MAX_CAPACITY overridden");
|
||||
match val.parse() {
|
||||
Ok(capacity) => self.cache_max_capacity = Some(capacity),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"⚠️ [CONFIG] Failed to parse RCODER_CACHE_MAX_CAPACITY '{}': {}, using default",
|
||||
val,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get configuration summary
|
||||
pub fn get_summary(&self) -> String {
|
||||
format!(
|
||||
"Docker config: network_mode={}, network_base_name={}, work_dir={}, auto_cleanup={}, container_ttl={}, api_timeout={}s, quick_timeout={}s, status_cache={}s, network_cache={}s, cache_max_capacity={}",
|
||||
self.network_mode.as_deref().unwrap_or("default"),
|
||||
self.network_base_name.as_deref().unwrap_or("agent-network"),
|
||||
self.work_dir.as_deref().unwrap_or("/app"),
|
||||
self.auto_cleanup.unwrap_or(true),
|
||||
self.container_ttl_seconds.unwrap_or(3600),
|
||||
self.api_timeout_seconds.unwrap_or(10),
|
||||
self.api_timeout_quick_seconds.unwrap_or(5),
|
||||
self.cache_status_ttl_seconds.unwrap_or(10),
|
||||
self.cache_network_ttl_seconds.unwrap_or(15),
|
||||
self.cache_max_capacity.unwrap_or(10000)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载配置(命令行参数 + 配置文件 + 环境变量)
|
||||
pub fn load_config_with_args(cli_args: CliArgs) -> anyhow::Result<AppConfig> {
|
||||
let mut config = if std::path::Path::new(CONFIG_FILE).exists() {
|
||||
// 尝试从文件加载配置
|
||||
match load_config_from_file() {
|
||||
Ok(file_config) => {
|
||||
info!("Config file already loaded: {}", CONFIG_FILE);
|
||||
file_config
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to load config file, using default config: {}", e);
|
||||
AppConfig::default()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!(
|
||||
"configfilenot found, createddefaultconfigfile: {}",
|
||||
CONFIG_FILE
|
||||
);
|
||||
let default_config = AppConfig::default();
|
||||
create_default_config_file(&default_config)?;
|
||||
default_config
|
||||
};
|
||||
|
||||
// 命令行参数覆盖配置文件
|
||||
if let Some(port) = cli_args.port {
|
||||
config.port = port;
|
||||
}
|
||||
|
||||
if let Some(projects_dir) = cli_args.projects_dir {
|
||||
config.projects_dir = PathBuf::from(projects_dir);
|
||||
}
|
||||
|
||||
// 环境变量覆盖所有配置
|
||||
if let Ok(port) = std::env::var("RCODER_PORT") {
|
||||
if let Ok(port) = port.parse::<u16>() {
|
||||
config.port = port;
|
||||
} else {
|
||||
warn!(" parse RCODER_PORT failed: {}", port);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(projects_dir) = std::env::var("RCODER_PROJECTS_DIR") {
|
||||
config.projects_dir = PathBuf::from(projects_dir);
|
||||
}
|
||||
|
||||
// 如果启用了代理,配置代理相关参数
|
||||
if cli_args.enable_proxy {
|
||||
let mut proxy_config = ProxyConfig::default();
|
||||
|
||||
if let Some(proxy_port) = cli_args.proxy_port {
|
||||
proxy_config.listen_port = proxy_port;
|
||||
}
|
||||
|
||||
if let Some(default_backend_port) = cli_args.default_backend_port {
|
||||
proxy_config.default_backend_port = default_backend_port;
|
||||
}
|
||||
|
||||
config.proxy_config = Some(proxy_config);
|
||||
}
|
||||
|
||||
// 应用 Docker 配置的环境变量覆盖
|
||||
if let Some(docker_config) = &mut config.docker_config {
|
||||
docker_config.apply_env_overrides()?;
|
||||
}
|
||||
|
||||
// 应用 API Key 配置的环境变量覆盖
|
||||
if let Ok(val) = std::env::var("RCODER_API_KEY_ENABLED") {
|
||||
if let Ok(enabled) = val.parse::<bool>() {
|
||||
config.api_key_auth.enabled = enabled;
|
||||
info!(" RCODER_API_KEY_ENABLED: {}", enabled);
|
||||
} else {
|
||||
warn!(" parse RCODER_API_KEY_ENABLED failed: {}", val);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(val) = std::env::var("RCODER_API_KEY") {
|
||||
config.api_key_auth.api_key = val.clone();
|
||||
info!(" RCODER_API_KEY configured");
|
||||
}
|
||||
|
||||
// 验证 API Key 配置
|
||||
if config.api_key_auth.enabled && config.api_key_auth.api_key.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"API Key authentication is enabled but API Key is empty, please check config file or environment variables"
|
||||
));
|
||||
}
|
||||
|
||||
// 配置验证
|
||||
if let Some(docker_config) = &config.docker_config {
|
||||
if let Err(e) = docker_config.validate_multi_image_config() {
|
||||
return Err(anyhow::anyhow!("Docker configuration validation failed: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Final config: port={}, projects_dir={:?}, default_agent_id={}, proxy_enabled={}",
|
||||
config.port,
|
||||
config.projects_dir,
|
||||
config.default_agent_id,
|
||||
config.proxy_config.is_some()
|
||||
);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 加载配置(保留旧接口以保持兼容性)
|
||||
pub fn load_config() -> anyhow::Result<AppConfig> {
|
||||
let cli_args = CliArgs {
|
||||
port: None,
|
||||
projects_dir: None,
|
||||
enable_proxy: false,
|
||||
proxy_port: None,
|
||||
default_backend_port: None,
|
||||
};
|
||||
load_config_with_args(cli_args)
|
||||
}
|
||||
|
||||
/// 从文件加载配置
|
||||
fn load_config_from_file() -> anyhow::Result<AppConfig> {
|
||||
let config_content =
|
||||
fs::read_to_string(CONFIG_FILE).map_err(|e| anyhow::anyhow!("Failed to read config file: {}", e))?;
|
||||
|
||||
tracing::debug!("config file content: {}", config_content);
|
||||
|
||||
let config: AppConfig = serde_yaml::from_str(&config_content)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse config file: {}", e))?;
|
||||
|
||||
// 调试:打印解析后的多镜像配置
|
||||
if let Some(ref docker_config) = config.docker_config {
|
||||
tracing::info!("[CONFIG] docker_config is Some, checking multi_image_config");
|
||||
if let Some(ref multi_config) = docker_config.multi_image_config {
|
||||
tracing::info!("[CONFIG] multi_image_config is Some, services count: {}", multi_config.services.len());
|
||||
for (service_key, service_config) in &multi_config.services {
|
||||
tracing::info!("[CONFIG] Service '{}': arm64_image={:?}, amd64_image={:?}",
|
||||
service_key, service_config.arm64_image, service_config.amd64_image);
|
||||
tracing::debug!(
|
||||
" Service '{}' mount config (total {} mounts):",
|
||||
service_key,
|
||||
service_config.mounts.len()
|
||||
);
|
||||
for (i, mount) in service_config.mounts.iter().enumerate() {
|
||||
tracing::debug!(
|
||||
" [{}]: {} -> {} ({})",
|
||||
i,
|
||||
mount.container_path,
|
||||
mount.host_path,
|
||||
mount.mount_type
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 从配置文件中仅加载 API Key 配置(用于热更新)
|
||||
///
|
||||
/// 此函数由 config_watcher 模块调用,用于配置热重载。
|
||||
/// 编译器可能误报为未使用,因为是跨模块调用。
|
||||
#[allow(dead_code)]
|
||||
pub fn load_api_key_config_from_file(
|
||||
config_path: &std::path::Path,
|
||||
) -> anyhow::Result<ApiKeyAuthConfig> {
|
||||
let config_content =
|
||||
fs::read_to_string(config_path).map_err(|e| anyhow::anyhow!("Failed to read config file: {}", e))?;
|
||||
|
||||
let config: AppConfig = serde_yaml::from_str(&config_content)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse config file: {}", e))?;
|
||||
|
||||
Ok(config.api_key_auth)
|
||||
}
|
||||
|
||||
/// 创建默认配置文件
|
||||
fn create_default_config_file(_config: &AppConfig) -> anyhow::Result<()> {
|
||||
// 检查配置文件是否已存在
|
||||
if std::path::Path::new(CONFIG_FILE).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 创建配置文件目录(如果不存在)
|
||||
if let Some(parent) = std::path::Path::new(CONFIG_FILE).parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| anyhow::anyhow!("Failed to create config directory: {}", e))?;
|
||||
}
|
||||
|
||||
// 使用嵌入式配置文件
|
||||
let default_config = include_str!("rcoder_default.yml");
|
||||
|
||||
// 🆕 生成随机 API Key 并替换模板占位符
|
||||
let generated_api_key = generate_random_api_key();
|
||||
let config_content = default_config.replace("{{GENERATED_API_KEY}}", &generated_api_key);
|
||||
|
||||
fs::write(CONFIG_FILE, config_content)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write default config file: {}", e))?;
|
||||
|
||||
info!("Created default config file: {}", CONFIG_FILE);
|
||||
info!("🔑 Loaded API Key (not set)");
|
||||
Ok(())
|
||||
}
|
||||
205
qiming-rcoder/crates/rcoder/src/config_watcher.rs
Normal file
205
qiming-rcoder/crates/rcoder/src/config_watcher.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! 配置文件监控模块
|
||||
//!
|
||||
//! 本模块使用 `notify` crate 实现配置文件的实时监控和热更新。
|
||||
//! 当 `config.yml` 文件被修改时,自动重新加载 API Key 配置,无需重启服务。
|
||||
//!
|
||||
//! # 功能特性
|
||||
//!
|
||||
//! - 实时监控配置文件变化(基于文件系统事件)
|
||||
//! - 自动重载 API Key 配置
|
||||
//! - 配置验证(防止空 API Key)
|
||||
//! - 详细的变更日志记录
|
||||
//! - 线程安全的配置更新(使用 RwLock)
|
||||
//!
|
||||
//! # 使用示例
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use std::sync::{Arc, RwLock};
|
||||
//! use std::path::PathBuf;
|
||||
//! # use crate::config::ApiKeyAuthConfig;
|
||||
//! # use crate::config_watcher::ConfigWatcher;
|
||||
//!
|
||||
//! let api_key_config = Arc::new(ArcSwap::from_pointee(ApiKeyAuthConfig::default()));
|
||||
//! let config_path = PathBuf::from("config.yml");
|
||||
//!
|
||||
//! match ConfigWatcher::new(config_path, api_key_config) {
|
||||
//! Ok(watcher) => {
|
||||
//! println!("config watcher already started");
|
||||
//! // watcher 必须保持存活,否则监控会停止
|
||||
//! }
|
||||
//! Err(e) => {
|
||||
//! eprintln!("config watcher start failed: {}", e);
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use anyhow::Result;
|
||||
use arc_swap::ArcSwap;
|
||||
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::config::{ApiKeyAuthConfig, load_api_key_config_from_file};
|
||||
|
||||
/// 配置文件监控器
|
||||
///
|
||||
/// 内部持有 `RecommendedWatcher` 以保持文件监控活跃。
|
||||
/// 一旦 ConfigWatcher 被 drop,文件监控将停止。
|
||||
///
|
||||
/// # 注意
|
||||
///
|
||||
/// `config_path` 和 `api_key_config` 字段虽然未直接读取,
|
||||
/// 但它们被 spawn 的异步任务闭包捕获,因此必须保留。
|
||||
pub struct ConfigWatcher {
|
||||
/// 文件系统监控器(必须保持存活)
|
||||
_watcher: RecommendedWatcher,
|
||||
/// 配置文件路径(未直接访问,但被闭包捕获)
|
||||
#[allow(dead_code)]
|
||||
config_path: PathBuf,
|
||||
/// API Key 配置的共享引用(未直接访问,但被闭包捕获)
|
||||
#[allow(dead_code)]
|
||||
api_key_config: Arc<ArcSwap<ApiKeyAuthConfig>>,
|
||||
}
|
||||
|
||||
impl ConfigWatcher {
|
||||
/// 创建新的配置监控器
|
||||
pub fn new(
|
||||
config_path: PathBuf,
|
||||
api_key_config: Arc<ArcSwap<ApiKeyAuthConfig>>,
|
||||
) -> Result<Self> {
|
||||
let (tx, mut rx) = mpsc::channel(100);
|
||||
|
||||
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let _ = tx.blocking_send(event);
|
||||
}
|
||||
})?;
|
||||
|
||||
watcher.watch(&config_path, RecursiveMode::NonRecursive)?;
|
||||
|
||||
info!(
|
||||
"📁 [CONFIG_WATCHER] Starting config file watch: {:?}",
|
||||
config_path
|
||||
);
|
||||
|
||||
// 克隆必要的数据以在 tokio 任务中使用
|
||||
let config_path_clone = config_path.clone();
|
||||
let api_key_config_clone = Arc::clone(&api_key_config);
|
||||
|
||||
// 启动配置监控任务
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Some(event) = rx.recv().await {
|
||||
// 只处理修改事件
|
||||
if matches!(event.kind, EventKind::Modify(_)) {
|
||||
// 添加短暂延迟,避免文件未完全写入
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
if let Err(e) = Self::reload_config(
|
||||
&config_path_clone,
|
||||
Arc::clone(&api_key_config_clone),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(" [CONFIG_WATCHER] config reload failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
_watcher: watcher,
|
||||
config_path,
|
||||
api_key_config,
|
||||
})
|
||||
}
|
||||
|
||||
/// 重新加载配置(使用 ArcSwap 无锁更新)
|
||||
async fn reload_config(
|
||||
config_path: &Path,
|
||||
api_key_config: Arc<ArcSwap<ApiKeyAuthConfig>>,
|
||||
) -> Result<()> {
|
||||
match load_api_key_config_from_file(config_path) {
|
||||
Ok(new_config) => {
|
||||
// 验证配置有效性
|
||||
if new_config.enabled && new_config.api_key.trim().is_empty() {
|
||||
error!("[CONFIG_WATCHER] API Key is empty");
|
||||
return Err(anyhow::anyhow!("API Key cannot be empty string"));
|
||||
}
|
||||
|
||||
// 🚀 使用 ArcSwap 原子更新配置(无锁,不阻塞读取)
|
||||
let old_config = api_key_config.load();
|
||||
let old_enabled = old_config.enabled;
|
||||
let key_changed = old_config.api_key != new_config.api_key;
|
||||
|
||||
// 提前保存新配置状态(用于日志)
|
||||
let new_enabled = new_config.enabled;
|
||||
|
||||
// 原子替换配置(移动所有权,避免 clone)
|
||||
api_key_config.store(Arc::new(new_config));
|
||||
|
||||
// 记录配置变更
|
||||
if old_enabled != new_enabled {
|
||||
info!(
|
||||
"🔄 [CONFIG_WATCHER] API Key auth status updated: {} -> {}",
|
||||
old_enabled, new_enabled
|
||||
);
|
||||
}
|
||||
|
||||
if key_changed {
|
||||
info!("[CONFIG_WATCHER] API Key alreadyupdated");
|
||||
}
|
||||
|
||||
if !old_enabled && !new_enabled && !key_changed {
|
||||
// 配置未实际变化,不记录日志
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("[CONFIG_WATCHER] Config update succeeded");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("[CONFIG_WATCHER] Config file reload failed: {}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_watcher_creation() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let config_path = temp_dir.path().join("config.yml");
|
||||
|
||||
// 创建测试配置文件
|
||||
fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
api_key_auth:
|
||||
enabled: false
|
||||
api_key: "sk-test123"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let api_key_config = Arc::new(ArcSwap::from_pointee(ApiKeyAuthConfig {
|
||||
enabled: false,
|
||||
api_key: "sk-test123".to_string(),
|
||||
}));
|
||||
|
||||
let watcher = ConfigWatcher::new(config_path, api_key_config);
|
||||
assert!(watcher.is_ok());
|
||||
}
|
||||
}
|
||||
235
qiming-rcoder/crates/rcoder/src/grpc/channel_pool.rs
Normal file
235
qiming-rcoder/crates/rcoder/src/grpc/channel_pool.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
//! gRPC Channel 连接池
|
||||
//!
|
||||
//! 管理到各个 agent_runner 容器的 gRPC 连接,支持 TTL 自动清理失效连接。
|
||||
|
||||
use anyhow::Result;
|
||||
use dashmap::DashMap;
|
||||
use shared_types::grpc::agent_service_client::AgentServiceClient;
|
||||
use std::time::{Duration, Instant};
|
||||
use tonic::transport::Channel;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// gRPC 连接池 TTL(5分钟)
|
||||
///
|
||||
/// 连接超过此时间未被使用则自动清理,防止内存泄漏。
|
||||
const CHANNEL_TTL_SECS: u64 = 300;
|
||||
|
||||
/// gRPC 连接池最大容量
|
||||
const MAX_CAPACITY: usize = 10000;
|
||||
|
||||
/// 创建配置好的 gRPC 客户端(设置消息大小限制)
|
||||
///
|
||||
/// tonic 的消息大小限制是在 AgentServiceClient 级别配置的,
|
||||
/// 无法在 Channel 或 Endpoint 级别统一配置,所以需要这个辅助函数。
|
||||
fn create_configured_client(channel: Channel) -> AgentServiceClient<Channel> {
|
||||
AgentServiceClient::new(channel)
|
||||
.max_decoding_message_size(shared_types::GRPC_MAX_MESSAGE_SIZE)
|
||||
.max_encoding_message_size(shared_types::GRPC_MAX_MESSAGE_SIZE)
|
||||
}
|
||||
|
||||
/// Channel 元数据(包含创建时间)
|
||||
#[derive(Clone)]
|
||||
struct ChannelEntry {
|
||||
channel: Channel,
|
||||
created_at: Instant,
|
||||
}
|
||||
|
||||
impl ChannelEntry {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.created_at.elapsed() > Duration::from_secs(CHANNEL_TTL_SECS)
|
||||
}
|
||||
}
|
||||
|
||||
/// gRPC 连接池
|
||||
///
|
||||
/// 为每个容器维护独立的 gRPC 连接,支持:
|
||||
/// - 连接复用:相同地址的请求复用同一连接
|
||||
/// - TTL 自动清理:5分钟未使用的连接自动移除,防止内存泄漏
|
||||
/// - 并发安全:支持高并发下的安全连接创建
|
||||
pub struct GrpcChannelPool {
|
||||
/// 容器地址到 Channel 的映射
|
||||
channels: DashMap<String, ChannelEntry>,
|
||||
}
|
||||
|
||||
impl GrpcChannelPool {
|
||||
/// 创建新的连接池
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定地址的 gRPC 客户端
|
||||
///
|
||||
/// 如果连接不存在则创建新连接。过期连接会被自动清理。
|
||||
pub async fn get_client(&self, addr: &str) -> Result<AgentServiceClient<Channel>> {
|
||||
// 先检查缓存,同时清理过期条目
|
||||
// 使用 remove_if_available 模式避免 TOCTOU 竞态
|
||||
let should_remove = {
|
||||
self.channels.get(addr).map(|entry| entry.is_expired()).unwrap_or(false)
|
||||
};
|
||||
|
||||
if should_remove {
|
||||
// 过期则移除
|
||||
self.channels.remove(addr);
|
||||
}
|
||||
|
||||
// 再次检查(可能被其他线程修改)
|
||||
if let Some(entry) = self.channels.get(addr) {
|
||||
debug!("📡 [gRPC] reuse connection: {}", addr);
|
||||
return Ok(create_configured_client(entry.channel.clone()));
|
||||
}
|
||||
|
||||
// 缓存未命中或已过期,创建新连接
|
||||
info!("🔌 [gRPC] creating connection: {}", addr);
|
||||
let endpoint = format!("http://{}", addr);
|
||||
let channel = Channel::from_shared(endpoint)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid URI: {}", e))?
|
||||
.connect_timeout(Duration::from_secs(
|
||||
shared_types::GRPC_CONNECT_TIMEOUT_SECS,
|
||||
))
|
||||
.timeout(Duration::from_secs(
|
||||
shared_types::GRPC_REQUEST_TIMEOUT_SECS,
|
||||
))
|
||||
// HTTP/2 Keepalive 配置
|
||||
.http2_keep_alive_interval(Duration::from_secs(30))
|
||||
.keep_alive_timeout(Duration::from_secs(10))
|
||||
.keep_alive_while_idle(true)
|
||||
// TCP Keepalive 配置
|
||||
.tcp_keepalive(Some(Duration::from_secs(60)))
|
||||
.tcp_nodelay(true)
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Connection failed: {}", e))?;
|
||||
|
||||
// 清理过期连接
|
||||
self.cleanup_expired();
|
||||
|
||||
// 插入缓存
|
||||
self.channels.insert(
|
||||
addr.to_string(),
|
||||
ChannelEntry {
|
||||
channel: channel.clone(),
|
||||
created_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
|
||||
debug!("📡 [gRPC] connection ready: {}", addr);
|
||||
Ok(create_configured_client(channel))
|
||||
}
|
||||
|
||||
/// 清理过期的连接
|
||||
///
|
||||
/// 每次调用时检查并清理过期连接,保持缓存高效。
|
||||
fn cleanup_expired(&self) {
|
||||
let len = self.channels.len();
|
||||
|
||||
// 收集所有过期连接的键
|
||||
let expired: Vec<String> = self
|
||||
.channels
|
||||
.iter()
|
||||
.filter(|e| e.is_expired())
|
||||
.map(|e| e.key().clone())
|
||||
.collect();
|
||||
|
||||
// 移除过期连接
|
||||
for key in &expired {
|
||||
self.channels.remove(key);
|
||||
}
|
||||
|
||||
if !expired.is_empty() {
|
||||
debug!(
|
||||
"🔌 [gRPC] cleaned up {} expired connections (cache size: {})",
|
||||
expired.len(),
|
||||
self.channels.len()
|
||||
);
|
||||
}
|
||||
|
||||
// 如果清理后仍然满(>= 80%),再清理一半的非过期连接
|
||||
if self.channels.len() >= MAX_CAPACITY * 8 / 10 {
|
||||
let to_remove: Vec<_> = self
|
||||
.channels
|
||||
.iter()
|
||||
.take(MAX_CAPACITY / 2)
|
||||
.map(|e| e.key().clone())
|
||||
.collect();
|
||||
|
||||
for key in &to_remove {
|
||||
self.channels.remove(key);
|
||||
}
|
||||
warn!(
|
||||
"🔌 [gRPC] cache still full after cleanup, evicted {} entries",
|
||||
to_remove.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取指定容器端口的 gRPC 客户端
|
||||
///
|
||||
/// 假设容器 IP 为 localhost,端口为 gRPC 端口(默认 50051)
|
||||
pub async fn get_client_for_container(
|
||||
&self,
|
||||
container_ip: &str,
|
||||
grpc_port: u16,
|
||||
) -> Result<AgentServiceClient<Channel>> {
|
||||
let addr = format!("{}:{}", container_ip, grpc_port);
|
||||
self.get_client(&addr).await
|
||||
}
|
||||
|
||||
/// 移除指定地址的连接
|
||||
pub fn remove(&self, addr: &str) {
|
||||
if self.channels.remove(addr).is_some() {
|
||||
info!("🔌 [gRPC] removed connection: {}", addr);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空所有连接
|
||||
pub fn clear(&self) {
|
||||
self.channels.clear();
|
||||
info!("🔌 [gRPC] cleared all connections");
|
||||
}
|
||||
|
||||
/// 获取当前连接数
|
||||
pub fn connection_count(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GrpcChannelPool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for GrpcChannelPool {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("GrpcChannelPool")
|
||||
.field("connection_count", &self.connection_count())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_pool() {
|
||||
let pool = GrpcChannelPool::new();
|
||||
assert_eq!(pool.connection_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_non_existent() {
|
||||
let pool = GrpcChannelPool::new();
|
||||
pool.remove("non_existent");
|
||||
assert_eq!(pool.connection_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clear() {
|
||||
let pool = GrpcChannelPool::new();
|
||||
pool.clear();
|
||||
assert_eq!(pool.connection_count(), 0);
|
||||
}
|
||||
}
|
||||
198
qiming-rcoder/crates/rcoder/src/grpc/chat_client.rs
Normal file
198
qiming-rcoder/crates/rcoder/src/grpc/chat_client.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
//! gRPC Chat 客户端
|
||||
//!
|
||||
//! 通过 gRPC 调用 agent_runner 的 Chat RPC
|
||||
|
||||
use crate::grpc::GrpcChannelPool;
|
||||
use shared_types::ChatAgentConfig;
|
||||
use shared_types::grpc::{
|
||||
CancelRequest, CancelResponse, ChatRequest as GrpcChatRequest, ChatResponse as GrpcChatResponse,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// 通过 gRPC 发送 Chat 请求到 agent_runner (使用连接池)
|
||||
pub async fn grpc_chat_with_pool(
|
||||
pool: &Arc<GrpcChannelPool>,
|
||||
grpc_addr: &str,
|
||||
project_id: String,
|
||||
session_id: Option<String>,
|
||||
prompt: String,
|
||||
attachments: Vec<shared_types::Attachment>,
|
||||
data_source_attachments: Vec<String>,
|
||||
model_config: Option<shared_types::ModelProviderConfig>,
|
||||
request_id: Option<String>,
|
||||
request_timeout: Option<std::time::Duration>,
|
||||
// 新增参数 (v2)
|
||||
system_prompt: Option<String>,
|
||||
user_prompt: Option<String>,
|
||||
agent_config: Option<ChatAgentConfig>,
|
||||
service_type: Option<shared_types::ServiceType>,
|
||||
user_id: Option<String>, // 新增:用于 ComputerAgentRunner 模式
|
||||
) -> anyhow::Result<GrpcChatResponse> {
|
||||
info!(
|
||||
"🚀 [gRPC_CHAT] Sending Chat request (connection pool): addr={}, project_id={}",
|
||||
grpc_addr, project_id
|
||||
);
|
||||
|
||||
// 使用连接池获取客户端
|
||||
let mut client = pool.get_client(grpc_addr).await?;
|
||||
|
||||
// 构建 gRPC 请求
|
||||
let grpc_request = GrpcChatRequest {
|
||||
project_id,
|
||||
session_id: session_id.unwrap_or_default(),
|
||||
prompt,
|
||||
model_config: model_config.map(super::converters::to_grpc_model_config),
|
||||
attachments: attachments
|
||||
.into_iter()
|
||||
.map(super::converters::to_grpc_attachment)
|
||||
.collect(),
|
||||
request_id,
|
||||
data_source_attachments,
|
||||
// 新增字段 (v2)
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
agent_config: agent_config.map(super::converters::to_grpc_chat_agent_config),
|
||||
service_type: service_type.map(|st| format!("{:?}", st)),
|
||||
user_id, // 传递 user_id
|
||||
};
|
||||
|
||||
debug!("[gRPC_CHAT] sendrequest: {:?}", grpc_request);
|
||||
|
||||
// 构建 tonic Request 并设置请求级别超时
|
||||
let locale = super::current_grpc_locale();
|
||||
let mut request = super::new_request_with_locale(grpc_request, locale);
|
||||
|
||||
// ✅ 使用 Tonic 原生 API 设置请求超时
|
||||
if let Some(timeout) = request_timeout {
|
||||
request.set_timeout(timeout);
|
||||
debug!("⏱️ [gRPC_CHAT] request timeout: {:?}", timeout);
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
let response = client.chat(request).await.map_err(|e| {
|
||||
error!("[gRPC_CHAT] Chat RPC call failed: {}", e);
|
||||
anyhow::anyhow!("gRPC Chat call failed: {}", e)
|
||||
})?;
|
||||
|
||||
let chat_response = response.into_inner();
|
||||
|
||||
info!(
|
||||
"✅ [gRPC_CHAT] Received response: project_id={}, session_id={}, success={}",
|
||||
chat_response.project_id, chat_response.session_id, chat_response.success
|
||||
);
|
||||
|
||||
Ok(chat_response)
|
||||
}
|
||||
|
||||
/// 将 gRPC ChatResponse 转换为内部 ChatResponse
|
||||
pub fn grpc_response_to_chat_response(grpc_resp: GrpcChatResponse) -> shared_types::ChatResponse {
|
||||
shared_types::ChatResponse {
|
||||
project_id: grpc_resp.project_id,
|
||||
session_id: grpc_resp.session_id,
|
||||
error: grpc_resp.error,
|
||||
request_id: grpc_resp.request_id,
|
||||
need_fallback: None,
|
||||
fallback_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 gRPC 取消会话(使用连接池)
|
||||
pub async fn grpc_cancel_session_with_pool(
|
||||
pool: &Arc<GrpcChannelPool>,
|
||||
grpc_addr: &str,
|
||||
session_id: String,
|
||||
reason: String,
|
||||
project_id: String,
|
||||
) -> anyhow::Result<CancelResponse> {
|
||||
info!(
|
||||
"🛑 [gRPC_CANCEL] Sending cancel session request (connection pool): addr={}, session_id={}, project_id={}",
|
||||
grpc_addr, session_id, project_id
|
||||
);
|
||||
|
||||
// 使用连接池获取客户端
|
||||
let mut client = pool.get_client(grpc_addr).await?;
|
||||
|
||||
// 构建 gRPC 请求
|
||||
let grpc_request = CancelRequest {
|
||||
session_id,
|
||||
reason,
|
||||
project_id,
|
||||
};
|
||||
|
||||
debug!("[gRPC_CANCEL] sendrequest: {:?}", grpc_request);
|
||||
|
||||
// 发送请求
|
||||
let locale = super::current_grpc_locale();
|
||||
let request = super::new_request_with_locale(grpc_request, locale);
|
||||
|
||||
let response = client.cancel_session(request).await.map_err(|e| {
|
||||
error!("[gRPC_CANCEL] CancelSession RPC call failed: {}", e);
|
||||
anyhow::anyhow!("gRPC CancelSession call failed: {}", e)
|
||||
})?;
|
||||
|
||||
let cancel_response = response.into_inner();
|
||||
|
||||
info!(
|
||||
"✅ [gRPC_CANCEL] Received response: success={}, message={:?}",
|
||||
cancel_response.success, cancel_response.message
|
||||
);
|
||||
|
||||
Ok(cancel_response)
|
||||
}
|
||||
|
||||
/// 通过 gRPC 取消会话(不使用连接池,兼容旧接口)
|
||||
pub async fn grpc_cancel_session(
|
||||
grpc_addr: &str,
|
||||
session_id: String,
|
||||
reason: String,
|
||||
project_id: String,
|
||||
) -> anyhow::Result<CancelResponse> {
|
||||
// 创建临时连接池(单次使用)
|
||||
let pool = Arc::new(GrpcChannelPool::new());
|
||||
grpc_cancel_session_with_pool(&pool, grpc_addr, session_id, reason, project_id).await
|
||||
}
|
||||
|
||||
/// 通过 gRPC 停止 Agent(使用连接池)
|
||||
pub async fn grpc_stop_agent_with_pool(
|
||||
pool: &Arc<GrpcChannelPool>,
|
||||
grpc_addr: &str,
|
||||
project_id: String,
|
||||
reason: Option<String>,
|
||||
force: bool,
|
||||
) -> anyhow::Result<shared_types::grpc::StopAgentResponse> {
|
||||
info!(
|
||||
"🔄 [gRPC_STOP_AGENT] Sending stop Agent request (connection pool): addr={}, project_id={}, force={}",
|
||||
grpc_addr, project_id, force
|
||||
);
|
||||
|
||||
// 使用连接池获取客户端
|
||||
let mut client = pool.get_client(grpc_addr).await?;
|
||||
|
||||
// 构建 gRPC 请求
|
||||
let grpc_request = shared_types::grpc::StopAgentRequest {
|
||||
project_id: project_id.clone(),
|
||||
reason,
|
||||
force,
|
||||
};
|
||||
|
||||
debug!("[gRPC_STOP_AGENT] sendrequest: {:?}", grpc_request);
|
||||
|
||||
// 发送请求
|
||||
let locale = super::current_grpc_locale();
|
||||
let request = super::new_request_with_locale(grpc_request, locale);
|
||||
|
||||
let response = client.stop_agent(request).await.map_err(|e| {
|
||||
error!("[gRPC_STOP_AGENT] StopAgent RPC call failed: {}", e);
|
||||
anyhow::anyhow!("gRPC StopAgent call failed: {}", e)
|
||||
})?;
|
||||
|
||||
let stop_response = response.into_inner();
|
||||
|
||||
info!(
|
||||
"✅ [gRPC_STOP_AGENT] Received response: result={}, success={}, message={:?}",
|
||||
stop_response.result, stop_response.success, stop_response.message
|
||||
);
|
||||
|
||||
Ok(stop_response)
|
||||
}
|
||||
231
qiming-rcoder/crates/rcoder/src/grpc/converters.rs
Normal file
231
qiming-rcoder/crates/rcoder/src/grpc/converters.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! 类型转换器
|
||||
//!
|
||||
//! 在 Rust 内部类型和 gRPC Protobuf 类型之间进行转换
|
||||
|
||||
use shared_types::grpc::{
|
||||
Attachment as GrpcAttachment, AttachmentSource as GrpcAttachmentSource,
|
||||
AudioAttachment as GrpcAudioAttachment, Base64Data, ChatAgentConfig as GrpcChatAgentConfig,
|
||||
ChatAgentServerConfig as GrpcChatAgentServerConfig,
|
||||
ChatContextServerConfig as GrpcChatContextServerConfig, ChatRequest as GrpcChatRequest,
|
||||
ChatResponse as GrpcChatResponse, DocumentAttachment as GrpcDocumentAttachment,
|
||||
ImageAttachment as GrpcImageAttachment, ImageDimensions as GrpcImageDimensions,
|
||||
ModelProviderConfig as GrpcModelProviderConfig, ProgressEvent,
|
||||
TextAttachment as GrpcTextAttachment, attachment, attachment_source,
|
||||
};
|
||||
use shared_types::{Attachment, AttachmentSource, ModelProviderConfig, UnifiedSessionMessage};
|
||||
use shared_types::{ChatAgentConfig, ChatAgentServerConfig, ChatContextServerConfig};
|
||||
|
||||
/// 将内部 ChatRequest 转换为 gRPC ChatRequest
|
||||
///
|
||||
/// 注意:此函数目前未被使用,chat_client.rs 直接构建 GrpcChatRequest。
|
||||
/// 保留以备将来使用或重构。
|
||||
#[allow(dead_code)]
|
||||
pub fn to_grpc_chat_request(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
prompt: String,
|
||||
attachments: Vec<Attachment>,
|
||||
data_source_attachments: Vec<String>,
|
||||
model_config: Option<ModelProviderConfig>,
|
||||
request_id: Option<String>,
|
||||
// 新增参数 (v2)
|
||||
system_prompt: Option<String>,
|
||||
user_prompt: Option<String>,
|
||||
agent_config: Option<ChatAgentConfig>,
|
||||
service_type: Option<shared_types::ServiceType>,
|
||||
user_id: Option<String>, // 新增:用于 ComputerAgentRunner 模式
|
||||
) -> GrpcChatRequest {
|
||||
GrpcChatRequest {
|
||||
project_id,
|
||||
session_id,
|
||||
prompt,
|
||||
model_config: model_config.map(to_grpc_model_config),
|
||||
attachments: attachments.into_iter().map(to_grpc_attachment).collect(),
|
||||
request_id,
|
||||
data_source_attachments,
|
||||
// 新增字段 (v2)
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
agent_config: agent_config.map(to_grpc_chat_agent_config),
|
||||
service_type: service_type.map(|st| format!("{:?}", st)),
|
||||
user_id, // 传递 user_id
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 ModelProviderConfig 转换为 gRPC 格式
|
||||
pub fn to_grpc_model_config(config: ModelProviderConfig) -> GrpcModelProviderConfig {
|
||||
GrpcModelProviderConfig {
|
||||
id: config.id, // 保留原始 ID,用于会话复用判断
|
||||
provider: config.name,
|
||||
model: config.default_model,
|
||||
api_key: Some(config.api_key),
|
||||
api_base: Some(config.base_url),
|
||||
requires_openai_auth: Some(config.requires_openai_auth),
|
||||
api_protocol: config.api_protocol,
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 AttachmentSource 转换为 gRPC 格式
|
||||
///
|
||||
/// 辅助函数,将 Rust 侧的 AttachmentSource 枚举转换为 gRPC 的 AttachmentSource
|
||||
fn to_grpc_attachment_source(source: AttachmentSource) -> Option<GrpcAttachmentSource> {
|
||||
let grpc_source = match source {
|
||||
AttachmentSource::FilePath { path } => attachment_source::Source::FilePath(path),
|
||||
AttachmentSource::Base64 { data, mime_type } => {
|
||||
attachment_source::Source::Base64(Base64Data { data, mime_type })
|
||||
}
|
||||
AttachmentSource::Url { url } => attachment_source::Source::Url(url),
|
||||
};
|
||||
|
||||
Some(GrpcAttachmentSource {
|
||||
source: Some(grpc_source),
|
||||
})
|
||||
}
|
||||
|
||||
/// 将 Attachment 转换为 gRPC 格式
|
||||
///
|
||||
/// 完整实现:根据 Attachment 类型正确填充 gRPC 的 oneof 字段
|
||||
pub fn to_grpc_attachment(attachment: Attachment) -> GrpcAttachment {
|
||||
let attachment_type = match attachment {
|
||||
Attachment::Text(text) => attachment::AttachmentType::Text(GrpcTextAttachment {
|
||||
id: text.id,
|
||||
source: to_grpc_attachment_source(text.source),
|
||||
filename: text.filename,
|
||||
description: text.description,
|
||||
}),
|
||||
Attachment::Image(image) => attachment::AttachmentType::Image(GrpcImageAttachment {
|
||||
id: image.id,
|
||||
source: to_grpc_attachment_source(image.source),
|
||||
mime_type: image.mime_type,
|
||||
filename: image.filename,
|
||||
description: image.description,
|
||||
dimensions: image.dimensions.map(|d| GrpcImageDimensions {
|
||||
width: d.width,
|
||||
height: d.height,
|
||||
}),
|
||||
}),
|
||||
Attachment::Audio(audio) => attachment::AttachmentType::Audio(GrpcAudioAttachment {
|
||||
id: audio.id,
|
||||
source: to_grpc_attachment_source(audio.source),
|
||||
mime_type: audio.mime_type,
|
||||
filename: audio.filename,
|
||||
description: audio.description,
|
||||
duration: audio.duration,
|
||||
}),
|
||||
Attachment::Document(doc) => attachment::AttachmentType::Document(GrpcDocumentAttachment {
|
||||
id: doc.id,
|
||||
source: to_grpc_attachment_source(doc.source),
|
||||
mime_type: doc.mime_type,
|
||||
filename: doc.filename,
|
||||
description: doc.description,
|
||||
size: doc.size,
|
||||
}),
|
||||
};
|
||||
|
||||
GrpcAttachment {
|
||||
attachment_type: Some(attachment_type),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 gRPC ChatResponse 提取结果
|
||||
pub fn from_grpc_chat_response(resp: GrpcChatResponse) -> (String, String, bool, Option<String>) {
|
||||
(resp.project_id, resp.session_id, resp.success, resp.error)
|
||||
}
|
||||
|
||||
/// 从 gRPC ProgressEvent 转换为 UnifiedSessionMessage
|
||||
///
|
||||
/// 简化版:直接使用透传的字段
|
||||
pub fn from_grpc_progress_event(
|
||||
event: ProgressEvent,
|
||||
session_id: &str,
|
||||
) -> Option<UnifiedSessionMessage> {
|
||||
use chrono::Utc;
|
||||
use shared_types::SessionMessageType;
|
||||
use tracing::warn;
|
||||
|
||||
let timestamp = match chrono::DateTime::from_timestamp_millis(event.timestamp) {
|
||||
Some(ts) => ts,
|
||||
None => {
|
||||
warn!(
|
||||
"⚠️ [CONVERTER] Invalid timestamp: session_id={}, timestamp={}, using current time",
|
||||
session_id, event.timestamp
|
||||
);
|
||||
Utc::now()
|
||||
}
|
||||
};
|
||||
|
||||
// 从 message_type 字符串解析枚举
|
||||
let message_type = match event.message_type.as_str() {
|
||||
"SessionPromptStart" => SessionMessageType::SessionPromptStart,
|
||||
"SessionPromptEnd" => SessionMessageType::SessionPromptEnd,
|
||||
"AgentSessionUpdate" => SessionMessageType::AgentSessionUpdate,
|
||||
"Heartbeat" => SessionMessageType::Heartbeat,
|
||||
_ => SessionMessageType::AgentSessionUpdate, // 默认为 AgentSessionUpdate
|
||||
};
|
||||
|
||||
// 解析 payload JSON
|
||||
let data = match serde_json::from_str(&event.payload) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [CONVERTER] Failed to parse gRPC payload: session_id={}, payload_preview={}, error={}",
|
||||
session_id,
|
||||
event.payload.chars().take(100).collect::<String>(),
|
||||
e
|
||||
);
|
||||
// 返回包含原始 payload 的错误对象
|
||||
serde_json::json!({
|
||||
"_parse_error": e.to_string(),
|
||||
"_original_payload": event.payload
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Some(UnifiedSessionMessage {
|
||||
session_id: session_id.to_string(),
|
||||
message_type,
|
||||
sub_type: event.sub_type,
|
||||
data,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
// === ChatAgentConfig 类型转换 (v2) ===
|
||||
|
||||
/// 将 ChatAgentConfig 转换为 gRPC 格式
|
||||
pub fn to_grpc_chat_agent_config(config: ChatAgentConfig) -> GrpcChatAgentConfig {
|
||||
GrpcChatAgentConfig {
|
||||
agent_server: config.agent_server.map(to_grpc_chat_agent_server_config),
|
||||
context_servers: config
|
||||
.context_servers
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, to_grpc_chat_context_server_config(v)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 ChatAgentServerConfig 转换为 gRPC 格式
|
||||
pub fn to_grpc_chat_agent_server_config(
|
||||
config: ChatAgentServerConfig,
|
||||
) -> GrpcChatAgentServerConfig {
|
||||
GrpcChatAgentServerConfig {
|
||||
agent_id: config.agent_id,
|
||||
command: config.command,
|
||||
args: config.args.unwrap_or_default(),
|
||||
env: config.env.unwrap_or_default(),
|
||||
metadata: config.metadata.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 ChatContextServerConfig 转换为 gRPC 格式
|
||||
pub fn to_grpc_chat_context_server_config(
|
||||
config: ChatContextServerConfig,
|
||||
) -> GrpcChatContextServerConfig {
|
||||
GrpcChatContextServerConfig {
|
||||
source: config.source,
|
||||
enabled: config.enabled,
|
||||
command: config.command,
|
||||
args: config.args.unwrap_or_default(),
|
||||
env: config.env.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
209
qiming-rcoder/crates/rcoder/src/grpc/error.rs
Normal file
209
qiming-rcoder/crates/rcoder/src/grpc/error.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
//! gRPC 错误分类和处理
|
||||
//!
|
||||
//! 基于 Tonic 的 Status Code 进行智能错误分类,优化重试策略
|
||||
|
||||
use tonic::{Code, Status};
|
||||
|
||||
/// gRPC 错误分类
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum GrpcErrorCategory {
|
||||
/// 可重试错误(网络问题、资源不足等临时性错误)
|
||||
Retryable,
|
||||
/// 不可重试错误(参数错误、权限问题等客户端错误)
|
||||
NonRetryable,
|
||||
/// 永久性错误(未找到、未实现等服务端永久性问题)
|
||||
Permanent,
|
||||
}
|
||||
|
||||
/// 基于 Tonic Status Code 分类 gRPC 错误
|
||||
///
|
||||
/// 根据 gRPC 标准错误码判断错误是否应该重试
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `status` - gRPC Status 对象
|
||||
///
|
||||
/// # Returns
|
||||
/// 错误分类结果
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use tonic::{Code, Status};
|
||||
/// use rcoder::grpc::GrpcErrorCategory;
|
||||
///
|
||||
/// let status = Status::unavailable("服务不可用");
|
||||
/// let category = rcoder::grpc::categorize_grpc_error(&status);
|
||||
/// assert_eq!(category, GrpcErrorCategory::Retryable);
|
||||
/// ```
|
||||
pub fn categorize_grpc_error(status: &Status) -> GrpcErrorCategory {
|
||||
match status.code() {
|
||||
// ✅ 可重试错误:网络问题、资源不足、瞬时故障
|
||||
Code::Unavailable | // 服务不可用(最常见的网络问题)
|
||||
Code::DeadlineExceeded | // 超时(可能是临时性网络延迟)
|
||||
Code::ResourceExhausted | // 资源耗尽(服务器过载,可能恢复)
|
||||
Code::Aborted | // 操作被中止(可能是并发冲突,重试可能成功)
|
||||
Code::Internal | // 内部错误(可能是临时性服务器问题)
|
||||
Code::Unknown => // 未知错误(保守策略:允许重试)
|
||||
GrpcErrorCategory::Retryable,
|
||||
|
||||
// ❌ 永久性错误:服务端不支持或资源不存在
|
||||
Code::NotFound | // 未找到资源
|
||||
Code::Unimplemented | // 方法未实现
|
||||
Code::OutOfRange => // 超出范围(通常是客户端逻辑错误)
|
||||
GrpcErrorCategory::Permanent,
|
||||
|
||||
// ❌ 不可重试错误:客户端问题,重试也不会成功
|
||||
Code::InvalidArgument | // 参数错误
|
||||
Code::Unauthenticated | // 未认证
|
||||
Code::PermissionDenied | // 权限不足
|
||||
Code::FailedPrecondition | // 前置条件失败
|
||||
Code::AlreadyExists | // 资源已存在
|
||||
Code::Cancelled => // 用户取消(不应重试)
|
||||
GrpcErrorCategory::NonRetryable,
|
||||
|
||||
// ✅ OK - 理论上不应该走到这里
|
||||
Code::Ok => GrpcErrorCategory::NonRetryable,
|
||||
|
||||
// ⚠️ DataLoss - 严重错误,但可能是临时性的
|
||||
Code::DataLoss => GrpcErrorCategory::Retryable,
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断 gRPC 错误是否应该重试
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `status` - gRPC Status 对象
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` 如果错误可以重试,`false` 否则
|
||||
pub fn should_retry_grpc_error(status: &Status) -> bool {
|
||||
matches!(categorize_grpc_error(status), GrpcErrorCategory::Retryable)
|
||||
}
|
||||
|
||||
/// 从 anyhow::Error 中提取 Tonic Status(如果存在)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `error` - anyhow Error 对象
|
||||
///
|
||||
/// # Returns
|
||||
/// `Some(Status)` 如果错误包含 Tonic Status,`None` 否则
|
||||
pub fn extract_grpc_status(error: &anyhow::Error) -> Option<&Status> {
|
||||
error.downcast_ref::<Status>()
|
||||
}
|
||||
|
||||
/// 判断 anyhow::Error 是否应该重试(自动提取 Tonic Status)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `error` - anyhow Error 对象
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` 如果错误包含可重试的 gRPC 错误,`false` 否则
|
||||
pub fn should_retry_error(error: &anyhow::Error) -> bool {
|
||||
if let Some(status) = extract_grpc_status(error) {
|
||||
should_retry_grpc_error(status)
|
||||
} else {
|
||||
// 非 gRPC 错误,保守策略:不重试
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取错误的友好描述
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `status` - gRPC Status 对象
|
||||
///
|
||||
/// # Returns
|
||||
/// 错误的中文描述
|
||||
pub fn get_error_description(status: &Status) -> &'static str {
|
||||
match status.code() {
|
||||
Code::Ok => "Success",
|
||||
Code::Cancelled => "Operation cancelled",
|
||||
Code::Unknown => "Unknown error",
|
||||
Code::InvalidArgument => "Invalid argument",
|
||||
Code::DeadlineExceeded => "Request timeout",
|
||||
Code::NotFound => "Resource not found",
|
||||
Code::AlreadyExists => "Resource already exists",
|
||||
Code::PermissionDenied => "Permission denied",
|
||||
Code::ResourceExhausted => "Resource exhausted",
|
||||
Code::FailedPrecondition => "Precondition failed",
|
||||
Code::Aborted => "Operation aborted",
|
||||
Code::OutOfRange => "Out of range",
|
||||
Code::Unimplemented => "Method not implemented",
|
||||
Code::Internal => "Internal server error",
|
||||
Code::Unavailable => "Service unavailable",
|
||||
Code::DataLoss => "Data loss",
|
||||
Code::Unauthenticated => "Unauthenticated",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_categorize_retryable_errors() {
|
||||
let retryable_codes = vec![
|
||||
Code::Unavailable,
|
||||
Code::DeadlineExceeded,
|
||||
Code::ResourceExhausted,
|
||||
Code::Aborted,
|
||||
Code::Internal,
|
||||
Code::Unknown,
|
||||
];
|
||||
|
||||
for code in retryable_codes {
|
||||
let status = Status::new(code, "test error");
|
||||
assert_eq!(
|
||||
categorize_grpc_error(&status),
|
||||
GrpcErrorCategory::Retryable,
|
||||
"Code {:?} should be retryable",
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_non_retryable_errors() {
|
||||
let non_retryable_codes = vec![
|
||||
Code::InvalidArgument,
|
||||
Code::Unauthenticated,
|
||||
Code::PermissionDenied,
|
||||
Code::FailedPrecondition,
|
||||
Code::AlreadyExists,
|
||||
Code::Cancelled,
|
||||
];
|
||||
|
||||
for code in non_retryable_codes {
|
||||
let status = Status::new(code, "test error");
|
||||
assert_eq!(
|
||||
categorize_grpc_error(&status),
|
||||
GrpcErrorCategory::NonRetryable,
|
||||
"Code {:?} should not be retryable",
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_permanent_errors() {
|
||||
let permanent_codes = vec![Code::NotFound, Code::Unimplemented, Code::OutOfRange];
|
||||
|
||||
for code in permanent_codes {
|
||||
let status = Status::new(code, "test error");
|
||||
assert_eq!(
|
||||
categorize_grpc_error(&status),
|
||||
GrpcErrorCategory::Permanent,
|
||||
"Code {:?} should be permanent",
|
||||
code
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_retry_grpc_error() {
|
||||
let retryable = Status::unavailable("service unavailable");
|
||||
assert!(should_retry_grpc_error(&retryable));
|
||||
|
||||
let non_retryable = Status::invalid_argument("bad request");
|
||||
assert!(!should_retry_grpc_error(&non_retryable));
|
||||
}
|
||||
}
|
||||
54
qiming-rcoder/crates/rcoder/src/grpc/locale_metadata.rs
Normal file
54
qiming-rcoder/crates/rcoder/src/grpc/locale_metadata.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use tonic::metadata::MetadataValue;
|
||||
|
||||
const ACCEPT_LANGUAGE_METADATA_KEY: &str = "accept-language";
|
||||
|
||||
/// Get locale from current HTTP request context (task-local).
|
||||
pub fn current_grpc_locale() -> &'static str {
|
||||
shared_types::current_request_locale()
|
||||
}
|
||||
|
||||
/// Inject `accept-language` metadata into a gRPC request.
|
||||
pub fn inject_accept_language_metadata<T>(request: &mut tonic::Request<T>, locale: &'static str) {
|
||||
match MetadataValue::try_from(locale) {
|
||||
Ok(value) => {
|
||||
request
|
||||
.metadata_mut()
|
||||
.insert(ACCEPT_LANGUAGE_METADATA_KEY, value);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"[GRPC_LOCALE] Invalid accept-language metadata value: locale={}, error={}",
|
||||
locale,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a gRPC request with `accept-language` metadata.
|
||||
pub fn new_request_with_locale<T>(message: T, locale: &'static str) -> tonic::Request<T> {
|
||||
let mut request = tonic::Request::new(message);
|
||||
inject_accept_language_metadata(&mut request, locale);
|
||||
request
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{current_grpc_locale, inject_accept_language_metadata};
|
||||
|
||||
#[test]
|
||||
fn test_inject_accept_language_metadata() {
|
||||
let mut request = tonic::Request::new(());
|
||||
inject_accept_language_metadata(&mut request, "zh-CN");
|
||||
let value = request
|
||||
.metadata()
|
||||
.get("accept-language")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
assert_eq!(value, Some("zh-CN"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_current_grpc_locale_fallback_default() {
|
||||
assert_eq!(current_grpc_locale(), "en-US");
|
||||
}
|
||||
}
|
||||
17
qiming-rcoder/crates/rcoder/src/grpc/mod.rs
Normal file
17
qiming-rcoder/crates/rcoder/src/grpc/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! gRPC 客户端模块
|
||||
//!
|
||||
//! 提供 rcoder 与 agent_runner 之间的 gRPC 通信客户端实现
|
||||
|
||||
pub mod channel_pool;
|
||||
pub mod chat_client;
|
||||
pub mod converters;
|
||||
pub mod error;
|
||||
pub mod locale_metadata;
|
||||
pub mod sse_stream;
|
||||
|
||||
pub use channel_pool::GrpcChannelPool;
|
||||
pub use chat_client::*;
|
||||
pub use converters::*;
|
||||
pub use error::*;
|
||||
pub use locale_metadata::*;
|
||||
pub use sse_stream::*;
|
||||
441
qiming-rcoder/crates/rcoder/src/grpc/sse_stream.rs
Normal file
441
qiming-rcoder/crates/rcoder/src/grpc/sse_stream.rs
Normal file
@@ -0,0 +1,441 @@
|
||||
//! gRPC SSE 流处理器
|
||||
//!
|
||||
//! 通过 gRPC SubscribeProgress 接收 agent_runner 的进度事件,
|
||||
//! 并转换为 SSE 事件返回给客户端
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use shared_types::grpc::{GetStatusRequest, ProgressRequest};
|
||||
use shared_types::{SessionMessageType, UnifiedSessionMessage};
|
||||
use tonic::Code;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// 创建基于 gRPC 的 SSE 代理流
|
||||
///
|
||||
/// 通过 gRPC `SubscribeProgress` 方法订阅 agent_runner 的进度事件,
|
||||
/// 并将事件转换为 SSE 格式返回
|
||||
///
|
||||
/// 🚀 优化:使用连接池 + 智能重试机制
|
||||
/// 🆕 新增:在建立流之前检查 Agent 状态,如果 Agent 闲置则直接发送 SessionPromptEnd 并关闭
|
||||
pub async fn create_grpc_sse_stream(
|
||||
grpc_addr: String,
|
||||
session_id: String,
|
||||
project_id: String,
|
||||
pool: std::sync::Arc<crate::grpc::GrpcChannelPool>,
|
||||
locale: &'static str,
|
||||
) -> impl futures_util::Stream<Item = Result<axum::response::sse::Event, std::convert::Infallible>>
|
||||
{
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(100);
|
||||
let session_id_clone = session_id.clone();
|
||||
|
||||
// 在后台任务中处理 gRPC 流
|
||||
tokio::spawn(async move {
|
||||
info!(
|
||||
"🔗 [gRPC_SSE] Starting connection to agent_runner gRPC: addr={}, session_id={}, project_id={}",
|
||||
grpc_addr, session_id_clone, project_id
|
||||
);
|
||||
|
||||
let max_retries = 2;
|
||||
let mut last_error_msg = String::new();
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
// 1. 从连接池获取客户端
|
||||
let mut client = match pool.get_client(&grpc_addr).await {
|
||||
Ok(client) => client,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Failed to get client (attempt {}/{}): {}, cleaning connection pool and retrying...",
|
||||
attempt, max_retries, e
|
||||
);
|
||||
pool.remove(&grpc_addr);
|
||||
last_error_msg = format!("failed to get client: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 2. 先检查 Agent 状态(使用 session_id 查询)
|
||||
let status_request = crate::grpc::new_request_with_locale(
|
||||
GetStatusRequest {
|
||||
project_id: String::new(), // 不使用 project_id
|
||||
session_id: session_id_clone.clone(), // 使用 session_id 查询
|
||||
},
|
||||
locale,
|
||||
);
|
||||
|
||||
match client.get_status(status_request).await {
|
||||
Ok(response) => {
|
||||
let status = response.into_inner().status;
|
||||
if status == "idle" {
|
||||
// Agent 闲置,发送 SessionPromptEnd 并关闭连接
|
||||
info!(
|
||||
"💤 [gRPC_SSE] Agent is idle, sending SessionPromptEnd and closing: session_id={}",
|
||||
session_id_clone
|
||||
);
|
||||
let end_event = create_session_prompt_end_event(&session_id_clone);
|
||||
if let Err(e) = tx.send(Ok(end_event)).await {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Failed to send SessionPromptEnd event: session_id={}, error={}",
|
||||
session_id_clone, e
|
||||
);
|
||||
}
|
||||
return; // 直接结束,不建立流
|
||||
}
|
||||
info!(
|
||||
"🔄 [gRPC_SSE] Agent status is {}, continuing to establish stream: session_id={}",
|
||||
status, session_id_clone
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// 状态检查失败,记录警告但继续尝试建立流
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Agent status check failed: {}, continuing to try establishing stream: session_id={}",
|
||||
e, session_id_clone
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 发送 SubscribeProgress 请求
|
||||
let request = crate::grpc::new_request_with_locale(
|
||||
ProgressRequest {
|
||||
session_id: session_id_clone.clone(),
|
||||
},
|
||||
locale,
|
||||
);
|
||||
|
||||
match client.subscribe_progress(request).await {
|
||||
Ok(response) => {
|
||||
info!(
|
||||
"✅ [gRPC_SSE] Successfully established SubscribeProgress stream: session_id={}",
|
||||
session_id_clone
|
||||
);
|
||||
|
||||
let mut stream = response.into_inner();
|
||||
|
||||
// 持续接收 gRPC 流中的事件
|
||||
loop {
|
||||
match stream.message().await {
|
||||
Ok(Some(progress_event)) => {
|
||||
debug!(
|
||||
"📨 [gRPC_SSE] Received progress event: session_id={}, message_type={}, sub_type={}",
|
||||
session_id_clone,
|
||||
progress_event.message_type,
|
||||
progress_event.sub_type
|
||||
);
|
||||
|
||||
// 将 ProgressEvent 转换为 SSE Event(传入 session_id 以重建完整消息结构)
|
||||
let sse_event =
|
||||
progress_event_to_sse(&progress_event, &session_id_clone);
|
||||
|
||||
if tx.send(Ok(sse_event)).await.is_err() {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Client disconnected: session_id={}",
|
||||
session_id_clone
|
||||
);
|
||||
// 客户端断开,直接退出任务
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
// 流正常结束(agent_runner 主动关闭)
|
||||
info!(
|
||||
"✅ [gRPC_SSE] gRPC stream ended normally: session_id={}",
|
||||
session_id_clone
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
// 流异常结束(连接中断、超时等)
|
||||
error!(
|
||||
"❌ [gRPC_SSE] gRPC stream error: session_id={}, code={}, message={}",
|
||||
session_id_clone,
|
||||
e.code(),
|
||||
e.message()
|
||||
);
|
||||
|
||||
// 发送标准格式的错误消息
|
||||
let error_event = create_grpc_stream_error_event(
|
||||
&session_id_clone,
|
||||
e.code(),
|
||||
e.message(),
|
||||
);
|
||||
if let Err(e) = tx.send(Ok(error_event)).await {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Failed to send error event: session_id={}, error={}",
|
||||
session_id_clone, e
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] SubscribeProgress call failed (attempt {}/{}): {}",
|
||||
attempt, max_retries, e
|
||||
);
|
||||
|
||||
// 如果不是最后一次尝试,清理连接池并重试
|
||||
if attempt < max_retries {
|
||||
info!(
|
||||
"🔌 [gRPC_SSE] Possibly connection broken, removing {} from connection pool and retrying...",
|
||||
grpc_addr
|
||||
);
|
||||
pool.remove(&grpc_addr);
|
||||
last_error_msg = format!("stream subscription failed: {}", e);
|
||||
continue;
|
||||
}
|
||||
|
||||
last_error_msg = format!("stream subscription ultimately failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果循环结束还没有 return,说明所有重试都失败了
|
||||
error!(
|
||||
"❌ [gRPC_SSE] Retried {} times ultimately failed: session_id={}, error={}",
|
||||
max_retries, session_id_clone, last_error_msg
|
||||
);
|
||||
|
||||
let error_event = create_connection_error_event(&session_id_clone, &last_error_msg);
|
||||
if let Err(e) = tx.send(Ok(error_event)).await {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Failed to send error event: session_id={}, error={}",
|
||||
session_id_clone, e
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
tokio_stream::wrappers::ReceiverStream::new(rx)
|
||||
}
|
||||
|
||||
/// 创建 Agent 闲置时的 SessionPromptEnd SSE 事件
|
||||
///
|
||||
/// 当 Agent 处于闲置状态时,发送此事件通知前端没有正在执行的任务
|
||||
fn create_session_prompt_end_event(session_id: &str) -> axum::response::sse::Event {
|
||||
let unified_message = UnifiedSessionMessage {
|
||||
session_id: session_id.to_string(),
|
||||
message_type: SessionMessageType::SessionPromptEnd,
|
||||
sub_type: "end_turn".to_string(),
|
||||
data: serde_json::json!({
|
||||
"reason": "EndTurn",
|
||||
"description": "Agent has no task in execution"
|
||||
}),
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
|
||||
let json_data = match serde_json::to_string(&unified_message) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Failed to serialize SessionPromptEnd message: {}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
// 返回包含 session_id 的最小可用结构
|
||||
format!(
|
||||
r#"{{"session_id":"{}","message_type":"SessionPromptEnd","sub_type":"end_turn","data":null}}"#,
|
||||
session_id
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
axum::response::sse::Event::default()
|
||||
.event("prompt_end")
|
||||
.data(json_data)
|
||||
}
|
||||
|
||||
/// 将 gRPC ProgressEvent 转换为 SSE Event
|
||||
///
|
||||
/// 使用 UnifiedSessionMessage 结构体重建完整消息,包含 sessionId、messageType、subType、data、timestamp
|
||||
/// 使用 sub_type 作为 SSE 事件名,前端通过 eventSource.addEventListener(sub_type, ...) 监听
|
||||
fn progress_event_to_sse(
|
||||
event: &shared_types::grpc::ProgressEvent,
|
||||
session_id: &str,
|
||||
) -> axum::response::sse::Event {
|
||||
// 解析 payload 为 data 字段
|
||||
let data: serde_json::Value =
|
||||
serde_json::from_str(&event.payload).unwrap_or(serde_json::Value::Null);
|
||||
|
||||
// 将 gRPC 时间戳(毫秒)转换为 DateTime<Utc>
|
||||
let timestamp = match DateTime::<Utc>::from_timestamp_millis(event.timestamp) {
|
||||
Some(ts) => ts,
|
||||
None => {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Invalid timestamp: session_id={}, timestamp={}, using current time",
|
||||
session_id, event.timestamp
|
||||
);
|
||||
Utc::now()
|
||||
}
|
||||
};
|
||||
|
||||
// 将 message_type 字符串转换为 SessionMessageType 枚举
|
||||
let message_type = parse_message_type(&event.message_type);
|
||||
|
||||
// 使用 UnifiedSessionMessage 结构体构建完整消息
|
||||
let unified_message = UnifiedSessionMessage {
|
||||
session_id: session_id.to_string(),
|
||||
message_type,
|
||||
sub_type: event.sub_type.clone(),
|
||||
data,
|
||||
timestamp,
|
||||
};
|
||||
|
||||
// 序列化为 JSON
|
||||
let json_data = match serde_json::to_string(&unified_message) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Failed to serialize ProgressEvent message: session_id={}, message_type={}, error={}",
|
||||
session_id, event.message_type, e
|
||||
);
|
||||
// 返回包含 session_id 的最小可用结构
|
||||
format!(
|
||||
r#"{{"session_id":"{}","message_type":"Unknown","sub_type":"{}","data":null}}"#,
|
||||
session_id, event.sub_type
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 sub_type 作为 SSE 事件名
|
||||
// 前端通过 eventSource.addEventListener('agent_message_chunk', ...) 等方式监听
|
||||
axum::response::sse::Event::default()
|
||||
.event(&event.sub_type)
|
||||
.data(json_data)
|
||||
}
|
||||
|
||||
/// 将 message_type 字符串解析为 SessionMessageType 枚举
|
||||
///
|
||||
/// 支持的格式:
|
||||
/// - "SessionPromptStart" -> SessionMessageType::SessionPromptStart
|
||||
/// - "SessionPromptEnd" -> SessionMessageType::SessionPromptEnd
|
||||
/// - "AgentSessionUpdate" -> SessionMessageType::AgentSessionUpdate
|
||||
/// - "Heartbeat" -> SessionMessageType::Heartbeat
|
||||
fn parse_message_type(message_type: &str) -> SessionMessageType {
|
||||
match message_type {
|
||||
"SessionPromptStart" => SessionMessageType::SessionPromptStart,
|
||||
"SessionPromptEnd" => SessionMessageType::SessionPromptEnd,
|
||||
"AgentSessionUpdate" => SessionMessageType::AgentSessionUpdate,
|
||||
"Heartbeat" => SessionMessageType::Heartbeat,
|
||||
// 默认作为 AgentSessionUpdate 处理
|
||||
_ => {
|
||||
debug!(
|
||||
"⚠️ [gRPC_SSE] Unknown message_type: {}, using AgentSessionUpdate as default",
|
||||
message_type
|
||||
);
|
||||
SessionMessageType::AgentSessionUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取容器的 gRPC 地址
|
||||
///
|
||||
/// 返回格式: `{container_ip}:{grpc_port}`
|
||||
/// 默认 gRPC 端口为 50051
|
||||
pub async fn get_container_grpc_addr(project_id: &str, grpc_port: u16) -> anyhow::Result<String> {
|
||||
info!(
|
||||
"🔍 [CONTAINER] Getting container gRPC address: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get runtime: {}", e))?;
|
||||
|
||||
let agent_info = runtime
|
||||
.get_container_info_by_identifier(project_id, &shared_types::ServiceType::RCoder)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get container info: {}", e))?
|
||||
.ok_or_else(|| anyhow::anyhow!("Container info not found: project_id={}", project_id))?;
|
||||
|
||||
let grpc_addr = format!("{}:{}", agent_info.container_ip, grpc_port);
|
||||
|
||||
info!("[CONTAINER] get container gRPC addr: {}", grpc_addr);
|
||||
Ok(grpc_addr)
|
||||
}
|
||||
|
||||
/// 创建 gRPC 流异常错误事件
|
||||
///
|
||||
/// 当 gRPC 流在传输过程中异常结束时发送此事件
|
||||
fn create_grpc_stream_error_event(
|
||||
session_id: &str,
|
||||
code: Code,
|
||||
message: &str,
|
||||
) -> axum::response::sse::Event {
|
||||
// 使用项目标准的错误码映射
|
||||
let error_code = map_tonic_code_to_error_code(code);
|
||||
|
||||
let unified_message = UnifiedSessionMessage {
|
||||
session_id: session_id.to_string(),
|
||||
message_type: SessionMessageType::SessionPromptEnd,
|
||||
sub_type: "error".to_string(),
|
||||
data: serde_json::json!({
|
||||
"code": error_code,
|
||||
"message": "Agent computer execution error, please retry (tasks consuming too much memory may cause the agent computer process to terminate).",
|
||||
}),
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
|
||||
let json_data = match serde_json::to_string(&unified_message) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Failed to serialize gRPC stream error event: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
// 返回包含基本信息的最小结构
|
||||
format!(
|
||||
r#"{{"session_id":"{}","message_type":"SessionPromptEnd","sub_type":"error","data":{{"code":"{}","message":"Agent computer execution error, please retry (tasks consuming too much memory may cause the agent computer process to terminate)."}}}}"#,
|
||||
session_id, error_code
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
axum::response::sse::Event::default()
|
||||
.event("error")
|
||||
.data(json_data)
|
||||
}
|
||||
|
||||
/// 创建连接失败错误事件
|
||||
///
|
||||
/// 当 gRPC 连接建立失败(重试后)时发送此事件
|
||||
fn create_connection_error_event(session_id: &str, message: &str) -> axum::response::sse::Event {
|
||||
let unified_message = UnifiedSessionMessage {
|
||||
session_id: session_id.to_string(),
|
||||
message_type: SessionMessageType::SessionPromptEnd,
|
||||
sub_type: "error".to_string(),
|
||||
data: serde_json::json!({
|
||||
"code": "GRPC_CONNECTION_FAILED",
|
||||
"message": message,
|
||||
}),
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
|
||||
let json_data = match serde_json::to_string(&unified_message) {
|
||||
Ok(json) => json,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [gRPC_SSE] Failed to serialize connection error event: session_id={}, error={}",
|
||||
session_id, e
|
||||
);
|
||||
// 返回包含基本信息的最小结构
|
||||
format!(
|
||||
r#"{{"session_id":"{}","message_type":"SessionPromptEnd","sub_type":"error","data":{{"code":"GRPC_CONNECTION_FAILED","message":"Connection failed"}}}}"#,
|
||||
session_id
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
axum::response::sse::Event::default()
|
||||
.event("error")
|
||||
.data(json_data)
|
||||
}
|
||||
|
||||
/// 将 tonic::Code 映射为业务错误码
|
||||
fn map_tonic_code_to_error_code(code: Code) -> &'static str {
|
||||
match code {
|
||||
Code::Unavailable => "GRPC_SERVICE_UNAVAILABLE",
|
||||
Code::Cancelled => "GRPC_CANCELLED",
|
||||
Code::DeadlineExceeded => "GRPC_TIMEOUT",
|
||||
Code::Unknown => "GRPC_UNKNOWN_ERROR",
|
||||
_ => "GRPC_ERROR",
|
||||
}
|
||||
}
|
||||
648
qiming-rcoder/crates/rcoder/src/handler/agent_cancel_handler.rs
Normal file
648
qiming-rcoder/crates/rcoder/src/handler/agent_cancel_handler.rs
Normal file
@@ -0,0 +1,648 @@
|
||||
//! Agent任务取消处理器
|
||||
//!
|
||||
//! 转发取消请求到容器内的 agent_runner 服务
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::router::AppState;
|
||||
use docker_manager::ContainerBasicInfo;
|
||||
use shared_types::{AppError, AgentCancelRequest, AgentCancelResponse, ComputerAgentCancelRequest, HttpResult};
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, extract_grpc_addr, get_locale_from_headers};
|
||||
|
||||
/// Computer Agent 取消任务的查询参数(仅用于测试)
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct ComputerCancelQuery {
|
||||
/// 用户ID,用于标识特定的用户容器(ComputerAgentRunner模式,可与 pod_id 二选一)
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: Option<String>,
|
||||
/// 项目ID,必填,用于标识要取消的特定项目的 agent
|
||||
#[schema(example = "project456")]
|
||||
pub project_id: String,
|
||||
/// 会话ID,用于标识要取消的会话(可选)
|
||||
#[serde(default)]
|
||||
#[schema(example = "session789")]
|
||||
pub session_id: Option<String>,
|
||||
/// Pod ID,用于共享容器模式下的容器定位(可选)
|
||||
#[serde(default)]
|
||||
pub pod_id: Option<String>,
|
||||
/// 租户ID(可选)
|
||||
#[serde(default, deserialize_with = "shared_types::flexible_string::flexible_string")]
|
||||
pub tenant_id: Option<String>,
|
||||
/// 空间ID(可选)
|
||||
#[serde(default, deserialize_with = "shared_types::flexible_string::flexible_string")]
|
||||
pub space_id: Option<String>,
|
||||
/// 隔离类型(可选),如 "project", "tenant", "space"
|
||||
#[serde(default)]
|
||||
pub isolation_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 取消操作的标识符
|
||||
#[derive(Debug, Clone)]
|
||||
enum CancelIdentifier {
|
||||
/// RCoder 模式:使用 project_id
|
||||
Project(String),
|
||||
/// ComputerAgentRunner 模式:使用 user_id
|
||||
User(String),
|
||||
/// 共享容器模式:使用 pod_id
|
||||
Pod(String),
|
||||
}
|
||||
|
||||
/// 统一的容器查询函数 - 通过 DuckDB 查询
|
||||
async fn get_container_for_cancel_duckdb(
|
||||
state: &AppState,
|
||||
identifier: &CancelIdentifier,
|
||||
) -> Result<Option<ContainerBasicInfo>, AppError> {
|
||||
let identifier_display = match identifier {
|
||||
CancelIdentifier::Project(pid) => format!("project_id={}", pid),
|
||||
CancelIdentifier::User(uid) => format!("user_id={}", uid),
|
||||
CancelIdentifier::Pod(pod_id) => format!("pod_id={}", pod_id),
|
||||
};
|
||||
|
||||
info!(
|
||||
"🔍 [CANCEL_CONTAINER_DUCKDB] Looking up container: {}",
|
||||
identifier_display
|
||||
);
|
||||
|
||||
let container_info = match identifier {
|
||||
CancelIdentifier::Project(project_id) => {
|
||||
// RCoder 模式:直接通过 project_id 查询
|
||||
// ProjectAdapter.get() 内部会调用 get_container_for_project
|
||||
state
|
||||
.get_project(project_id)
|
||||
.and_then(|info| info.container().cloned())
|
||||
}
|
||||
CancelIdentifier::User(user_id) => {
|
||||
// ComputerAgentRunner 模式:通过 user_id 查询容器
|
||||
// 使用新添加的 get_container_by_user_id 方法
|
||||
state.projects.get_container_by_user_id(user_id)
|
||||
}
|
||||
CancelIdentifier::Pod(pod_id) => {
|
||||
// 共享容器模式:通过 pod_id 查询容器
|
||||
// 目前暂时使用 get_container_by_user_id 作为占位,后续需要实现 get_container_by_pod_id
|
||||
state.projects.get_container_by_pod_id(pod_id)
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref info) = container_info {
|
||||
info!(
|
||||
"✅ [CANCEL_CONTAINER_DUCKDB] Container found: {}, container_id={}, service_url={}",
|
||||
identifier_display, info.container_id, info.service_url
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"ℹ️ [CANCEL_CONTAINER_DUCKDB] Container not found: {}, no need to cancel",
|
||||
identifier_display
|
||||
);
|
||||
}
|
||||
|
||||
Ok(container_info)
|
||||
}
|
||||
|
||||
/// 转发取消请求到容器内的 agent_runner 服务
|
||||
///
|
||||
/// 🎯 使用 gRPC CancelSession RPC 替代 HTTP 转发
|
||||
async fn forward_cancel_request_to_container_service(
|
||||
project_id: &str,
|
||||
session_id: Option<&str>,
|
||||
container_info: &ContainerBasicInfo,
|
||||
grpc_pool: &Arc<crate::grpc::GrpcChannelPool>,
|
||||
locale: &'static str,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
|
||||
let session_id_display = session_id
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "None".to_string());
|
||||
info!(
|
||||
"📤 [CANCEL_FORWARD] Forwarding cancel request to container (gRPC): project_id={}, session_id={}, container_id={}",
|
||||
project_id, session_id_display, container_info.container_id
|
||||
);
|
||||
|
||||
// 🎯 使用 gRPC 替代 HTTP
|
||||
// 从 service_url 提取 gRPC 地址
|
||||
let grpc_addr = extract_grpc_addr(&container_info.service_url)?;
|
||||
|
||||
info!(
|
||||
"📡 [CANCEL_FORWARD] Sending gRPC cancel request to: {}, session_id={}",
|
||||
grpc_addr, session_id_display
|
||||
);
|
||||
|
||||
// 构建 session_id(如果未提供则使用空字符串,由 Agent Runner 根据 project_id 查找)
|
||||
let session_id_str = session_id.unwrap_or("").to_string();
|
||||
let reason = "User requested cancellation".to_string();
|
||||
|
||||
// 调用 gRPC CancelSession
|
||||
match crate::grpc::grpc_cancel_session_with_pool(
|
||||
grpc_pool,
|
||||
&grpc_addr,
|
||||
session_id_str.clone(),
|
||||
reason,
|
||||
project_id.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(grpc_response) => {
|
||||
if grpc_response.success {
|
||||
info!(
|
||||
"✅ [CANCEL_FORWARD] gRPC cancel succeeded: session_id={}",
|
||||
session_id_str
|
||||
);
|
||||
Ok(HttpResult::success(AgentCancelResponse {
|
||||
success: true,
|
||||
session_id: session_id_str,
|
||||
}))
|
||||
} else {
|
||||
let error_msg = grpc_response
|
||||
.message
|
||||
.unwrap_or_else(|| "Unknown error".to_string());
|
||||
error!("[CANCEL_FORWARD] gRPC cancelfailed: {}", error_msg);
|
||||
Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_CANCEL_FAILED,
|
||||
locale,
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("[CANCEL_FORWARD] gRPC call failed: {}", e);
|
||||
|
||||
// 检查特定的 gRPC 错误码并分类处理
|
||||
if let Some(status) = crate::grpc::extract_grpc_status(&e) {
|
||||
use tonic::Code;
|
||||
match status.code() {
|
||||
Code::NotFound => {
|
||||
// 会话或 Agent 不存在,返回成功(幂等设计)
|
||||
info!("[CANCEL_FORWARD] Session not found, cancel succeeded");
|
||||
return Ok(HttpResult::success(AgentCancelResponse {
|
||||
success: true,
|
||||
session_id: session_id.unwrap_or("").to_string(),
|
||||
}));
|
||||
}
|
||||
Code::Unavailable => {
|
||||
// Agent Worker 不可用,需要判断是容器已销毁还是临时故障
|
||||
// 通过 Docker API 检查容器是否真的存在
|
||||
let container_exists = check_container_exists_by_info(container_info, rcoder_prefix, computer_prefix).await;
|
||||
|
||||
if !container_exists {
|
||||
// 容器已销毁,取消目标已达成(幂等设计)
|
||||
info!(
|
||||
"[CANCEL_FORWARD] container already destroyed, cancel request already completed"
|
||||
);
|
||||
return Ok(HttpResult::success(AgentCancelResponse {
|
||||
success: true,
|
||||
session_id: session_id.unwrap_or("").to_string(),
|
||||
}));
|
||||
} else {
|
||||
// 容器存在但服务不可用(可能是临时故障),返回错误
|
||||
warn!(
|
||||
"[CANCEL_FORWARD] Agent Worker unavailable (container exists, may be temporary failure)"
|
||||
);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_SERVICE_UNAVAILABLE,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
other_code => {
|
||||
// 其他 gRPC 状态码
|
||||
error!("[CANCEL_FORWARD] gRPC error code: {:?}", other_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其他 gRPC 通信失败(网络错误等)
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查容器是否真实存在(通过 Docker API)
|
||||
///
|
||||
/// 用于区分 Unavailable 错误的原因:
|
||||
/// - 容器已销毁 → 返回 false(取消目标已达成)
|
||||
/// - 容器存在但服务不可用 → 返回 true(临时故障)
|
||||
///
|
||||
/// 使用容器名称而非 ID,因为容器重启后 ID 会变,但名称不变
|
||||
async fn check_container_exists_by_info(
|
||||
container_info: &ContainerBasicInfo,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
) -> bool {
|
||||
match docker_manager::runtime::RuntimeManager::get().await {
|
||||
Ok(runtime) => {
|
||||
// 使用配置化的前缀,而不是硬编码的 ServiceType::container_prefix()
|
||||
|
||||
let query = if let Some(identifier) = container_info
|
||||
.container_name
|
||||
.strip_prefix(&format!("{}-", computer_prefix))
|
||||
{
|
||||
runtime
|
||||
.get_container_info_by_identifier(
|
||||
identifier,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
} else if let Some(identifier) = container_info
|
||||
.container_name
|
||||
.strip_prefix(&format!("{}-", rcoder_prefix))
|
||||
{
|
||||
runtime
|
||||
.get_container_info_by_identifier(identifier, &shared_types::ServiceType::RCoder)
|
||||
.await
|
||||
} else {
|
||||
return true;
|
||||
};
|
||||
|
||||
match query {
|
||||
Ok(Some(info)) => {
|
||||
debug!(
|
||||
"🔍 [CANCEL_FORWARD] Runtime container exists: name={}, id={}",
|
||||
info.container_name, info.container_id
|
||||
);
|
||||
true
|
||||
}
|
||||
Ok(None) => {
|
||||
info!(
|
||||
"🔍 [CANCEL_FORWARD] Runtime container not found (already destroyed): {}",
|
||||
container_info.container_name
|
||||
);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [CANCEL_FORWARD] Failed to query runtime container status: {}, conservatively assuming container exists",
|
||||
e
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 无法获取 runtime,保守地认为容器存在
|
||||
warn!(
|
||||
"[CANCEL_FORWARD] Failed to get runtime: {}, conservatively assuming container exists",
|
||||
e
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部核心处理函数 v2:处理会话取消请求(支持多种服务类型)
|
||||
///
|
||||
/// 使用 DuckDB 统一查询,支持 RCoder 和 ComputerAgentRunner 两种模式
|
||||
async fn handle_session_cancel_internal_v2(
|
||||
state: &AppState,
|
||||
identifier: CancelIdentifier,
|
||||
project_id: String, // 必填:传递给 agent_runner 的项目ID
|
||||
session_id: Option<String>, // 可选:会话ID
|
||||
locale: &'static str,
|
||||
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
|
||||
let session_id_display = session_id
|
||||
.as_deref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "None".to_string());
|
||||
|
||||
let identifier_display = match &identifier {
|
||||
CancelIdentifier::Project(pid) => format!("project_id={}", pid),
|
||||
CancelIdentifier::User(uid) => format!("user_id={}", uid),
|
||||
CancelIdentifier::Pod(pod_id) => format!("pod_id={}", pod_id),
|
||||
};
|
||||
|
||||
info!(
|
||||
"🛑 [CANCEL_FORWARD_V2] Received cancel task request: session_id={}, project_id={}, {}",
|
||||
session_id_display, project_id, identifier_display
|
||||
);
|
||||
|
||||
// 获取容器(不创建)
|
||||
let container_info = get_container_for_cancel_duckdb(state, &identifier).await?;
|
||||
|
||||
// 如果容器不存在,说明任务已经结束或从未启动,直接返回成功
|
||||
let Some(container_info) = container_info else {
|
||||
info!(
|
||||
"✅ [CANCEL_FORWARD_V2] Container not found, cancel target already achieved: {}",
|
||||
identifier_display
|
||||
);
|
||||
return Ok(HttpResult::success(AgentCancelResponse {
|
||||
success: true,
|
||||
session_id: session_id.unwrap_or_else(|| "all".to_string()),
|
||||
}));
|
||||
};
|
||||
|
||||
// 转发取消请求到容器服务
|
||||
let result = forward_cancel_request_to_container_service(
|
||||
&project_id, // 使用传入的 project_id
|
||||
session_id.as_deref(),
|
||||
&container_info,
|
||||
&state.grpc_pool,
|
||||
locale,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
)
|
||||
.await;
|
||||
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
info!(
|
||||
"✅ [CANCEL_FORWARD_V2] Cancel request handled successfully: project_id={}, {}",
|
||||
project_id, identifier_display
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [CANCEL_FORWARD_V2] Cancel request handling failed: project_id={}, {}, error={}",
|
||||
project_id, identifier_display, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 处理agent任务取消请求
|
||||
///
|
||||
/// 转发取消请求到容器内的 agent_runner 服务(使用 gRPC)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/agent/session/cancel",
|
||||
request_body = AgentCancelRequest,
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功转发取消请求到容器",
|
||||
body = HttpResult<AgentCancelResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"session_id": "session456"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "INVALID_PARAMS",
|
||||
"message": "Invalid project_id or session_id"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "未找到对应的项目或会话",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "PROJECT_NOT_FOUND",
|
||||
"message": "Project or session not found"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "转发取消请求失败",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "CANCEL_FAILED",
|
||||
"message": "Failed to forward cancel request to container"
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "agent",
|
||||
operation_id = "agent_session_cancel",
|
||||
summary = "转发Agent任务取消请求(gRPC)",
|
||||
description = "将取消请求通过 gRPC 转发到容器内的 agent_runner 服务"
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn agent_session_cancel(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<AgentCancelRequest>,
|
||||
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
info!(
|
||||
"🚫 [CANCEL] Agent cancel request: project_id={}, session_id={:?}",
|
||||
project_id, request.session_id
|
||||
);
|
||||
|
||||
handle_session_cancel_internal_v2(
|
||||
&state,
|
||||
CancelIdentifier::Project(project_id.to_string()),
|
||||
project_id.to_string(),
|
||||
request.session_id,
|
||||
locale,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 处理 Computer Agent 任务取消请求
|
||||
///
|
||||
/// 转发取消请求到容器内的 agent_runner 服务(使用 gRPC)
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/agent/session/cancel",
|
||||
request_body = ComputerAgentCancelRequest,
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功转发取消请求到容器",
|
||||
body = HttpResult<AgentCancelResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"session_id": "session456"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "ERR_VALIDATION",
|
||||
"message": "user_id 或 pod_id is required"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "未找到对应的用户容器或会话",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "CONTAINER_NOT_FOUND",
|
||||
"message": "User container not found"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "转发取消请求失败",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "CANCEL_FAILED",
|
||||
"message": "Failed to forward cancel request to container"
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_agent_session_cancel",
|
||||
summary = "转发 Computer Agent 任务取消请求(支持 user_id)",
|
||||
description = "将 Computer Agent 取消请求通过 gRPC 转发到容器内的 agent_runner 服务,支持通过 user_id 或 pod_id 定位用户容器"
|
||||
)]
|
||||
#[instrument(skip(state), fields(user_id = ?request.user_id.as_ref().map(|s| s.as_str()), project_id = %request.project_id, pod_id = ?request.pod_id.as_ref().map(|s| s.as_str())))]
|
||||
pub async fn computer_agent_session_cancel(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentCancelRequest>,
|
||||
) -> Result<HttpResult<AgentCancelResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 验证 user_id 或 pod_id 至少有一个
|
||||
let has_user_id = request.user_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
let has_pod_id = request.pod_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
if !has_user_id && !has_pod_id {
|
||||
error!("[COMPUTER_CANCEL] user_id or pod_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
// 验证 project_id 不为空
|
||||
if request.project_id.trim().is_empty() {
|
||||
error!("[COMPUTER_CANCEL] project_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"🚀 [COMPUTER_CANCEL] Starting to process cancel request: user_id={:?}, pod_id={:?}, project_id={}, session_id={:?}",
|
||||
request.user_id, request.pod_id, request.project_id, request.session_id
|
||||
);
|
||||
|
||||
// 使用 user_id 或 pod_id 来构建 CancelIdentifier
|
||||
let identifier = if has_user_id {
|
||||
CancelIdentifier::User(request.user_id.clone().unwrap())
|
||||
} else {
|
||||
CancelIdentifier::Pod(request.pod_id.clone().unwrap())
|
||||
};
|
||||
|
||||
handle_session_cancel_internal_v2(
|
||||
&state,
|
||||
identifier,
|
||||
request.project_id, // 必填的 project_id
|
||||
request.session_id,
|
||||
locale,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_computer_cancel_query_deserialization() {
|
||||
// 测试 ComputerCancelQuery 反序列化
|
||||
let query_json = json!({
|
||||
"user_id": "user_123",
|
||||
"project_id": "project_456",
|
||||
"session_id": "session_789"
|
||||
});
|
||||
|
||||
let query: ComputerCancelQuery = serde_json::from_value(query_json).unwrap();
|
||||
assert_eq!(query.user_id, Some("user_123".to_string()));
|
||||
assert_eq!(query.project_id, "project_456");
|
||||
assert_eq!(query.session_id, Some("session_789".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_computer_cancel_query_optional_session() {
|
||||
// 测试不带 session_id 的情况
|
||||
let query_json = json!({
|
||||
"user_id": "user_123",
|
||||
"project_id": "project_456"
|
||||
});
|
||||
|
||||
let query: ComputerCancelQuery = serde_json::from_value(query_json).unwrap();
|
||||
assert_eq!(query.user_id, Some("user_123".to_string()));
|
||||
assert_eq!(query.project_id, "project_456");
|
||||
assert_eq!(query.session_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cancel_identifier_display() {
|
||||
// 测试 CancelIdentifier 的显示格式
|
||||
let project_id = CancelIdentifier::Project("test_project".to_string());
|
||||
let user_id = CancelIdentifier::User("test_user".to_string());
|
||||
let pod_id = CancelIdentifier::Pod("test_pod".to_string());
|
||||
|
||||
let display = match &project_id {
|
||||
CancelIdentifier::Project(pid) => format!("project_id={}", pid),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(display, "project_id=test_project");
|
||||
|
||||
let display = match &user_id {
|
||||
CancelIdentifier::User(uid) => format!("user_id={}", uid),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(display, "user_id=test_user");
|
||||
|
||||
let display = match &pod_id {
|
||||
CancelIdentifier::Pod(pod_id) => format!("pod_id={}", pod_id),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(display, "pod_id=test_pod");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
137
qiming-rcoder/crates/rcoder/src/handler/agent_status_handler.rs
Normal file
137
qiming-rcoder/crates/rcoder/src/handler/agent_status_handler.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use super::utils::{I18nPath, get_locale_from_headers};
|
||||
use crate::{AgentStatusResponse, AppError, HttpResult, router::AppState};
|
||||
|
||||
/// 查询Agent状态
|
||||
///
|
||||
/// 查询指定项目的Agent服务状态信息(保持原有接口兼容性)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/agent/status/{project_id}",
|
||||
params(
|
||||
("project_id" = String, Path, description = "项目ID", example = "test_project")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功获取Agent状态",
|
||||
body = HttpResult<AgentStatusResponse>,
|
||||
examples(
|
||||
("Agent存活" = (value = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"project_id": "test_project",
|
||||
"is_alive": true,
|
||||
"session_id": "session123",
|
||||
"status": "Active",
|
||||
"last_activity": "2024-01-01T12:00:00Z",
|
||||
"created_at": "2024-01-01T10:00:00Z",
|
||||
"model_provider": {
|
||||
"id": "custom",
|
||||
"name": "custom",
|
||||
"api_protocol": "OpenAI",
|
||||
"default_model": "gpt-4"
|
||||
}
|
||||
},
|
||||
"error": null
|
||||
}))),
|
||||
("Agent不存活" = (value = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"project_id": "test_project",
|
||||
"is_alive": false
|
||||
},
|
||||
"error": null
|
||||
})))
|
||||
)
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "INVALID_PARAMS",
|
||||
"message": "project_id cannot be empty"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "agent",
|
||||
operation_id = "agent_status",
|
||||
summary = "查询Agent状态",
|
||||
description = "查询指定项目的Agent服务状态信息。如果Agent在容器中存在且运行正常,返回完整的状态信息(包括会话ID、活动时间、模型配置等);如果Agent不存在,只返回project_id和is_alive=false。"
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn agent_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nPath(project_id): I18nPath<String>,
|
||||
) -> Result<HttpResult<AgentStatusResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let project_id = project_id.trim();
|
||||
|
||||
if project_id.is_empty() {
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"📊 [AGENT_STATUS] Received agent status query: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 从 DuckDB 存储中获取 Agent 信息
|
||||
if let Some(agent_info) = state.get_project(project_id) {
|
||||
let response = AgentStatusResponse {
|
||||
project_id: agent_info.project_id().to_string(),
|
||||
is_alive: true,
|
||||
session_id: agent_info.session_id().map(|s| s.to_string()),
|
||||
status: agent_info.status().cloned(),
|
||||
last_activity: Some(agent_info.last_activity()),
|
||||
created_at: Some(agent_info.created_at()),
|
||||
model_provider: agent_info
|
||||
.model_provider()
|
||||
.as_ref()
|
||||
.map(|mp| mp.to_safe_info()),
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [AGENT_STATUS] Successfully retrieved Agent status: project_id={}, status={:?}",
|
||||
project_id,
|
||||
agent_info.status()
|
||||
);
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
} else {
|
||||
info!(
|
||||
"📭 [AGENT_STATUS] Agent service not found: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
let response = AgentStatusResponse {
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: false,
|
||||
session_id: None,
|
||||
status: None,
|
||||
last_activity: None,
|
||||
created_at: None,
|
||||
model_provider: None,
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
}
|
||||
}
|
||||
238
qiming-rcoder/crates/rcoder/src/handler/agent_stop_handler.rs
Normal file
238
qiming-rcoder/crates/rcoder/src/handler/agent_stop_handler.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
//! Agent任务停止处理器
|
||||
//!
|
||||
//! 转发停止请求到容器内的 agent_runner 服务
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, get_locale_from_headers};
|
||||
use crate::{AppError, HttpResult, router::AppState};
|
||||
use shared_types::{AgentStopRequest, AgentStopResponse};
|
||||
|
||||
/// 直接销毁指定项目对应的容器
|
||||
async fn destroy_container_for_project(
|
||||
state: &Arc<AppState>,
|
||||
project_id: &str,
|
||||
pod_id: Option<&str>,
|
||||
locale: &'static str,
|
||||
) -> Result<HttpResult<AgentStopResponse>, AppError> {
|
||||
// 容器标识符:pod_id 优先,否则使用 project_id(与创建时一致)
|
||||
let container_identifier = pod_id.unwrap_or(project_id);
|
||||
|
||||
info!(
|
||||
"[STOP_DESTROY] startingdestroycontainer: project_id={}, pod_id={:?}, container_identifier={}",
|
||||
project_id, pod_id, container_identifier
|
||||
);
|
||||
|
||||
let runtime = match docker_manager::runtime::RuntimeManager::get().await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
error!("[STOP_DESTROY] Failed to get runtime: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_CONTAINER_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let container_info = runtime
|
||||
.get_container_info_by_identifier(container_identifier, &shared_types::ServiceType::RCoder)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some(container_info) = container_info {
|
||||
info!(
|
||||
"🎯 [STOP_DESTROY] Container found, starting destruction: container_identifier={}, container_id={}, container_name={}",
|
||||
container_identifier, container_info.container_id, container_info.container_name
|
||||
);
|
||||
|
||||
// 停止容器(使用 container_identifier 构造正确的 pod name / container name)
|
||||
let stop_result = runtime
|
||||
.stop_container_by_identifier(container_identifier, &shared_types::ServiceType::RCoder)
|
||||
.await;
|
||||
|
||||
if let Err(e) = stop_result {
|
||||
error!("[STOP_DESTROY] stoppedcontainerfailed: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_STOP_FAILED,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
// 清理旧容器的 gRPC 连接(避免复用已失效的 TCP 连接)
|
||||
if !container_info.container_ip.is_empty() {
|
||||
let old_grpc_addr = format!(
|
||||
"{}:{}",
|
||||
container_info.container_ip,
|
||||
shared_types::GRPC_DEFAULT_PORT
|
||||
);
|
||||
state.grpc_pool.remove(&old_grpc_addr);
|
||||
}
|
||||
|
||||
// 从 DuckDB 存储中移除项目(如果 project_id 不是 "unknown")
|
||||
if container_info.project_id != "unknown" {
|
||||
state.remove_project(&container_info.project_id);
|
||||
}
|
||||
|
||||
info!(
|
||||
"✅ [STOP_DESTROY] Container destroyed successfully: project_id={}, container_id={}, container_name={}",
|
||||
project_id, container_info.container_id, container_info.container_name
|
||||
);
|
||||
|
||||
let response = AgentStopResponse {
|
||||
success: true,
|
||||
project_id: project_id.to_string(),
|
||||
session_id: None,
|
||||
message: shared_types::get_i18n_message("success.container_destroyed", locale),
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
} else {
|
||||
// 容器不存在,但返回成功
|
||||
info!(
|
||||
"📭 [STOP_DESTROY] Container does not exist, no need to destroy: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
let response = AgentStopResponse {
|
||||
success: true,
|
||||
project_id: project_id.to_string(),
|
||||
session_id: None,
|
||||
message: shared_types::get_i18n_message("success.container_not_exist", locale),
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止指定项目的Agent服务
|
||||
///
|
||||
/// 直接销毁 project_id 对应的容器,不向容器内的 agent_runner 发送消息
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/agent/stop",
|
||||
request_body = AgentStopRequest,
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功销毁容器",
|
||||
body = HttpResult<AgentStopResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"project_id": "test_project",
|
||||
"session_id": null,
|
||||
"message": "容器已成功销毁"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 200,
|
||||
description = "容器不存在但返回成功",
|
||||
body = HttpResult<AgentStopResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"project_id": "test_project",
|
||||
"session_id": null,
|
||||
"message": "容器不存在,无需销毁"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "INVALID_PARAMS",
|
||||
"message": "Invalid project_id parameter"
|
||||
}
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "销毁容器失败",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "DESTROY_FAILED",
|
||||
"message": "Failed to destroy container"
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "agent",
|
||||
operation_id = "agent_stop",
|
||||
summary = "销毁Agent容器",
|
||||
description = "直接销毁 project_id 对应的容器,不向容器内的 agent_runner 发送消息。如果容器不存在,也返回成功。"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn agent_stop(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<AgentStopRequest>,
|
||||
) -> Result<HttpResult<AgentStopResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
info!(
|
||||
"🛑 [STOP_DESTROY] Received container destroy request: project_id={}, pod_id={:?}",
|
||||
project_id, request.pod_id
|
||||
);
|
||||
|
||||
// 直接销毁容器
|
||||
let result = destroy_container_for_project(&state, project_id, request.pod_id.as_deref(), locale).await;
|
||||
|
||||
match &result {
|
||||
Ok(response) => {
|
||||
if let Some(data) = response.data.as_ref() {
|
||||
if data.success {
|
||||
info!(
|
||||
"[STOP_DESTROY] containerdestroysucceeded: project_id={}",
|
||||
project_id
|
||||
);
|
||||
} else {
|
||||
error!(
|
||||
"[STOP_DESTROY] containerdestroyfailed: project_id={}",
|
||||
project_id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
error!(
|
||||
"[STOP_DESTROY] Empty response: project_id={}",
|
||||
project_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [STOP_DESTROY] 销毁容器过程中出错: project_id={}, error={}",
|
||||
project_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
579
qiming-rcoder/crates/rcoder/src/handler/chat_handler.rs
Normal file
579
qiming-rcoder/crates/rcoder/src/handler/chat_handler.rs
Normal file
@@ -0,0 +1,579 @@
|
||||
//! 聊天处理器
|
||||
//!
|
||||
//! 将原始 HTTP 请求直接转发到容器内的 agent_runner 服务
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{extract::State, http::HeaderMap};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_types::{AgentChatRequest, ChatAgentConfig, IsolationType, ModelProviderConfig, ProjectAndContainerInfo};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{router::AppState, *};
|
||||
use docker_manager::ContainerBasicInfo;
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, extract_grpc_addr_with_port, get_locale_from_headers, build_workspace_path};
|
||||
|
||||
/// 处理聊天请求 - 转发到容器化 agent_runner 服务
|
||||
///
|
||||
/// 1. 根据 project_id 检查或动态创建对应的容器(默认使用 ServiceType::RCoder)
|
||||
/// 2. 将原始聊天请求直接转发到容器内的 agent_runner 服务
|
||||
/// 3. 获取并返回 agent_runner 的处理结果
|
||||
///
|
||||
/// 注意:
|
||||
/// - 所有参数处理(如 project_id、session_id 生成)都由 agent_runner 处理
|
||||
/// - RCoder 只负责容器管理和请求转发
|
||||
/// - 当前默认使用 ServiceType::RCoder,AgentRunner 模式正在开发中
|
||||
/// - Resume 会话的降级逻辑已在 agent_runner 层通过 list_sessions API 预检查处理
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/chat",
|
||||
request_body(
|
||||
content = AgentChatRequest,
|
||||
description = "聊天请求,包含用户输入的 prompt 和可选的多媒体附件",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功处理聊天请求",
|
||||
body = HttpResult<ChatResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"project_id": "test_project",
|
||||
"session_id": "session456",
|
||||
"error": null,
|
||||
"request_id": "req_123456789"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误或容器服务异常",
|
||||
body = HttpResult<String>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": {
|
||||
"code": "INTERNAL001",
|
||||
"message": "Internal server error"
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "chat",
|
||||
operation_id = "handle_chat",
|
||||
summary = "转发聊天消息到容器化 AI 服务",
|
||||
description = "根据 project_id 动态管理容器(默认使用 ServiceType::RCoder),将原始聊天请求直接转发到容器内的 agent_runner 服务进行处理"
|
||||
)]
|
||||
#[instrument(skip(state, request), fields(project_id = ?request.project_id, session_id = ?request.session_id))]
|
||||
pub async fn handle_chat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(mut request): I18nJsonOrQuery<AgentChatRequest>,
|
||||
) -> Result<HttpResult<ChatResponse>, AppError> {
|
||||
// 获取语言设置
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
let project_id = match &request.project_id {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
let project_id = crate::service::container_manager::generate_project_id();
|
||||
request.project_id = Some(project_id.clone()); // 设置 project_id
|
||||
project_id
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 隔离类型参数校验 ==========
|
||||
// IF pod_id IS NOT NULL THEN isolation_type, tenant_id, space_id 必须非空
|
||||
if request.pod_id.is_some() {
|
||||
if request.isolation_type.is_none() {
|
||||
error!("[CHAT] Validation failed: isolation_type is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"isolation_type is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
if request.tenant_id.is_none() {
|
||||
error!("[CHAT] Validation failed: tenant_id is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"tenant_id is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
if request.space_id.is_none() {
|
||||
error!("[CHAT] Validation failed: space_id is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"space_id is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
|
||||
// 验证 isolation_type 值有效(大小写不敏感)
|
||||
if let Some(ref it) = request.isolation_type {
|
||||
if IsolationType::from_str(it).is_err() {
|
||||
error!("[CHAT] Validation failed: invalid isolation_type '{}', expected tenant|space|project", it);
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
&format!("invalid isolation_type '{}', expected: tenant, space, project", it),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 记录验证通过的参数(此时 pod_id, isolation_type, tenant_id, space_id 必定为 Some)
|
||||
if let (Some(pid), Some(it), Some(tid), Some(sid)) = (
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
) {
|
||||
info!(
|
||||
"🔒 [CHAT] Isolation parameters validated: pod_id={}, isolation_type={}, tenant_id={}, space_id={}",
|
||||
pid, it, tid, sid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 构建工作空间路径 ==========
|
||||
// 根据 isolation_type 确定容器内工作目录:
|
||||
// - tenant/space: /app/project_workspace/{tenant_id}/{space_id}/{project_id}
|
||||
// - project 或默认: /app/project_workspace/{project_id}
|
||||
let container_work_path = build_workspace_path(
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
&project_id,
|
||||
);
|
||||
|
||||
info!(
|
||||
"📁 [CHAT] Workspace path determined: {} (isolation_type={})",
|
||||
container_work_path,
|
||||
request.isolation_type.as_deref().unwrap_or("project")
|
||||
);
|
||||
|
||||
// 验证资源限制配置
|
||||
if let Some(ref agent_config) = request.agent_config {
|
||||
if let Some(ref resource_limits) = agent_config.resource_limits {
|
||||
resource_limits.validate().map_err(|e| {
|
||||
AppError::validation_error(&format!("Invalid resource limits: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"🚀 [CHAT] Starting to process chat request: project_id={}, session_id={:?}, prompt_length={}, attachments_count={}, model_provider={}",
|
||||
project_id,
|
||||
request.session_id,
|
||||
request.prompt.len(),
|
||||
request.attachments.len(),
|
||||
request
|
||||
.model_provider
|
||||
.as_ref()
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| "None".to_string())
|
||||
);
|
||||
|
||||
// 打印 agent_config 配置信息(debug 级别)
|
||||
info!(
|
||||
"🔧 [CHAT] agent_config: project_id={}, agent_config={:?}",
|
||||
project_id, request.agent_config
|
||||
);
|
||||
|
||||
// 第一步:获取或创建容器,默认使用 ServiceType::RCoder
|
||||
let service_type = shared_types::ServiceType::RCoder;
|
||||
let container_info =
|
||||
crate::service::container_manager::ContainerManager::get_or_create_container(
|
||||
&project_id,
|
||||
&service_type,
|
||||
request
|
||||
.agent_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.resource_limits.clone()),
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
&container_work_path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 第二步:获取或创建 ProjectAndContainerInfo - 使用 DuckDB 存储
|
||||
let _ = {
|
||||
info!(
|
||||
"[CHAT] Getting/creating project: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 检查项目是否存在
|
||||
if let Some(existing_info) = state.get_project(&project_id) {
|
||||
info!(
|
||||
"[CHAT] Project exists, checking for update: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 检查是否需要更新扩展状态
|
||||
let needs_extended_update = existing_info.container().is_none()
|
||||
|| existing_info.model_provider().is_none()
|
||||
|| existing_info.request_id().is_none();
|
||||
|
||||
if needs_extended_update {
|
||||
// 创建更新后的信息
|
||||
let mut mutable_info = (*existing_info).clone();
|
||||
// 补充 pod_id(兼容旧数据或服务重启后丢失的情况)
|
||||
if mutable_info.pod_id().is_none() && request.pod_id.is_some() {
|
||||
mutable_info.set_pod_id(request.pod_id.clone());
|
||||
}
|
||||
mutable_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(service_type.clone()),
|
||||
);
|
||||
mutable_info.update_activity();
|
||||
|
||||
let arc_info = Arc::new(mutable_info);
|
||||
state.insert_project(project_id.clone(), arc_info.clone());
|
||||
|
||||
info!(
|
||||
"✅ [CHAT] Project info fully updated: project_id={}, container_id={}",
|
||||
project_id, container_info.container_id
|
||||
);
|
||||
|
||||
arc_info
|
||||
} else {
|
||||
// 只需要更新活动时间
|
||||
state.update_activity(&project_id);
|
||||
info!(
|
||||
"[CHAT] Activity time updated: project_id={}",
|
||||
project_id
|
||||
);
|
||||
existing_info
|
||||
}
|
||||
} else {
|
||||
info!(
|
||||
"[CHAT] Creating new project: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 创建新的 ProjectAndContainerInfo
|
||||
let mut new_info = ProjectAndContainerInfo::new(project_id.clone());
|
||||
new_info.set_pod_id(request.pod_id.clone());
|
||||
new_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(service_type.clone()),
|
||||
);
|
||||
|
||||
let arc_info = Arc::new(new_info);
|
||||
state.insert_project(project_id.clone(), arc_info.clone());
|
||||
|
||||
info!(
|
||||
"✅ [CHAT] Project info created: project_id={}, container_id={}",
|
||||
project_id, container_info.container_id
|
||||
);
|
||||
|
||||
arc_info
|
||||
}
|
||||
};
|
||||
|
||||
// 请求到达时立即更新活动时间(不等待请求执行结果)
|
||||
// 这样可以防止在 gRPC 请求期间被 cleanup_task 误清理
|
||||
state.update_activity(&project_id);
|
||||
debug!("[CHAT] Updated activity time: project_id={}", project_id);
|
||||
|
||||
// 🆕 自动查找 session_id 逻辑
|
||||
// 如果用户没有传递 session_id,尝试从状态中查找最新的 session_id
|
||||
let session_id_to_use = match &request.session_id {
|
||||
Some(sid) if !sid.is_empty() => {
|
||||
debug!("[CHAT] Using provided session_id: {}", sid);
|
||||
sid.clone()
|
||||
}
|
||||
_ => {
|
||||
// 用户没有传递 session_id,尝试查找最新的
|
||||
match state.get_project(&project_id) {
|
||||
Some(project_info) => {
|
||||
let existing_session_id = project_info.session_id();
|
||||
match existing_session_id {
|
||||
Some(sid) if !sid.is_empty() => {
|
||||
info!(
|
||||
"🔄 [CHAT] No session_id provided, auto using latest session: project_id={}, session_id={}",
|
||||
project_id, sid
|
||||
);
|
||||
sid.to_string()
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
"[CHAT] No existing session_id for project, will create new session"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
debug!("[CHAT] No project exists, will create new session");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 克隆 request 并修改 session_id
|
||||
let mut request_for_forward = request.clone();
|
||||
request_for_forward.session_id = if session_id_to_use.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(session_id_to_use)
|
||||
};
|
||||
// 🆕 自动查找 session_id 逻辑结束
|
||||
|
||||
// 第三步:转发请求到容器服务(使用全局连接池)
|
||||
info!("[CHAT] Forwarding request to container service");
|
||||
let result = forward_request_to_container_service(
|
||||
&request_for_forward,
|
||||
&container_info,
|
||||
&state.grpc_pool,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
locale,
|
||||
)
|
||||
.await;
|
||||
info!("[CHAT] Container request completed: success={}", result.is_ok());
|
||||
|
||||
// 响应后状态更新 - 使用 DuckDB 存储
|
||||
// 无论请求成功还是失败,只要响应中包含 session_id,都要更新映射
|
||||
// 这样用户可以通过 SSE 接口获取错误通知,而不会收到 SESSION_EXPIRED 错误
|
||||
if let Ok(http_result) = &result {
|
||||
if let Some(chat_response) = &http_result.data {
|
||||
let session_id = chat_response.session_id.clone();
|
||||
|
||||
// 只有当 session_id 非空时才更新映射
|
||||
if !session_id.is_empty() {
|
||||
info!(
|
||||
"📊 [CHAT] Received chat response, starting state update: session_id={}, success={}",
|
||||
session_id,
|
||||
http_result.is_success()
|
||||
);
|
||||
|
||||
// 更新会话信息(同时更新 session_id 和 session-to-container 映射)
|
||||
info!(
|
||||
"🔗 [SESSION_MAP] Associated session_id {} to project_id {}",
|
||||
session_id, project_id
|
||||
);
|
||||
state.update_session(&project_id, &session_id);
|
||||
|
||||
// 更新项目活动时间
|
||||
state.update_activity(&project_id);
|
||||
|
||||
if http_result.is_success() {
|
||||
info!(
|
||||
"🎯 [CHAT] All state updates completed: project_id={}, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"⚠️ [CHAT] Request failed but session mapping saved: project_id={}, session_id={}, code={}, message={}",
|
||||
project_id, session_id, http_result.code, http_result.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.as_ref().map_or(true, |r| {
|
||||
!r.is_success() && r.data.as_ref().map_or(true, |d| d.session_id.is_empty())
|
||||
}) {
|
||||
error!("[CHAT] Container returned error: {:?}", result);
|
||||
}
|
||||
|
||||
info!("[CHAT] Request completed: project_id={}", project_id);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// 转发请求到容器内的 agent_runner 服务
|
||||
///
|
||||
/// 🎯 使用 gRPC Chat RPC 替代 HTTP 转发(使用全局连接池)
|
||||
async fn forward_request_to_container_service(
|
||||
request: &AgentChatRequest,
|
||||
container_info: &ContainerBasicInfo,
|
||||
grpc_pool: &Arc<crate::grpc::GrpcChannelPool>,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
locale: &'static str,
|
||||
) -> Result<crate::HttpResult<ChatResponse>, crate::AppError> {
|
||||
let project_id = if let Some(id) = &request.project_id {
|
||||
id.clone()
|
||||
} else {
|
||||
error!("[FORWARD]session project_id is required");
|
||||
return Ok(crate::HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
};
|
||||
|
||||
info!(
|
||||
"📤 [FORWARD] Forwarding request to container (gRPC): project_id={}, session_id={:?}, container_id={}, service_url={}",
|
||||
project_id, request.session_id, container_info.container_id, container_info.service_url
|
||||
);
|
||||
|
||||
// 🎯 使用 gRPC 替代 HTTP
|
||||
// 使用实时 IP 获取,避免容器重建后 IP 变化导致连接失败
|
||||
// 直接使用 container_info.container_name(创建时已确定,无需重新拼接)
|
||||
let container_name = container_info.container_name.clone();
|
||||
let mut grpc_addr = match super::utils::get_realtime_container_ip(
|
||||
&container_name,
|
||||
&container_info.container_ip,
|
||||
rcoder_prefix,
|
||||
computer_prefix,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT),
|
||||
Err(e) => {
|
||||
warn!("[FORWARD] Real-time IP resolution failed: {}, falling back to service_url", e);
|
||||
extract_grpc_addr_with_port(&container_info.service_url, shared_types::GRPC_DEFAULT_PORT)?
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"📡 [FORWARD] Sending gRPC request to: {}, prompt_length={}, attachments_count={}",
|
||||
grpc_addr,
|
||||
request.prompt.len(),
|
||||
request.attachments.len()
|
||||
);
|
||||
|
||||
// 调用 gRPC Chat(使用全局连接池,带重试和被动驱逐机制)
|
||||
let max_retries = 2;
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
match crate::grpc::grpc_chat_with_pool(
|
||||
grpc_pool,
|
||||
&grpc_addr,
|
||||
project_id.clone(),
|
||||
request.session_id.clone(),
|
||||
request.prompt.clone(),
|
||||
request.attachments.clone(),
|
||||
request.data_source_attachments.clone(),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(std::time::Duration::from_secs(300)), // 5 分钟超时,避免永久阻塞
|
||||
// 新增参数 (v2)
|
||||
request.system_prompt.clone(),
|
||||
request.user_prompt.clone(),
|
||||
request.agent_config.clone(),
|
||||
Some(shared_types::ServiceType::RCoder), // ✅ RCoder 模式使用 RCoder ServiceType
|
||||
None, // RCoder 模式不需要 user_id
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(grpc_response) => {
|
||||
if grpc_response.success {
|
||||
// 转换为内部 ChatResponse
|
||||
let chat_response = crate::grpc::grpc_response_to_chat_response(grpc_response);
|
||||
info!(
|
||||
"✅ [FORWARD] gRPC response success: project_id={}, session_id={}",
|
||||
chat_response.project_id, chat_response.session_id
|
||||
);
|
||||
return Ok(crate::HttpResult::success(chat_response));
|
||||
} else {
|
||||
let error_msg = grpc_response
|
||||
.error
|
||||
.unwrap_or_else(|| "Unknown error".to_string());
|
||||
// 🎯 从 gRPC 响应中提取错误码(完整透传)
|
||||
let error_code = grpc_response
|
||||
.error_code
|
||||
.unwrap_or_else(|| shared_types::error_codes::ERR_AGENT_ERROR.to_string());
|
||||
error!(
|
||||
"❌ [FORWARD] gRPC response error: code={}, message={}",
|
||||
error_code, error_msg
|
||||
);
|
||||
return Ok(crate::HttpResult::error(&error_code, &error_msg));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [FORWARD] gRPC call failed (attempt {}/{}): {}",
|
||||
attempt, max_retries, e
|
||||
);
|
||||
|
||||
// ✅ 使用错误分类判断是否应该重试
|
||||
let should_retry = crate::grpc::should_retry_error(&e);
|
||||
|
||||
if should_retry && attempt < max_retries {
|
||||
// 可重试错误:清理连接池并重新获取 IP 后重试
|
||||
info!("🔄 [FORWARD] Detected retryable error, re-resolving container IP and retrying...");
|
||||
grpc_pool.remove(&grpc_addr);
|
||||
|
||||
// 重新获取最新容器 IP(容器可能已重建,IP 可能变化)
|
||||
match super::utils::get_realtime_container_ip(
|
||||
&container_name,
|
||||
&container_info.container_ip,
|
||||
rcoder_prefix,
|
||||
computer_prefix,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => {
|
||||
let new_addr = format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT);
|
||||
info!(
|
||||
"🔄 [FORWARD] Container IP re-resolved: {} -> {}",
|
||||
grpc_addr, new_addr
|
||||
);
|
||||
grpc_addr = new_addr;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [FORWARD] Failed to re-resolve container IP, keeping old address: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
last_error = Some(e);
|
||||
continue;
|
||||
} else if !should_retry {
|
||||
// 不可重试错误:直接返回
|
||||
error!("[FORWARD] Detected non-retryable error, stopped retry: {}", e);
|
||||
last_error = Some(e);
|
||||
break;
|
||||
}
|
||||
|
||||
// 最后一次尝试失败
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果所有重试都失败
|
||||
if let Some(e) = last_error {
|
||||
error!("[FORWARD] gRPC request failed after all retries: {}", e);
|
||||
|
||||
// gRPC 通信失败,直接返回错误
|
||||
// 注:业务错误码(如 Agent busy)现在由 agent_runner 通过 grpc_response.error_code 返回
|
||||
// 这里只处理真正的 gRPC 通信层错误
|
||||
Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ERROR,
|
||||
locale,
|
||||
))
|
||||
} else {
|
||||
// 理论上不会走到这里,除非 max_retries < 1
|
||||
Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ERROR,
|
||||
locale,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Computer Agent Status Handler
|
||||
//!
|
||||
//! 查询 Computer Agent 的运行状态(通过 gRPC GetStatus 主动确认)
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, get_locale_from_headers, get_realtime_container_ip};
|
||||
use crate::router::AppState;
|
||||
use crate::{AppError, HttpResult};
|
||||
use shared_types::{ComputerAgentStatusRequest, ComputerAgentStatusResponse};
|
||||
|
||||
/// gRPC GetStatus 最大重试次数
|
||||
const GRPC_MAX_RETRIES: u32 = 3;
|
||||
|
||||
/// gRPC GetStatus 请求超时时间(秒)
|
||||
const GRPC_REQUEST_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// 处理 Computer Agent 状态查询
|
||||
///
|
||||
/// 核心流程:
|
||||
/// 1. 验证 user_id 和 project_id
|
||||
/// 2. 查询容器是否存在且运行中
|
||||
/// 3. 主动调用 gRPC GetStatus 确认 Agent 真实状态
|
||||
/// 4. 返回综合状态信息
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/agent/status",
|
||||
request_body(
|
||||
content = ComputerAgentStatusRequest,
|
||||
description = "Computer Agent 状态查询请求",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功获取 Agent 状态",
|
||||
body = HttpResult<ComputerAgentStatusResponse>,
|
||||
examples(
|
||||
("Agent 已启动" = (value = json!({
|
||||
"success": true,
|
||||
"code": "0000",
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"user_id": "user_123",
|
||||
"project_id": "proj_456",
|
||||
"is_alive": true,
|
||||
"session_id": "session_abc123",
|
||||
"status": "idle",
|
||||
"last_activity": "2024-01-01T12:00:00Z",
|
||||
"created_at": "2024-01-01T10:00:00Z"
|
||||
}
|
||||
}))),
|
||||
("Agent 未启动" = (value = json!({
|
||||
"success": true,
|
||||
"code": "0000",
|
||||
"message": "Success",
|
||||
"data": {
|
||||
"user_id": "user_123",
|
||||
"project_id": "proj_456",
|
||||
"is_alive": false
|
||||
}
|
||||
})))
|
||||
)
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_agent_status",
|
||||
summary = "查询 Computer Agent 状态",
|
||||
description = "查询指定 user_id + project_id 对应的 Computer Agent 是否已启动。通过主动调用子容器的 gRPC GetStatus 接口确认 Agent 真实状态。"
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn computer_agent_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentStatusRequest>,
|
||||
) -> Result<HttpResult<ComputerAgentStatusResponse>, AppError> {
|
||||
// 获取语言设置
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
// 1. 参数验证:user_id 或 pod_id 至少有一个
|
||||
let has_user_id = request.user_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
let has_pod_id = request.pod_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
if !has_user_id && !has_pod_id {
|
||||
error!("[COMPUTER_AGENT_STATUS] user_id or pod_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
// 用于日志输出的标识符
|
||||
let identifier_display = if has_user_id {
|
||||
format!("user_id={}", request.user_id.as_ref().unwrap())
|
||||
} else {
|
||||
format!("pod_id={}", request.pod_id.as_ref().unwrap())
|
||||
};
|
||||
|
||||
info!(
|
||||
"🔍 [COMPUTER_AGENT_STATUS] Querying Agent status: {}, project_id={}",
|
||||
identifier_display, project_id
|
||||
);
|
||||
|
||||
// 2. 查询容器信息(通过 Runtime)
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_AGENT_STATUS] Failed to get runtime: {}", e);
|
||||
AppError::internal_server_error(&format!("Failed to get runtime: {}", e))
|
||||
})?;
|
||||
|
||||
// 获取容器标识符(user_id 或 pod_id)
|
||||
let identifier = request.user_id.clone().or(request.pod_id.clone());
|
||||
let identifier_str = identifier.as_deref().unwrap_or("");
|
||||
|
||||
// 获取容器信息(ComputerAgentRunner 使用 user_id 或 pod_id 作为容器标识)
|
||||
let container_info = match runtime
|
||||
.get_container_info_by_identifier(
|
||||
identifier_str,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
info!(
|
||||
"📭 [COMPUTER_AGENT_STATUS] Container not found: identifier={}",
|
||||
identifier_str
|
||||
);
|
||||
// Early return: 直接 move request 的字段
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [COMPUTER_AGENT_STATUS] Failed to query container info: identifier={}, error={}",
|
||||
identifier_str, e
|
||||
);
|
||||
return Err(AppError::internal_server_error(&format!(
|
||||
"Failed to query container info: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 检查容器是否运行中
|
||||
if container_info.status != "running" {
|
||||
info!(
|
||||
"⚠️ [COMPUTER_AGENT_STATUS] Container not running: identifier={}, status={}",
|
||||
identifier_str, container_info.status
|
||||
);
|
||||
// Early return: 直接 move request 的字段
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_AGENT_STATUS] Container running: container_id={}, container_ip={}",
|
||||
container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
// 4. 主动调用 gRPC GetStatus 确认 Agent 真实状态
|
||||
// 使用实时 IP 获取,避免 restart 后 IP 过期
|
||||
let grpc_addr = match get_realtime_container_ip(
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [COMPUTER_AGENT_STATUS] Failed to get container IP: identifier={}, error={}",
|
||||
identifier_str, e
|
||||
);
|
||||
// Early return: 直接 move request 的字段
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"📡 [COMPUTER_AGENT_STATUS] gRPC address: {}, project_id={}",
|
||||
grpc_addr, project_id
|
||||
);
|
||||
|
||||
// 调用 gRPC GetStatus(带超时和重试,重试时自动重新获取 IP)
|
||||
let grpc_response = match call_grpc_get_status_with_retry(
|
||||
&state.grpc_pool,
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
project_id,
|
||||
GRPC_MAX_RETRIES,
|
||||
locale,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_AGENT_STATUS] gRPC GetStatus call failed: {}, project_id={}, error={}",
|
||||
identifier_display, project_id, e
|
||||
);
|
||||
// gRPC 调用失败视为 Agent 不存在
|
||||
// Early return: 直接 move request 的字段
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// 5. 使用 is_found 字段判断 Agent 是否存活
|
||||
let is_alive = grpc_response.is_found;
|
||||
|
||||
if !is_alive {
|
||||
info!(
|
||||
"📭 [COMPUTER_AGENT_STATUS] Agent not started: {}, project_id={}, is_found={}",
|
||||
identifier_display, project_id, grpc_response.is_found
|
||||
);
|
||||
return Ok(HttpResult::success(ComputerAgentStatusResponse::not_alive(
|
||||
request.user_id.clone(),
|
||||
project_id.to_string(),
|
||||
)));
|
||||
}
|
||||
|
||||
// 6. Agent 存活,从 DuckDB 获取完整信息
|
||||
let response = if let Some(project_info) = state.get_project(project_id) {
|
||||
ComputerAgentStatusResponse {
|
||||
user_id: request.user_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: true,
|
||||
session_id: project_info.session_id().map(|s| s.to_string()),
|
||||
status: Some(grpc_response.status.clone()),
|
||||
last_activity: Some(project_info.last_activity()),
|
||||
created_at: Some(project_info.created_at()),
|
||||
}
|
||||
} else {
|
||||
// DuckDB 中无记录,但 gRPC 确认 Agent 存在
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_AGENT_STATUS] Agent exists but no DuckDB record (may be due to service restart causing state loss): {}, project_id={}. Attempting self-healing...",
|
||||
identifier_display, project_id
|
||||
);
|
||||
|
||||
// 🛡️ 自愈逻辑 (Self-Healing)
|
||||
// 自动恢复丢失的项目记录,防止容器被孤立清理器误杀
|
||||
let mut project_info =
|
||||
shared_types::ProjectAndContainerInfo::new(project_id.to_string());
|
||||
project_info.set_user_id(request.user_id.clone());
|
||||
project_info.set_pod_id(request.pod_id.clone());
|
||||
|
||||
// 恢复容器信息
|
||||
// 注意:这里我们使用查询到的 container_info
|
||||
project_info.set_container(Some(container_info.clone()));
|
||||
project_info.set_service_type(Some(shared_types::ServiceType::ComputerAgentRunner));
|
||||
|
||||
// 设置状态 (根据 gRPC 返回的状态)
|
||||
// gRPC status: "idle", "busy" -> 对应 AgentStatus
|
||||
// 简单起见,我们先恢复记录,状态会在后续的心跳或交互中更新
|
||||
|
||||
// 如果 gRPC 返回了 session_id(虽然 GetStatus 通常不返回 session_id),尝试恢复
|
||||
// 但目前的 GetStatusResponse 没有 session_id 字段,所以只能置空或尝试从其他地方恢复
|
||||
// 这里暂时置空,等下次聊天时会自动更新
|
||||
|
||||
// 插入到 DuckDB
|
||||
state.insert_project(project_id.to_string(), Arc::new(project_info.clone()));
|
||||
|
||||
info!(
|
||||
"🔄 [COMPUTER_AGENT_STATUS] ✅ Self-healing succeeded: restored project record project_id={}, {}",
|
||||
project_id, identifier_display
|
||||
);
|
||||
|
||||
ComputerAgentStatusResponse {
|
||||
user_id: request.user_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
is_alive: true,
|
||||
session_id: None, // 恢复时暂时无法获知 session_id
|
||||
status: Some(grpc_response.status.clone()),
|
||||
// 使用当前时间作为最后活动时间,避免立即被清理
|
||||
last_activity: Some(chrono::Utc::now()),
|
||||
created_at: Some(chrono::Utc::now()), // 使用当前时间作为创建时间(近似)
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_AGENT_STATUS] Agent status query completed: {}, project_id={}, is_alive={}, status={}",
|
||||
identifier_display,
|
||||
project_id,
|
||||
response.is_alive,
|
||||
response.status.as_deref().unwrap_or("unknown")
|
||||
);
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
}
|
||||
|
||||
/// 调用 gRPC GetStatus(带重试机制)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `pool`: gRPC 连接池
|
||||
/// - `grpc_addr`: gRPC 服务地址
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `max_retries`: 最大重试次数
|
||||
///
|
||||
/// # 返回
|
||||
/// - `Ok(status)`: 从 Agent 返回的状态字符串(可能的值取决于 Agent 实现,通常为 "idle", "busy", "error", "not_found" 等)
|
||||
/// - `Err(e)`: gRPC 调用失败(网络错误、超时、连接失败等)
|
||||
///
|
||||
/// # 重试策略
|
||||
/// - 仅对可重试的错误进行重试:Unavailable, DeadlineExceeded, Unknown, Internal
|
||||
/// - 使用指数退避:100ms, 200ms, 400ms
|
||||
/// - 失败后自动从连接池移除失败的连接,并重新获取容器 IP
|
||||
async fn call_grpc_get_status_with_retry(
|
||||
pool: &Arc<crate::grpc::GrpcChannelPool>,
|
||||
container_name: &str,
|
||||
fallback_ip: &str,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
project_id: &str,
|
||||
max_retries: u32,
|
||||
locale: &'static str,
|
||||
) -> anyhow::Result<shared_types::grpc::GetStatusResponse> {
|
||||
let mut last_error = None;
|
||||
let mut grpc_addr = format!("{}:{}", fallback_ip, shared_types::GRPC_DEFAULT_PORT);
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
// 重新获取最新容器 IP(每次重试时)
|
||||
if attempt > 1 {
|
||||
match get_realtime_container_ip(container_name, fallback_ip, rcoder_prefix, computer_prefix).await {
|
||||
Ok(ip) => {
|
||||
let new_addr = format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT);
|
||||
info!(
|
||||
"🔄 [GRPC_GET_STATUS] Container IP re-resolved: {} -> {}",
|
||||
grpc_addr, new_addr
|
||||
);
|
||||
grpc_addr = new_addr;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [GRPC_GET_STATUS] Failed to re-resolve container IP, keeping old address: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match pool.get_client(&grpc_addr).await {
|
||||
Ok(mut client) => {
|
||||
let request = shared_types::grpc::GetStatusRequest {
|
||||
project_id: project_id.to_string(),
|
||||
session_id: String::new(), // 查询项目级别状态
|
||||
};
|
||||
|
||||
// 设置超时
|
||||
let mut tonic_request = crate::grpc::new_request_with_locale(request, locale);
|
||||
tonic_request
|
||||
.set_timeout(std::time::Duration::from_secs(GRPC_REQUEST_TIMEOUT_SECS));
|
||||
|
||||
match client.get_status(tonic_request).await {
|
||||
Ok(response) => {
|
||||
let grpc_response = response.into_inner();
|
||||
debug!(
|
||||
"✅ [GRPC_GET_STATUS] Attempt {} succeeded: project_id={}, status={}, is_found={}",
|
||||
attempt, project_id, grpc_response.status, grpc_response.is_found
|
||||
);
|
||||
return Ok(grpc_response);
|
||||
}
|
||||
Err(e) => {
|
||||
// 直接判断原始 tonic::Status,避免信息丢失
|
||||
let should_retry = matches!(
|
||||
e.code(),
|
||||
tonic::Code::Unavailable
|
||||
| tonic::Code::DeadlineExceeded
|
||||
| tonic::Code::Unknown
|
||||
| tonic::Code::Internal
|
||||
);
|
||||
|
||||
if should_retry && attempt < max_retries {
|
||||
warn!(
|
||||
"⚠️ [GRPC_GET_STATUS] Attempt {} failed (retryable): project_id={}, code={:?}, error={}",
|
||||
attempt,
|
||||
project_id,
|
||||
e.code(),
|
||||
e
|
||||
);
|
||||
// 从连接池移除失败的连接
|
||||
pool.remove(&grpc_addr);
|
||||
last_error = Some(anyhow::anyhow!("gRPC call failed: {}", e));
|
||||
|
||||
// 指数退避: 100ms, 200ms, 400ms
|
||||
let delay_ms = 100 * (1 << (attempt - 1));
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||
continue;
|
||||
} else {
|
||||
error!(
|
||||
"❌ [GRPC_GET_STATUS] Attempt {} failed (non-retryable or max retries reached): project_id={}, code={:?}, error={}",
|
||||
attempt,
|
||||
project_id,
|
||||
e.code(),
|
||||
e
|
||||
);
|
||||
return Err(anyhow::anyhow!("gRPC call failed: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [GRPC_GET_STATUS] Attempt {} to get gRPC client failed: error={}",
|
||||
attempt, e
|
||||
);
|
||||
// 从连接池移除可能失效的连接
|
||||
pool.remove(&grpc_addr);
|
||||
last_error = Some(e);
|
||||
if attempt < max_retries {
|
||||
// 指数退避: 100ms, 200ms, 400ms
|
||||
let delay_ms = 100 * (1 << (attempt - 1));
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Unknown error")))
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Computer Agent 停止处理器
|
||||
//!
|
||||
//! 处理停止特定 project_id 的 Agent 请求(不销毁容器)。
|
||||
//! 与 RCoder 的 agent_stop 不同,这里只停止单个 project_id 的 Agent,
|
||||
//! 容器会继续运行其他 project_id 的 Agent。
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument, warn};
|
||||
|
||||
use crate::{AppError, HttpResult, router::AppState};
|
||||
use shared_types::{ComputerAgentStopRequest, ComputerAgentStopResponse};
|
||||
|
||||
use super::utils::{I18nJsonOrQuery, extract_grpc_addr, get_locale_from_headers};
|
||||
|
||||
/// 停止 Computer Agent
|
||||
///
|
||||
/// 停止特定 user_id 下的特定 project_id 的 Agent。
|
||||
/// 注意:这不会销毁容器,容器会继续运行其他 project_id 的 Agent。
|
||||
///
|
||||
/// 只有当 user_id 下所有 project_id 都闲置时,容器才会被清理任务销毁。
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/agent/stop",
|
||||
request_body(
|
||||
content = ComputerAgentStopRequest,
|
||||
description = "停止特定 project_id 的 Agent 请求",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功停止 Agent",
|
||||
body = HttpResult<ComputerAgentStopResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"message": "Agent 已停止",
|
||||
"user_id": "user_123",
|
||||
"project_id": "proj_456"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到指定的容器或 Agent",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_agent_stop",
|
||||
summary = "停止 Computer Agent",
|
||||
description = "停止特定 project_id 的 Agent,不销毁容器"
|
||||
)]
|
||||
#[instrument(skip(state))]
|
||||
pub async fn computer_agent_stop(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(request): I18nJsonOrQuery<ComputerAgentStopRequest>,
|
||||
) -> Result<HttpResult<ComputerAgentStopResponse>, AppError> {
|
||||
// 获取语言设置
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 使用 garde 进行字段校验
|
||||
let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
let project_id = request.project_id.as_ref().expect("validated: project_id is required and non-empty");
|
||||
|
||||
// 1. 验证参数:user_id 或 pod_id 至少有一个
|
||||
let has_user_id = request.user_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
let has_pod_id = request.pod_id.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false);
|
||||
if !has_user_id && !has_pod_id {
|
||||
error!("[COMPUTER_STOP] user_id or pod_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
let user_id = request.user_id.clone();
|
||||
let pod_id = request.pod_id.clone();
|
||||
|
||||
info!(
|
||||
"🛑 [COMPUTER_STOP] Starting to stop Agent: user_id={:?}, pod_id={:?}, project_id={}, session_id={:?}",
|
||||
user_id, pod_id, project_id, request.session_id
|
||||
);
|
||||
|
||||
// 2. 查找容器(根据 user_id 或 pod_id)
|
||||
let container_info = if has_user_id {
|
||||
crate::service::ComputerContainerManager::get_container_info(user_id.as_ref().unwrap()).await?
|
||||
} else {
|
||||
// TODO: 实现通过 pod_id 查找容器的逻辑
|
||||
warn!("[COMPUTER_STOP] pod_id lookup not fully implemented yet");
|
||||
None
|
||||
};
|
||||
|
||||
let container_info = match container_info {
|
||||
Some(info) => info,
|
||||
None => {
|
||||
warn!("[COMPUTER_STOP] Container not found: user_id={:?}, pod_id={:?}", user_id, pod_id);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_CONTAINER_NOT_FOUND,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"📦 [COMPUTER_STOP] Container found: container_id={}, ip={}",
|
||||
container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
// 3. 通过 gRPC 调用 StopAgent RPC
|
||||
info!(
|
||||
"🔄 [COMPUTER_STOP] Preparing to call StopAgent RPC: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 提取 gRPC 地址
|
||||
let grpc_addr = extract_grpc_addr(&container_info.service_url)?;
|
||||
info!("[COMPUTER_STOP] gRPC addr: {}", grpc_addr);
|
||||
|
||||
// 调用 StopAgent RPC
|
||||
match crate::grpc::grpc_stop_agent_with_pool(
|
||||
&state.grpc_pool,
|
||||
&grpc_addr,
|
||||
project_id.to_string(),
|
||||
request
|
||||
.session_id
|
||||
.clone()
|
||||
.or_else(|| Some("User requested stop".to_string())),
|
||||
false, // force=false,优雅停止
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
info!(
|
||||
"📥 [COMPUTER_STOP] Received StopAgent response: result={}, success={}",
|
||||
response.result, response.success
|
||||
);
|
||||
|
||||
if response.success {
|
||||
// 🆕 清除 rcoder 端的 session_id(即使成功停止,也清理会话状态)
|
||||
state.clear_session(&project_id);
|
||||
|
||||
let message = format!(
|
||||
"Agent {} stopped successfully, container {} continues running",
|
||||
project_id, container_info.container_id
|
||||
);
|
||||
|
||||
let stop_response = ComputerAgentStopResponse {
|
||||
success: true,
|
||||
message,
|
||||
user_id: user_id.clone(),
|
||||
pod_id: pod_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_STOP] Agent stop completed: user_id={:?}, pod_id={:?}, project_id={}",
|
||||
user_id, pod_id, project_id
|
||||
);
|
||||
return Ok(HttpResult::success(stop_response));
|
||||
} else {
|
||||
// Agent 停止失败或已经停止
|
||||
match response.result.as_str() {
|
||||
"not_found" => {
|
||||
warn!(
|
||||
"[COMPUTER_STOP] Agent not found: project_id={}",
|
||||
project_id
|
||||
);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_AGENT_NOT_FOUND,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
"already_stopped" => {
|
||||
info!(
|
||||
"ℹ️ [COMPUTER_STOP] Agent already in stopped state: project_id={}",
|
||||
project_id
|
||||
);
|
||||
// 🆕 清除 rcoder 端的 session_id(即使 Agent 已停止,也清理会话状态)
|
||||
state.clear_session(&project_id);
|
||||
|
||||
let message =
|
||||
shared_types::get_i18n_message("success.agent_already_stopped", locale);
|
||||
let stop_response = ComputerAgentStopResponse {
|
||||
success: true,
|
||||
message,
|
||||
user_id: user_id.clone(),
|
||||
pod_id: pod_id.clone(),
|
||||
project_id: project_id.to_string(),
|
||||
};
|
||||
return Ok(HttpResult::success(stop_response));
|
||||
}
|
||||
"error" => {
|
||||
let err_msg = response.message.unwrap_or_else(|| "Unknown error".to_string());
|
||||
error!("[COMPUTER_STOP] Agent stoppedfailed: {}", err_msg);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_STOP_FAILED,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
warn!(
|
||||
"[COMPUTER_STOP] not response: {}",
|
||||
response.result
|
||||
);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_UNKNOWN,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"❌ [COMPUTER_STOP] StopAgent RPC call failed: {}, project_id={}",
|
||||
e, project_id
|
||||
);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
992
qiming-rcoder/crates/rcoder/src/handler/computer_chat_handler.rs
Normal file
992
qiming-rcoder/crates/rcoder/src/handler/computer_chat_handler.rs
Normal file
@@ -0,0 +1,992 @@
|
||||
//! Computer Agent Runner 聊天处理器
|
||||
//!
|
||||
//! 处理 Computer Agent Runner 模式的聊天请求。
|
||||
//! 与 RCoder 的 project_id 容器模式不同,ComputerAgentRunner 使用 user_id 作为容器标识。
|
||||
//!
|
||||
//! ## 请求流程
|
||||
//! ```text
|
||||
//! POST /computer/chat { user_id, project_id?, prompt, ... }
|
||||
//! ↓
|
||||
//! 1. 验证 user_id
|
||||
//! 2. 生成 project_id(若未提供)
|
||||
//! 3. get_or_create_container_for_user(user_id)
|
||||
//! - 挂载配置: config.yml mounts (配置化管理)
|
||||
//! - 宿主机: /computer-project-workspace/{user_id} → 容器: /home/user
|
||||
//! 4. 创建项目工作目录: /home/user/{project_id} (通过挂载自动同步)
|
||||
//! 5. 创建/更新项目和会话信息
|
||||
//! 6. gRPC Chat RPC → agent_runner (带 project_id)
|
||||
//! 7. 更新会话映射
|
||||
//! 8. 返回 ChatResponse
|
||||
//! ```
|
||||
//!
|
||||
//! 注意:Resume 会话的降级逻辑已在 agent_runner 层通过 list_sessions API 预检查处理
|
||||
|
||||
use axum::{extract::State, http::HeaderMap};
|
||||
use shared_types::{ChatResponse, ComputerChatRequest, IsolationType};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
|
||||
use crate::{AppError, HttpResult, router::AppState, service::ComputerContainerManager};
|
||||
use docker_manager::ContainerBasicInfo;
|
||||
|
||||
use super::utils::{
|
||||
I18nJsonOrQuery, extract_grpc_addr_with_port, get_locale_from_headers,
|
||||
get_realtime_container_ip, project_dir, build_computer_workspace_path,
|
||||
};
|
||||
|
||||
/// 处理 Computer Agent 聊天请求
|
||||
///
|
||||
/// 1. 根据 user_id 获取或创建用户容器
|
||||
/// 2. 将聊天请求转发到容器内的 agent_runner 服务
|
||||
/// 3. 更新会话映射
|
||||
///
|
||||
/// 注意:
|
||||
/// - user_id 是必填的,用于标识用户的容器
|
||||
/// - project_id 可选,若未提供则自动生成
|
||||
/// - 一个用户容器内可以运行多个 project_id 的 Agent 实例
|
||||
/// - Resume 会话的降级逻辑已在 agent_runner 层通过 list_sessions API 预检查处理
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/computer/chat",
|
||||
request_body(
|
||||
content = ComputerChatRequest,
|
||||
description = "Computer Agent 聊天请求,包含 user_id 和 prompt",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功处理聊天请求",
|
||||
body = HttpResult<ChatResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"project_id": "proj_456",
|
||||
"session_id": "session789",
|
||||
"error": null,
|
||||
"request_id": "req_123456789"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "请求参数错误(如 user_id 为空)",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "handle_computer_chat",
|
||||
summary = "发送聊天消息到 Computer Agent",
|
||||
description = "根据 user_id 动态管理容器,一个用户对应一个带桌面环境的容器"
|
||||
)]
|
||||
#[instrument(skip(state, request), fields(user_id = %request.user_id, project_id = ?request.project_id))]
|
||||
pub async fn handle_computer_chat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nJsonOrQuery(mut request): I18nJsonOrQuery<ComputerChatRequest>,
|
||||
) -> Result<HttpResult<ChatResponse>, AppError> {
|
||||
// 获取语言设置
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
|
||||
// 1. 验证 user_id
|
||||
if request.user_id.trim().is_empty() {
|
||||
error!("[COMPUTER_CHAT] user_id is required");
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
let user_id = request.user_id.clone();
|
||||
|
||||
// ========== 隔离类型参数校验 ==========
|
||||
// IF pod_id IS NOT NULL THEN isolation_type, tenant_id, space_id 必须非空
|
||||
if request.pod_id.is_some() {
|
||||
if request.isolation_type.is_none() {
|
||||
error!("[COMPUTER_CHAT] Validation failed: isolation_type is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"isolation_type is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
if request.tenant_id.is_none() {
|
||||
error!("[COMPUTER_CHAT] Validation failed: tenant_id is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"tenant_id is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
if request.space_id.is_none() {
|
||||
error!("[COMPUTER_CHAT] Validation failed: space_id is required when pod_id is provided");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
"space_id is required when pod_id is provided",
|
||||
));
|
||||
}
|
||||
|
||||
// 验证 isolation_type 值有效(大小写不敏感)
|
||||
if let Some(ref it) = request.isolation_type {
|
||||
if IsolationType::from_str(it).is_err() {
|
||||
error!("[COMPUTER_CHAT] Validation failed: invalid isolation_type '{}', expected tenant|space|project", it);
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
&format!("invalid isolation_type '{}', expected: tenant, space, project", it),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 记录验证通过的参数(此时 pod_id, isolation_type, tenant_id, space_id 必定为 Some)
|
||||
if let (Some(pid), Some(it), Some(tid), Some(sid)) = (
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
) {
|
||||
info!(
|
||||
"🔒 [COMPUTER_CHAT] Isolation parameters validated: pod_id={}, isolation_type={}, tenant_id={}, space_id={}",
|
||||
pid, it, tid, sid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 生成或使用提供的 project_id
|
||||
let project_id = match &request.project_id {
|
||||
Some(id) if !id.trim().is_empty() => id.clone(),
|
||||
_ => {
|
||||
let generated_id = crate::service::container_manager::generate_project_id();
|
||||
request.project_id = Some(generated_id.clone());
|
||||
generated_id
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"🚀 [COMPUTER_CHAT] Starting to process request: user_id={}, project_id={}, session_id={:?}, prompt_len={}, attachments={}, model_provider={:?}, agent_config={:?}",
|
||||
user_id,
|
||||
project_id,
|
||||
request.session_id,
|
||||
request.prompt.len(),
|
||||
request.attachments.len(),
|
||||
request.model_provider,
|
||||
request.agent_config
|
||||
);
|
||||
|
||||
// 3. 验证资源限制配置
|
||||
if let Some(ref agent_config) = request.agent_config {
|
||||
if let Some(ref resource_limits) = agent_config.resource_limits {
|
||||
if let Err(e) = resource_limits.validate() {
|
||||
error!("[COMPUTER_CHAT] Resource limits validation failed: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_INVALID_RESOURCE_LIMITS,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. === 并发保护:检查是否有其他请求正在创建同一用户的容器 ===
|
||||
// 使用原子标记(DashMap)避免并发请求互相干扰,无死锁风险
|
||||
let mut waited_container_info: Option<ContainerBasicInfo> = None;
|
||||
if let Some(creating_since) = state.pod_creating.get(&user_id) {
|
||||
let elapsed = creating_since.elapsed();
|
||||
drop(creating_since); // 释放 DashMap ref
|
||||
|
||||
// 标记超过 60 秒视为过期(创建方可能已崩溃),忽略并继续
|
||||
if elapsed < std::time::Duration::from_secs(60) {
|
||||
info!(
|
||||
"⏳ [COMPUTER_CHAT] Container is being created, waiting for completion: user_id={}, elapsed={:?}",
|
||||
user_id, elapsed
|
||||
);
|
||||
|
||||
// 轮询等待容器就绪(最多等 30 秒,每秒检查一次)
|
||||
for wait_sec in 1..=30 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
|
||||
// 标记已被移除 = 创建完成
|
||||
if !state.pod_creating.contains_key(&user_id) {
|
||||
// 尝试获取容器信息
|
||||
if let Ok(runtime) = docker_manager::runtime::RuntimeManager::get().await
|
||||
{
|
||||
if let Ok(Some(info)) = runtime
|
||||
.get_container_info_by_identifier(
|
||||
&user_id,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
info!(
|
||||
"✅ [COMPUTER_CHAT] Wait successful, container is ready (waited {}s): user_id={}, container_id={}",
|
||||
wait_sec, user_id, info.container_id
|
||||
);
|
||||
waited_container_info = Some(info);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if wait_sec % 5 == 0 {
|
||||
debug!(
|
||||
"[COMPUTER_CHAT] Still waiting for container creation: user_id={}, {}s elapsed",
|
||||
user_id, wait_sec
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if waited_container_info.is_none() {
|
||||
// 等待超时,继续正常的创建流程(此时标记可能已过期被清理)
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Wait for container creation timeout (30s), will try to create: user_id={}",
|
||||
user_id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 标记过期,清理后继续
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Creation marker expired ({:?}), cleaning up and continuing",
|
||||
elapsed
|
||||
);
|
||||
state.pod_creating.remove(&user_id);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 获取或创建用户容器
|
||||
let container_info = if let Some(info) = waited_container_info {
|
||||
// 使用等待获得的容器信息
|
||||
info!(
|
||||
"📦 [COMPUTER_CHAT] Using ready container (waiting for other request to finish creation): user_id={}, container_id={}",
|
||||
user_id, info.container_id
|
||||
);
|
||||
info
|
||||
} else {
|
||||
// 正常创建容器 - 设置标记防止并发
|
||||
state
|
||||
.pod_creating
|
||||
.insert(user_id.clone(), std::time::Instant::now());
|
||||
|
||||
let result = ComputerContainerManager::get_or_create_container_for_user(
|
||||
&user_id,
|
||||
request
|
||||
.agent_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.resource_limits.clone()),
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// 清除标记(无论成功还是失败)
|
||||
state.pod_creating.remove(&user_id);
|
||||
|
||||
match result {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
error!("[COMPUTER_CHAT] Failed to get or create container: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_CONTAINER_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 🛡️ 二次验证:确保容器 IP 非空
|
||||
// 容器管理器应该已经处理了空 IP 的情况,但缓存/Docker API 可能返回不一致结果
|
||||
// 如果 IP 为空,先清理旧容器再强制重建(不返回错误给客户端)
|
||||
let container_info = if container_info.container_ip.trim().is_empty() {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Container has empty IP after get_or_create, cleaning up and recreating: \
|
||||
user_id={}, old_container_id={}",
|
||||
user_id, container_info.container_id
|
||||
);
|
||||
// 必须先清理旧容器,否则 create_container 发现同名 "running" 容器会复用它
|
||||
let container_identifier = request.pod_id.as_deref().unwrap_or(&user_id);
|
||||
if let Ok(runtime) = docker_manager::runtime::RuntimeManager::get().await {
|
||||
if let Err(e) = runtime
|
||||
.stop_container_by_identifier(
|
||||
container_identifier,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Failed to cleanup broken container before recreate: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
ComputerContainerManager::force_create_container_for_user(
|
||||
&user_id,
|
||||
request
|
||||
.agent_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.resource_limits.clone()),
|
||||
request.pod_id.as_deref(),
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_CHAT] Force recreate container failed: {}", e);
|
||||
AppError::with_message(
|
||||
shared_types::error_codes::ERR_CONTAINER_ERROR,
|
||||
format!("Container recreation failed: {}", e),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
container_info
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_CHAT] Container ready: user_id={}, container_id={}, ip={}",
|
||||
user_id, container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
// 🔍 检测 user_id 变化:同一个 project_id 被不同的 user_id 请求
|
||||
// 这通常意味着负载测试脚本使用了多个不同的 user_id,会导致创建多个容器浪费资源
|
||||
if let Some(existing_info) = state.get_project(&project_id) {
|
||||
if let Some(existing_user_id) = existing_info.user_id() {
|
||||
if existing_user_id != user_id {
|
||||
warn!(
|
||||
"⚠️ [USER_ID_MISMATCH] Detected user_id change for project_id: \
|
||||
project_id={}, original user_id={}, new user_id={}, time={}. \
|
||||
This may be caused by load test scripts using different user_ids, \
|
||||
which creates multiple containers and wastes resources. \
|
||||
Please ensure the same project_id uses the same user_id in your test scripts.",
|
||||
project_id,
|
||||
existing_user_id,
|
||||
user_id,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🛡️ 关键修复:容器创建成功后立即插入 DuckDB 记录
|
||||
// 这样可以防止孤立容器清理器误判并清理刚创建的容器
|
||||
//
|
||||
// 必须在 gRPC 请求之前就插入记录,因为:
|
||||
// 1. 孤立容器清理器会检查 DuckDB 中是否存在该 user_id 的记录
|
||||
// 2. 如果记录不存在,容器会被判定为孤立并清理
|
||||
// 3. gRPC 请求是异步的,可能需要较长时间才能返回
|
||||
ensure_project_mapping_in_state(&state, &user_id, &project_id, &container_info, &request)?;
|
||||
|
||||
// 请求到达时立即更新活动时间(不等待请求执行结果)
|
||||
// 这样可以防止在 gRPC 请求期间被 cleanup_task 误清理
|
||||
// 注意:这里使用 project_id 而不是 user_id,因为 DuckDB 的 key 是 project_id
|
||||
state.update_activity(&project_id);
|
||||
debug!(
|
||||
"🔄 [COMPUTER_CHAT] Updated activity time: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
// 5. 创建项目工作目录(在用户容器内)
|
||||
// Computer Agent Runner 需要在用户工作区内为 project_id 创建子目录
|
||||
if let Err(e) = ensure_project_workspace_exists(
|
||||
request.isolation_type.as_deref(),
|
||||
request.tenant_id.as_deref(),
|
||||
request.space_id.as_deref(),
|
||||
&user_id,
|
||||
&project_id,
|
||||
)
|
||||
.await {
|
||||
error!("[COMPUTER_CHAT] Failed to create project workspace: {}", e);
|
||||
return Ok(HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_WORKSPACE_ERROR,
|
||||
locale,
|
||||
));
|
||||
}
|
||||
|
||||
// 6. 注册 VNC 后端到 Pingora(用于 WebSocket 代理)
|
||||
if let Some(ref pingora_service) = state.pingora_service {
|
||||
pingora_service.add_vnc_backend(&user_id, &container_info.container_ip);
|
||||
debug!(
|
||||
"🔗 [COMPUTER_CHAT] VNC backend registered: user_id={} -> {}",
|
||||
user_id, container_info.container_ip
|
||||
);
|
||||
}
|
||||
|
||||
// 6.5. 🆕 主动查询 Agent 状态 (User Request)
|
||||
// 在转发请求前,主动查询 Agent 状态,确保状态是最新的。
|
||||
// 这有助于在容器重启后,确认 Agent 是否真正处于空闲状态。
|
||||
{
|
||||
// 💫 使用实时 IP 获取,避免 restart 后 IP 过期的问题
|
||||
let grpc_addr_result = async {
|
||||
let container_ip = get_realtime_container_ip(
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("IP resolution error: {}", e))?;
|
||||
|
||||
Ok::<_, String>(format!(
|
||||
"{}:{}",
|
||||
container_ip,
|
||||
shared_types::GRPC_DEFAULT_PORT
|
||||
))
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Ok(grpc_addr) = grpc_addr_result {
|
||||
debug!("[COMPUTER_CHAT] Checking Agent status: {}", grpc_addr);
|
||||
if let Ok(mut client) = state.grpc_pool.get_client(&grpc_addr).await {
|
||||
let status_req = shared_types::grpc::GetStatusRequest {
|
||||
project_id: project_id.clone(),
|
||||
session_id: "".to_string(), // 我们只关心 project 级别的状态
|
||||
};
|
||||
|
||||
let mut grpc_request = crate::grpc::new_request_with_locale(status_req, locale);
|
||||
grpc_request.set_timeout(std::time::Duration::from_secs(5));
|
||||
|
||||
match client.get_status(grpc_request).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.into_inner().status;
|
||||
info!(
|
||||
"📊 [COMPUTER_CHAT] Agent current status: project_id={}, status={}",
|
||||
project_id, status
|
||||
);
|
||||
// 如果状态是 idle,我们可以更有信心地继续
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[COMPUTER_CHAT] Failed to get Agent status: {}", e);
|
||||
// Query failed不阻止请求继续,可能是网络波动,让后续的 Chat 请求去处理
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 🆕 自动查找 session_id 逻辑
|
||||
// 如果用户没有传递 session_id,尝试从状态中查找最新的 session_id
|
||||
let session_id_to_use = match &request.session_id {
|
||||
Some(sid) if !sid.is_empty() => {
|
||||
debug!("[COMPUTER_CHAT] Using session_id: {}", sid);
|
||||
sid.clone()
|
||||
}
|
||||
_ => {
|
||||
// 用户没有传递 session_id,尝试查找最新的
|
||||
match state.get_project(&project_id) {
|
||||
Some(project_info) => {
|
||||
let existing_session_id = project_info.session_id();
|
||||
match existing_session_id {
|
||||
Some(sid) if !sid.is_empty() => {
|
||||
info!(
|
||||
"🔄 [COMPUTER_CHAT] No session_id provided, auto using latest session: project_id={}, session_id={}",
|
||||
project_id, sid
|
||||
);
|
||||
sid.to_string()
|
||||
}
|
||||
_ => {
|
||||
debug!(
|
||||
"[COMPUTER_CHAT] Project exists, creating new session"
|
||||
);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
debug!("[COMPUTER_CHAT] No project, creating new session");
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 克隆 request 并修改 session_id
|
||||
let mut request_for_forward = request.clone();
|
||||
request_for_forward.session_id = if session_id_to_use.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(session_id_to_use)
|
||||
};
|
||||
// 🆕 自动查找 session_id 逻辑结束
|
||||
|
||||
// 8. 转发请求到容器服务(使用 gRPC)
|
||||
let result = forward_computer_request_to_container(
|
||||
&request_for_forward, // 使用修改后的 request
|
||||
&project_id,
|
||||
&container_info,
|
||||
&state.grpc_pool,
|
||||
locale,
|
||||
&state.container_prefix_rcoder,
|
||||
&state.container_prefix_computer,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 8. 更新会话映射(填充所有三个映射表,保持一致性)
|
||||
// 无论请求成功还是失败,只要响应中包含 session_id,都要更新映射
|
||||
// 这样用户可以通过 SSE 接口获取错误通知,而不会收到 SESSION_EXPIRED 错误
|
||||
if let Some(chat_response) = &result.data {
|
||||
let session_id = chat_response.session_id.clone();
|
||||
|
||||
// 只有当 session_id 非空时才更新映射
|
||||
if !session_id.is_empty() {
|
||||
info!(
|
||||
"🔗 [COMPUTER_CHAT] Associated session: session_id={} -> user_id={}, project_id={}, success={}",
|
||||
session_id,
|
||||
user_id,
|
||||
project_id,
|
||||
result.is_success()
|
||||
);
|
||||
|
||||
// 从 Runtime API 获取最新容器信息,避免使用过期 IP
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_CHAT] Failed to get runtime: {}", e);
|
||||
AppError::internal_server_error(&format!("Failed to get runtime: {}", e))
|
||||
})?;
|
||||
|
||||
let container_info = match runtime
|
||||
.get_container_info_by_identifier(
|
||||
&user_id,
|
||||
&shared_types::ServiceType::ComputerAgentRunner,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(info)) => {
|
||||
info!(
|
||||
"🔄 [COMPUTER_CHAT] Getting latest container info from Runtime API: user_id={}, container_id={}, container_ip={}",
|
||||
user_id, info.container_id, info.container_ip
|
||||
);
|
||||
info
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Container not found in runtime: user_id={}, using cached container info",
|
||||
user_id
|
||||
);
|
||||
// 使用之前获取的容器信息
|
||||
container_info.clone()
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Failed to get container info from runtime: user_id={}, error={}, using cached container info",
|
||||
user_id, e
|
||||
);
|
||||
// 使用之前获取的容器信息
|
||||
container_info.clone()
|
||||
}
|
||||
};
|
||||
|
||||
// ComputerAgentRunner 模式:每个 project 独立记录
|
||||
// 使用真正的 project_id 作为 map_key,user_id 存储在数据字段中
|
||||
let map_key = project_id.clone();
|
||||
|
||||
// 检查是否已存在该 project_id 的记录
|
||||
if let Some(existing_info) = state.get_project(&map_key) {
|
||||
// 已存在:更新信息
|
||||
let mut updated_info = (*existing_info).clone();
|
||||
|
||||
// 更新活动时间
|
||||
updated_info.update_activity();
|
||||
updated_info.update_session(session_id.clone());
|
||||
|
||||
// 更新扩展信息
|
||||
updated_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(shared_types::ServiceType::ComputerAgentRunner),
|
||||
);
|
||||
|
||||
state.insert_project(map_key.clone(), Arc::new(updated_info));
|
||||
|
||||
// 更新会话映射
|
||||
state.update_session(&map_key, &session_id);
|
||||
|
||||
info!(
|
||||
"🔄 [COMPUTER_CHAT] Updated existing container mapping: user_id={}, project_id={}, session_id={} (last_activity refreshed)",
|
||||
user_id, project_id, session_id
|
||||
);
|
||||
} else {
|
||||
// 不存在:创建新的 ProjectAndContainerInfo
|
||||
let mut project_info = shared_types::ProjectAndContainerInfo::new(map_key.clone());
|
||||
|
||||
// 设置 user_id(ComputerAgentRunner 模式)
|
||||
project_info.set_user_id(Some(user_id.clone()));
|
||||
// 设置 pod_id(共享容器模式)
|
||||
project_info.set_pod_id(request.pod_id.clone());
|
||||
|
||||
// 更新会话ID
|
||||
project_info.update_session(session_id.clone());
|
||||
|
||||
// 更新扩展信息(容器、模型配置等)
|
||||
project_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(shared_types::ServiceType::ComputerAgentRunner),
|
||||
);
|
||||
|
||||
state.insert_project(map_key.clone(), Arc::new(project_info));
|
||||
|
||||
// 更新会话映射
|
||||
state.update_session(&map_key, &session_id);
|
||||
|
||||
info!(
|
||||
"🆕 [COMPUTER_CHAT] Created new container mapping: user_id={}, project_id={}, session_id={}",
|
||||
user_id, project_id, session_id
|
||||
);
|
||||
}
|
||||
|
||||
if result.is_success() {
|
||||
info!(
|
||||
"✅ [COMPUTER_CHAT] Request processed: user_id={}, project_id={}, session_id={} (all mappings updated)",
|
||||
user_id, project_id, session_id
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CHAT] Request failed but session mapping saved: user_id={}, project_id={}, session_id={}, code={}, message={}",
|
||||
user_id, project_id, session_id, result.code, result.message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !result.is_success()
|
||||
&& result
|
||||
.data
|
||||
.as_ref()
|
||||
.map_or(true, |d| d.session_id.is_empty())
|
||||
{
|
||||
error!(
|
||||
"❌ [COMPUTER_CHAT] Container service returned error (no session_id): user_id={}, project_id={}, code={}, message={}",
|
||||
user_id, project_id, result.code, result.message
|
||||
);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 转发请求到容器内的 agent_runner 服务(仅使用 gRPC)
|
||||
///
|
||||
/// 与 RCoder 的 forward_request_to_container_service 类似,
|
||||
/// 但专门用于 ComputerAgentRunner 模式。
|
||||
async fn forward_computer_request_to_container(
|
||||
request: &ComputerChatRequest,
|
||||
project_id: &str,
|
||||
container_info: &ContainerBasicInfo,
|
||||
grpc_pool: &Arc<crate::grpc::GrpcChannelPool>,
|
||||
locale: &'static str,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
) -> HttpResult<ChatResponse> {
|
||||
info!(
|
||||
"📤 [COMPUTER_FORWARD] Forwarding request to container (gRPC): user_id={}, project_id={}, session_id={:?}, container_id={}",
|
||||
request.user_id, project_id, request.session_id, container_info.container_id
|
||||
);
|
||||
|
||||
// 直接使用 gRPC 的健康检查机制,不额外检查容器状态
|
||||
// gRPC 连接失败会自动返回错误,由上层处理
|
||||
|
||||
// 从 service_url 提取 gRPC 地址
|
||||
// 🆕 使用实时 IP 获取,避免 restart 后 IP 过期的问题
|
||||
let mut grpc_addr = match get_realtime_container_ip(
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
rcoder_prefix,
|
||||
computer_prefix,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_FORWARD] Real-time IP resolution failed: {}, trying to extract from service_url",
|
||||
e
|
||||
);
|
||||
match extract_grpc_addr_with_port(
|
||||
&container_info.service_url,
|
||||
shared_types::GRPC_DEFAULT_PORT,
|
||||
) {
|
||||
Ok(addr) => addr,
|
||||
Err(e) => {
|
||||
error!("[COMPUTER_FORWARD] Failed to extract gRPC address: {}", e);
|
||||
return HttpResult::error_with_locale(
|
||||
shared_types::error_codes::ERR_GRPC_ADDR_ERROR,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
debug!(
|
||||
"📡 [COMPUTER_FORWARD] gRPC address: {}, prompt_len={}, attachments={}",
|
||||
grpc_addr,
|
||||
request.prompt.len(),
|
||||
request.attachments.len()
|
||||
);
|
||||
|
||||
// Computer Agent Runner 的工作目录路径
|
||||
// 在容器内:/app/computer-project-workspace/{user_id}/{project_id}
|
||||
let project_workspace = format!("{}/", project_dir(&request.user_id, &project_id));
|
||||
|
||||
debug!(
|
||||
"[COMPUTER_FORWARD] projectworkdirectory: {}",
|
||||
project_workspace
|
||||
);
|
||||
|
||||
// gRPC 调用(带重试机制)
|
||||
let max_retries = 2;
|
||||
let mut last_error = None;
|
||||
|
||||
for attempt in 1..=max_retries {
|
||||
match crate::grpc::grpc_chat_with_pool(
|
||||
grpc_pool,
|
||||
&grpc_addr,
|
||||
project_id.to_string(),
|
||||
request.session_id.clone(),
|
||||
request.prompt.clone(),
|
||||
request.attachments.clone(),
|
||||
request.data_source_attachments.clone(),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(std::time::Duration::from_secs(300)), // 5 分钟超时,避免永久阻塞
|
||||
request.system_prompt.clone(),
|
||||
request.user_prompt.clone(),
|
||||
request.agent_config.clone(),
|
||||
Some(shared_types::ServiceType::ComputerAgentRunner), // ✅ 传递正确的 ServiceType
|
||||
Some(request.user_id.clone()), // ✅ 传递 user_id(ComputerAgentRunner 必需)
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(grpc_response) => {
|
||||
if grpc_response.success {
|
||||
let chat_response = crate::grpc::grpc_response_to_chat_response(grpc_response);
|
||||
info!(
|
||||
"✅ [COMPUTER_FORWARD] gRPC response success: project_id={}, session_id={}",
|
||||
chat_response.project_id, chat_response.session_id
|
||||
);
|
||||
return HttpResult::success(chat_response);
|
||||
} else {
|
||||
let error_msg = grpc_response
|
||||
.error
|
||||
.unwrap_or_else(|| "Unknown error".to_string());
|
||||
// 🎯 从 gRPC 响应中提取错误码(完整透传)
|
||||
let error_code = grpc_response
|
||||
.error_code
|
||||
.unwrap_or_else(|| shared_types::error_codes::ERR_AGENT_ERROR.to_string());
|
||||
error!(
|
||||
"❌ [COMPUTER_FORWARD] gRPC response error: code={}, message={}",
|
||||
error_code, error_msg
|
||||
);
|
||||
return HttpResult::error(&error_code, &error_msg);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_FORWARD] gRPC call failed (attempt {}/{}): {}",
|
||||
attempt, max_retries, e
|
||||
);
|
||||
|
||||
let should_retry = crate::grpc::should_retry_error(&e);
|
||||
|
||||
if should_retry && attempt < max_retries {
|
||||
info!(
|
||||
"🔄 [COMPUTER_FORWARD] Detected retryable error, re-resolving container IP and retrying..."
|
||||
);
|
||||
grpc_pool.remove(&grpc_addr);
|
||||
|
||||
// 重新获取最新容器 IP(容器可能已重建,IP 可能变化)
|
||||
match get_realtime_container_ip(
|
||||
&container_info.container_name,
|
||||
&container_info.container_ip,
|
||||
rcoder_prefix,
|
||||
computer_prefix,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ip) => {
|
||||
let new_addr = format!("{}:{}", ip, shared_types::GRPC_DEFAULT_PORT);
|
||||
info!(
|
||||
"🔄 [COMPUTER_FORWARD] Container IP re-resolved: {} -> {}",
|
||||
grpc_addr, new_addr
|
||||
);
|
||||
grpc_addr = new_addr;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_FORWARD] Failed to re-resolve container IP, keeping old address: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
last_error = Some(e);
|
||||
continue;
|
||||
} else if !should_retry {
|
||||
error!(
|
||||
"[COMPUTER_FORWARD] Retry error, stopped retry: {}",
|
||||
e
|
||||
);
|
||||
last_error = Some(e);
|
||||
break;
|
||||
}
|
||||
|
||||
last_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
if let Some(e) = last_error {
|
||||
error!(
|
||||
"❌ [COMPUTER_FORWARD] gRPC final call failed: {}, user_id={}, project_id={}",
|
||||
e, request.user_id, project_id
|
||||
);
|
||||
|
||||
// gRPC 通信失败,直接返回错误
|
||||
// 注:业务错误码(如 Agent busy)现在由 agent_runner 通过 grpc_response.error_code 返回
|
||||
HttpResult::error_with_locale(shared_types::error_codes::ERR_GRPC_ERROR, locale)
|
||||
} else {
|
||||
HttpResult::error_with_locale(shared_types::error_codes::ERR_UNKNOWN, locale)
|
||||
}
|
||||
}
|
||||
|
||||
/// 确保 project_id 对应的工作目录存在
|
||||
///
|
||||
/// Computer Agent Runner 的目录结构:
|
||||
/// /app/computer-project-workspace/{user_id}/{project_id}/
|
||||
///
|
||||
/// 注意:这个目录已经在 docker-compose.yml 中挂载,可以直接在 rcoder 容器内创建
|
||||
///
|
||||
/// # 参数
|
||||
/// - `isolation_type`: 隔离类型(可选)
|
||||
/// - `tenant_id`: 租户 ID(可选)
|
||||
/// - `space_id`: 空间 ID(可选)
|
||||
/// - `user_id`: 用户 ID(当 isolation_type 为 project 时使用)
|
||||
/// - `project_id`: 项目 ID
|
||||
async fn ensure_project_workspace_exists(
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
// 根据隔离类型构建工作空间路径
|
||||
let project_workspace_path = std::path::PathBuf::from(build_computer_workspace_path(
|
||||
isolation_type,
|
||||
tenant_id,
|
||||
space_id,
|
||||
user_id,
|
||||
project_id,
|
||||
));
|
||||
|
||||
debug!(
|
||||
"📁 [COMPUTER_CHAT] Ensuring project workspace directory exists: {:?}",
|
||||
project_workspace_path
|
||||
);
|
||||
|
||||
// 直接在 rcoder 容器内创建目录
|
||||
tokio::fs::create_dir_all(&project_workspace_path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"❌ [COMPUTER_CHAT] Failed to create project workspace: path={:?}, error={}",
|
||||
project_workspace_path, e
|
||||
);
|
||||
AppError::internal_server_error(&format!("Failed to create project workspace: {}", e))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"✅ [COMPUTER_CHAT] Project workspace directory created: user_id={}, project_id={}, isolation_type={:?}, path={:?}",
|
||||
user_id, project_id, isolation_type, project_workspace_path
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 确保 DuckDB 中存在 project_id 到容器的映射
|
||||
///
|
||||
/// 🛡️ 关键修复:容器创建成功后立即插入 DuckDB 记录
|
||||
///
|
||||
/// 这样可以防止孤立容器清理器误判并清理刚创建的容器,因为:
|
||||
/// 1. 孤立容器清理器会检查 DuckDB 中是否存在该 user_id 关联的记录
|
||||
/// 2. 如果记录不存在,容器会被判定为孤立并清理
|
||||
/// 3. gRPC 请求是异步的,可能需要较长时间才能返回
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `state` - 应用状态
|
||||
/// * `user_id` - 用户 ID
|
||||
/// * `project_id` - 项目 ID
|
||||
/// * `container_info` - 容器信息
|
||||
/// * `request` - 聊天请求
|
||||
fn ensure_project_mapping_in_state(
|
||||
state: &Arc<crate::router::AppState>,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
container_info: &ContainerBasicInfo,
|
||||
request: &ComputerChatRequest,
|
||||
) -> Result<(), AppError> {
|
||||
// 检查是否已存在该 project_id 的记录
|
||||
if let Some(existing_project) = state.get_project(project_id) {
|
||||
// 如果记录存在,检查容器ID是否变更
|
||||
if let Some(existing_container) = existing_project.container() {
|
||||
if existing_container.container_id != container_info.container_id {
|
||||
info!(
|
||||
"🔄 [COMPUTER_CHAT] Detected container change: project_id={}, old_cid={}, new_cid={}",
|
||||
project_id, existing_container.container_id, container_info.container_id
|
||||
);
|
||||
// 容器变更,继续执行后续的插入/更新逻辑(insert_project 会执行 upsert)
|
||||
} else {
|
||||
debug!(
|
||||
"🔄 [COMPUTER_CHAT] DuckDB record already exists and container unchanged: project_id={}",
|
||||
project_id
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
// 现有记录没有容器信息,继续更新
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的 ProjectAndContainerInfo
|
||||
let mut project_info = shared_types::ProjectAndContainerInfo::new(project_id.to_string());
|
||||
|
||||
// 设置 user_id(ComputerAgentRunner 模式)
|
||||
project_info.set_user_id(Some(user_id.to_string()));
|
||||
// 设置 pod_id(共享容器模式)
|
||||
project_info.set_pod_id(request.pod_id.clone());
|
||||
|
||||
// 更新容器信息
|
||||
project_info.update_extended_from_request(
|
||||
Some(container_info.clone()),
|
||||
request.model_provider.clone(),
|
||||
request.request_id.clone(),
|
||||
Some(shared_types::ServiceType::ComputerAgentRunner),
|
||||
);
|
||||
|
||||
// 立即插入到 DuckDB
|
||||
state.insert_project(project_id.to_string(), Arc::new(project_info));
|
||||
|
||||
info!(
|
||||
"🆕 [COMPUTER_CHAT] Inserted DuckDB record (immediately after container creation): user_id={}, project_id={}, container_id={}",
|
||||
user_id, project_id, container_info.container_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSE 进度流处理器(复用现有的 agent_session_notification)
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,752 @@
|
||||
//! Computer Agent Runner VNC 桌面处理器
|
||||
//!
|
||||
//! 提供 VNC 桌面访问功能,允许用户通过 WebSocket 连接到容器内的 noVNC 服务。
|
||||
//!
|
||||
//! ## 端口说明
|
||||
//! - noVNC WebSocket: 6080 (容器内)
|
||||
//!
|
||||
//! ## 实现状态
|
||||
//!
|
||||
//! **当前版本已实现 Pingora WebSocket 透明代理。**
|
||||
//!
|
||||
//! 客户端应使用 Pingora 代理路径访问 VNC:
|
||||
//! - VNC 页面: `http://{proxy_host}/computer/vnc/{user_id}/{project_id}/vnc.html`
|
||||
//! - WebSocket: `ws://{proxy_host}/computer/vnc/{user_id}/{project_id}/websockify`
|
||||
//!
|
||||
//! Pingora 会自动将请求透明代理到对应用户容器的 noVNC 服务(端口 6080)。
|
||||
//!
|
||||
//! ## 安全说明
|
||||
//! - 生产环境中,客户端只通过代理地址访问,不直接暴露容器内部 IP
|
||||
//! - user_id 到 container_ip 的映射在 Pingora 内部管理
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info, instrument, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use super::utils::I18nPath;
|
||||
use super::utils::get_locale_from_headers;
|
||||
use crate::{AppError, HttpResult, router::AppState, service::ComputerContainerManager};
|
||||
|
||||
/// VNC 桌面路径参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct DesktopPathParams {
|
||||
/// 用户 ID
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
#[schema(example = "proj_456")]
|
||||
pub project_id: String,
|
||||
}
|
||||
|
||||
/// VNC 桌面访问响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DesktopAccessResponse {
|
||||
/// 操作是否成功
|
||||
pub success: bool,
|
||||
|
||||
/// Pingora 代理的 VNC 访问 URL(推荐使用)
|
||||
#[schema(example = "/computer/vnc/user_123/proj_456/vnc.html")]
|
||||
pub proxy_vnc_url: String,
|
||||
|
||||
/// Pingora 代理的 WebSocket 连接 URL(推荐使用)
|
||||
#[schema(example = "/computer/vnc/user_123/proj_456/websockify")]
|
||||
pub proxy_websocket_url: String,
|
||||
|
||||
/// 直接访问的 noVNC URL(仅开发/测试使用)
|
||||
#[schema(example = "http://172.17.0.5:6080/vnc.html")]
|
||||
pub direct_vnc_url: String,
|
||||
|
||||
/// 直接访问的 WebSocket URL(仅开发/测试使用)
|
||||
#[schema(example = "ws://172.17.0.5:6080/websockify")]
|
||||
pub direct_websocket_url: String,
|
||||
|
||||
/// 容器 ID
|
||||
pub container_id: String,
|
||||
|
||||
/// 容器 IP 地址(内部 IP,不应直接暴露给外部客户端)
|
||||
pub container_ip: String,
|
||||
|
||||
/// 用户 ID
|
||||
pub user_id: String,
|
||||
|
||||
/// 项目 ID
|
||||
pub project_id: String,
|
||||
|
||||
/// 访问提示
|
||||
#[schema(example = "请使用 proxy_vnc_url 或 proxy_websocket_url 访问 VNC 桌面")]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 错误响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DesktopErrorResponse {
|
||||
/// 错误代码
|
||||
pub error: String,
|
||||
/// 错误消息
|
||||
pub message: String,
|
||||
/// 用户 ID
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
pub project_id: String,
|
||||
}
|
||||
|
||||
/// noVNC 默认端口
|
||||
const NOVNC_PORT: u16 = 6080;
|
||||
|
||||
/// 获取 VNC 桌面访问信息
|
||||
///
|
||||
/// 返回 VNC 桌面的访问 URL。
|
||||
/// 推荐使用 Pingora 代理路径(proxy_vnc_url / proxy_websocket_url)访问,
|
||||
/// 这样可以避免暴露容器内部 IP。
|
||||
///
|
||||
/// ## 访问方式
|
||||
/// - **推荐**: 使用 `proxy_vnc_url` 通过 Pingora 代理访问
|
||||
/// - **开发测试**: 可以使用 `direct_vnc_url` 直接访问(需要网络互通)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/computer/desktop/{user_id}/{project_id}",
|
||||
params(
|
||||
("user_id" = String, Path, description = "用户 ID"),
|
||||
("project_id" = String, Path, description = "项目 ID")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功获取 VNC 访问信息",
|
||||
body = HttpResult<DesktopAccessResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"success": true,
|
||||
"proxy_vnc_url": "/computer/vnc/user_123/proj_456/vnc.html",
|
||||
"proxy_websocket_url": "/computer/vnc/user_123/proj_456/websockify",
|
||||
"direct_vnc_url": "http://172.17.0.5:6080/vnc.html",
|
||||
"direct_websocket_url": "ws://172.17.0.5:6080/websockify",
|
||||
"container_id": "abc123def456",
|
||||
"container_ip": "172.17.0.5",
|
||||
"user_id": "user_123",
|
||||
"project_id": "proj_456",
|
||||
"message": "请使用 proxy_vnc_url 访问 VNC 桌面"
|
||||
},
|
||||
"error": null
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 401,
|
||||
description = "API Key 鉴权失败",
|
||||
body = HttpResult<String>
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到用户容器",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
),
|
||||
(
|
||||
status = 500,
|
||||
description = "服务器内部错误",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_desktop_vnc",
|
||||
summary = "获取 VNC 桌面访问信息",
|
||||
description = "返回 VNC 桌面的访问 URL,推荐使用 Pingora 代理路径访问"
|
||||
)]
|
||||
#[instrument(skip(_state), fields(user_id = %params.user_id, project_id = %params.project_id))]
|
||||
pub async fn computer_desktop_vnc(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
I18nPath(params): I18nPath<DesktopPathParams>,
|
||||
) -> Result<HttpResult<DesktopAccessResponse>, AppError> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let user_id = params.user_id.clone();
|
||||
let project_id = params.project_id.clone();
|
||||
|
||||
// 1. 验证参数
|
||||
if user_id.trim().is_empty() {
|
||||
error!("[DESKTOP_VNC] user_id is required");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
&shared_types::get_i18n_message("error.user_id_required", locale),
|
||||
));
|
||||
}
|
||||
|
||||
if project_id.trim().is_empty() {
|
||||
error!("[DESKTOP_VNC] project_id is required");
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
locale,
|
||||
&shared_types::get_i18n_message("error.project_id_required", locale),
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
"🖥️ [DESKTOP_VNC] Getting VNC access info: user_id={}, project_id={}",
|
||||
user_id, project_id
|
||||
);
|
||||
|
||||
// 2. 查找用户容器
|
||||
let container_info = ComputerContainerManager::get_container_info(&user_id).await?;
|
||||
|
||||
let container_info = match container_info {
|
||||
Some(info) => info,
|
||||
None => {
|
||||
warn!("[DESKTOP_VNC] Container not found: user_id={}", user_id);
|
||||
return Ok(HttpResult::error_with_message(
|
||||
shared_types::error_codes::ERR_CONTAINER_NOT_FOUND,
|
||||
locale,
|
||||
&shared_types::get_i18n_message("error.container_not_found", locale),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
info!(
|
||||
"📦 [DESKTOP_VNC] Found container: container_id={}, ip={}",
|
||||
container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
// 3. 构建 VNC 访问 URL
|
||||
let container_ip = &container_info.container_ip;
|
||||
|
||||
// Pingora 代理路径(推荐使用)
|
||||
let proxy_vnc_url = format!("/computer/vnc/{}/{}/vnc.html", user_id, project_id);
|
||||
let proxy_websocket_url = format!("/computer/vnc/{}/{}/websockify", user_id, project_id);
|
||||
|
||||
// 直接访问路径(仅开发测试使用)
|
||||
let direct_vnc_url = format!("http://{}:{}/vnc.html", container_ip, NOVNC_PORT);
|
||||
let direct_websocket_url = format!("ws://{}:{}/websockify", container_ip, NOVNC_PORT);
|
||||
|
||||
// 4. 返回访问信息
|
||||
let response = DesktopAccessResponse {
|
||||
success: true,
|
||||
proxy_vnc_url: proxy_vnc_url.clone(),
|
||||
proxy_websocket_url: proxy_websocket_url.clone(),
|
||||
direct_vnc_url,
|
||||
direct_websocket_url,
|
||||
container_id: container_info.container_id.clone(),
|
||||
container_ip: container_ip.clone(),
|
||||
user_id: user_id.clone(),
|
||||
project_id: project_id.clone(),
|
||||
message: "请使用 proxy_vnc_url 或 proxy_websocket_url 通过 Pingora 代理访问 VNC 桌面"
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
info!(
|
||||
"✅ [DESKTOP_VNC] VNC access info generated: user_id={}, proxy_vnc_url={}",
|
||||
user_id, proxy_vnc_url
|
||||
);
|
||||
|
||||
Ok(HttpResult::success(response))
|
||||
}
|
||||
|
||||
/// VNC 桌面代理路径参数(用于 Pingora 代理)
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct VncProxyPathParams {
|
||||
/// 用户 ID
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
#[schema(example = "proj_456")]
|
||||
pub project_id: String,
|
||||
/// 剩余路径(可选)
|
||||
#[schema(example = "vnc.html", nullable = true)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// VNC 桌面代理路径(通过 Pingora 代理)
|
||||
///
|
||||
/// 这是一个占位实现,用于生成 OpenAPI 文档。
|
||||
/// 实际的 VNC 代理请求会通过 Pingora 透明代理到容器的 noVNC 服务。
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - `GET /computer/vnc/{user_id}/{project_id}/vnc.html` - VNC 桌面页面
|
||||
/// - `GET /computer/vnc/{user_id}/{project_id}/websockify` - VNC WebSocket 连接
|
||||
/// - `GET /computer/vnc/{user_id}/{project_id}/{*path}` - 其他 noVNC 资源
|
||||
///
|
||||
/// ## 实现说明
|
||||
/// 完整的 WebSocket 代理需要:
|
||||
/// 1. HTTP Upgrade 处理
|
||||
/// 2. 双向 WebSocket 帧转发
|
||||
/// 3. 连接生命周期管理
|
||||
///
|
||||
/// 当前实现使用 Pingora 透明代理,客户端请求会直接代理到容器内部服务。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/computer/vnc/{user_id}/{project_id}/{*path}",
|
||||
params(
|
||||
("user_id" = String, Path, description = "用户 ID"),
|
||||
("project_id" = String, Path, description = "项目 ID"),
|
||||
("path" = Option<String>, Path, description = "剩余路径,如 vnc.html, websockify 等")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功访问 VNC 资源",
|
||||
body = String,
|
||||
example = "<!DOCTYPE html>\\n<html>\\n<head><title>noVNC</title></head>\\n<body>noVNC Client</body>\\n</html>"
|
||||
),
|
||||
(
|
||||
status = 101,
|
||||
description = "WebSocket 升级响应",
|
||||
body = String
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到用户容器或资源不存在",
|
||||
body = HttpResult<DesktopErrorResponse>,
|
||||
example = json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"code": "PROXY_REDIRECT",
|
||||
"message": "请使用 Pingora 代理路径访问 VNC 桌面,路径: /computer/vnc/user_123/proj_456/vnc.html",
|
||||
"tid": null
|
||||
})
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_vnc_proxy",
|
||||
summary = "VNC 桌面代理",
|
||||
description = r#"
|
||||
通过 Pingora 代理访问容器的 VNC 桌面服务。
|
||||
|
||||
## 访问方式
|
||||
|
||||
### VNC 桌面页面
|
||||
```
|
||||
GET /computer/vnc/{user_id}/{project_id}/vnc.html
|
||||
```
|
||||
|
||||
### WebSocket 连接
|
||||
```
|
||||
GET /computer/vnc/{user_id}/{project_id}/websockify
|
||||
```
|
||||
|
||||
### 其他资源
|
||||
```
|
||||
GET /computer/vnc/{user_id}/{project_id}/{*path}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. 客户端请求到达 RCoder 服务
|
||||
2. Axum 路由器匹配到 VNC 代理路径
|
||||
3. 请求转发给 Pingora 代理服务
|
||||
4. Pingora 根据 user_id 查找容器 IP
|
||||
5. Pingora 透明代理请求到容器的 noVNC 服务(端口 6080)
|
||||
6. 响应返回给客户端
|
||||
|
||||
## 使用示例
|
||||
|
||||
```javascript
|
||||
// 访问 VNC 桌面页面
|
||||
window.open('/computer/vnc/user_123/proj_456/vnc.html', '_blank');
|
||||
|
||||
// 或在 iframe 中嵌入
|
||||
<iframe src="/computer/vnc/user_123/proj_456/vnc.html" width="100%" height="600"></iframe>
|
||||
```
|
||||
"#
|
||||
)]
|
||||
#[allow(dead_code)]
|
||||
pub async fn computer_desktop_proxy(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
I18nPath((user_id, project_id, path)): I18nPath<(String, String, Option<String>)>,
|
||||
) -> impl IntoResponse {
|
||||
// 占位实现:实际代理由 Pingora 处理
|
||||
// 这里返回 501 是为了表明这个端点应该由 Pingora 代理
|
||||
|
||||
let error_response = DesktopErrorResponse {
|
||||
error: "PROXY_REDIRECT".to_string(),
|
||||
message: format!(
|
||||
"请使用 Pingora 代理路径访问 VNC 桌面,路径: /computer/vnc/{}/{}/{}",
|
||||
user_id,
|
||||
project_id,
|
||||
path.as_deref().unwrap_or("vnc.html")
|
||||
),
|
||||
user_id,
|
||||
project_id,
|
||||
};
|
||||
|
||||
(StatusCode::NOT_IMPLEMENTED, Json(error_response))
|
||||
}
|
||||
|
||||
/// 音频代理路径参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct AudioProxyPathParams {
|
||||
/// 用户 ID
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
#[schema(example = "proj_456")]
|
||||
pub project_id: String,
|
||||
/// 剩余路径
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - 空字符串或 `index.html`: 音频播放器页面(HTTP 6090)
|
||||
/// - `ws`: 音频流 WebSocket(端口 6089)
|
||||
/// - `ws/{token}`: 带认证的音频流 WebSocket
|
||||
#[schema(example = "index.html", nullable = true)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// 音频流代理(通过 Pingora 代理)
|
||||
///
|
||||
/// 这是一个占位实现,用于生成 OpenAPI 文档。
|
||||
/// 实际的音频代理请求会通过 Pingora 透明代理到容器的音频服务。
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - `GET /computer/audio/{user_id}/{project_id}/` - 音频播放器页面
|
||||
/// - `GET /computer/audio/{user_id}/{project_id}/index.html` - 音频播放器页面
|
||||
/// - `GET /computer/audio/{user_id}/{project_id}/ws` - 音频流 WebSocket(Opus 编码)
|
||||
///
|
||||
/// ## 端口说明
|
||||
/// - **HTTP 6090**: 音频播放器页面和静态文件服务
|
||||
/// - **WebSocket 6089**: Opus 音频流(48kHz 双声道)
|
||||
///
|
||||
/// ## 工作原理
|
||||
/// 1. 客户端请求到达 RCoder 服务
|
||||
/// 2. Pingora 根据 user_id 查找容器 IP
|
||||
/// 3. 路径判断:`ws` 或 `ws/*` → WebSocket 端口 6089,其他 → HTTP 端口 6090
|
||||
/// 4. Pingora 透明代理请求到容器的音频服务
|
||||
/// 5. 音频流使用 WebSocket 传输 Opus 编码的音频数据
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/computer/audio/{user_id}/{project_id}/{*path}",
|
||||
params(
|
||||
("user_id" = String, Path, description = "用户 ID"),
|
||||
("project_id" = String, Path, description = "项目 ID"),
|
||||
("path" = Option<String>, Path, description = "剩余路径")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "成功访问音频播放器页面",
|
||||
body = String,
|
||||
content_type = "text/html",
|
||||
example = "<!DOCTYPE html>\\n<html>\\n<head><title>Audio Player</title></head>\\n<body>...</body>\\n</html>"
|
||||
),
|
||||
(
|
||||
status = 101,
|
||||
description = "WebSocket 升级响应(音频流)",
|
||||
body = String
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到用户容器或资源不存在",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
),
|
||||
(
|
||||
status = 503,
|
||||
description = "代理服务未启用",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_audio_proxy",
|
||||
summary = "音频流代理",
|
||||
description = r#"
|
||||
通过 Pingora 代理访问容器的音频流服务。
|
||||
|
||||
## 访问方式
|
||||
|
||||
### 音频播放器页面
|
||||
```
|
||||
GET /computer/audio/{user_id}/{project_id}/
|
||||
GET /computer/audio/{user_id}/{project_id}/index.html
|
||||
```
|
||||
|
||||
### 音频流 WebSocket
|
||||
```
|
||||
WebSocket /computer/audio/{user_id}/{project_id}/ws
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. 客户端请求到达 RCoder 服务
|
||||
2. Axum 路由器匹配到音频代理路径
|
||||
3. 请求转发给 Pingora 代理服务
|
||||
4. Pingora 根据 user_id 查找容器 IP
|
||||
5. **路径判断**:
|
||||
- `path == "ws"` 或 `path.starts_with("ws/")` → WebSocket 端口 6089
|
||||
- 其他(包括空路径)→ HTTP 端口 6090
|
||||
6. Pingora 透明代理请求到容器的音频服务
|
||||
7. 响应返回给客户端
|
||||
|
||||
## 音频编码格式
|
||||
|
||||
- **编码**: Opus
|
||||
- **采样率**: 48kHz
|
||||
- **声道**: 双声道(Stereo)
|
||||
- **传输**: WebSocket 二进制帧
|
||||
|
||||
## 使用示例
|
||||
|
||||
```javascript
|
||||
// 访问音频播放器页面
|
||||
window.open('/computer/audio/user_123/proj_456/', '_blank');
|
||||
|
||||
// 连接音频流 WebSocket
|
||||
const ws = new WebSocket('ws://localhost:8088/computer/audio/user_123/proj_456/ws');
|
||||
ws.binaryType = 'arraybuffer';
|
||||
ws.onmessage = (event) => {
|
||||
const opusData = new Uint8Array(event.data);
|
||||
// 解码 Opus → PCM → 播放
|
||||
};
|
||||
```
|
||||
"#
|
||||
)]
|
||||
#[allow(dead_code)]
|
||||
pub async fn computer_audio_proxy(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
I18nPath((user_id, project_id, path)): I18nPath<(String, String, Option<String>)>,
|
||||
) -> impl IntoResponse {
|
||||
let error_response = DesktopErrorResponse {
|
||||
error: "PROXY_REDIRECT".to_string(),
|
||||
message: format!(
|
||||
"请使用 Pingora 代理路径访问音频服务,路径: /computer/audio/{}/{}/{}",
|
||||
user_id,
|
||||
project_id,
|
||||
path.as_deref().unwrap_or("")
|
||||
),
|
||||
user_id,
|
||||
project_id,
|
||||
};
|
||||
|
||||
(StatusCode::NOT_IMPLEMENTED, Json(error_response))
|
||||
}
|
||||
|
||||
/// IME 输入法代理路径参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct ImeProxyPathParams {
|
||||
/// 用户 ID
|
||||
#[schema(example = "user_123")]
|
||||
pub user_id: String,
|
||||
/// 项目 ID
|
||||
#[schema(example = "proj_456")]
|
||||
pub project_id: String,
|
||||
/// 剩余路径
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - `connect`: IME WebSocket 连接端点(端口 6091)
|
||||
/// - 其他值会被转发到 IME 服务
|
||||
#[schema(example = "connect", nullable = true)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// IME 输入法代理(通过 Pingora 代理)
|
||||
///
|
||||
/// 这是一个占位实现,用于生成 OpenAPI 文档。
|
||||
/// 实际的 IME 代理请求会通过 Pingora 透明代理到容器的 IME 服务。
|
||||
///
|
||||
/// ## 路径说明
|
||||
/// - `WebSocket /computer/ime/{user_id}/{project_id}/connect` - IME WebSocket 连接
|
||||
///
|
||||
/// ## 端口说明
|
||||
/// - **WebSocket 6091**: IME 输入法透传服务
|
||||
///
|
||||
/// ## 工作原理
|
||||
/// 客户端本地输入法(浏览器 IME)通过 WebSocket 发送文本到 Pingora,
|
||||
/// Pingora 代理到容器 IME 服务,容器使用 xdotool 将文本输入到远程桌面。
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/computer/ime/{user_id}/{project_id}/{*path}",
|
||||
params(
|
||||
("user_id" = String, Path, description = "用户 ID"),
|
||||
("project_id" = String, Path, description = "项目 ID"),
|
||||
("path" = Option<String>, Path, description = "剩余路径")
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 101,
|
||||
description = "WebSocket 升级响应(IME 连接)",
|
||||
body = String
|
||||
),
|
||||
(
|
||||
status = 404,
|
||||
description = "找不到用户容器",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
),
|
||||
(
|
||||
status = 503,
|
||||
description = "代理服务未启用",
|
||||
body = HttpResult<DesktopErrorResponse>
|
||||
)
|
||||
),
|
||||
tag = "computer",
|
||||
operation_id = "computer_ime_proxy",
|
||||
summary = "IME 输入法代理",
|
||||
description = r#"
|
||||
通过 Pingora 代理访问容器的 IME 输入法透传服务。
|
||||
|
||||
## 访问方式
|
||||
|
||||
### IME WebSocket 连接
|
||||
```
|
||||
WebSocket /computer/ime/{user_id}/{project_id}/connect
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **客户端**: 浏览器本地 IME 输入中文
|
||||
2. **WebSocket 发送**: 将文本通过 WebSocket 发送到 Pingora
|
||||
3. **Pingora 代理**: 根据 user_id 查找容器 IP,代理到端口 6091
|
||||
4. **容器 IME 服务**: 接收文本,使用 xdotool 输入到远程桌面
|
||||
5. **远程桌面**: 显示输入的文本
|
||||
|
||||
## 消息格式
|
||||
|
||||
### 客户端 → 容器
|
||||
```json
|
||||
{
|
||||
"type": "text",
|
||||
"text": "你好,世界",
|
||||
"method": "xdotool"
|
||||
}
|
||||
```
|
||||
|
||||
### 容器 → 客户端
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "文本已输入"
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
```javascript
|
||||
// 连接 IME WebSocket
|
||||
const imeWs = new WebSocket('ws://localhost:8088/computer/ime/user_123/proj_456/connect');
|
||||
|
||||
// 发送文本
|
||||
imeWs.send(JSON.stringify({
|
||||
type: 'text',
|
||||
text: '测试中文输入',
|
||||
method: 'xdotool'
|
||||
}));
|
||||
|
||||
// 接收响应
|
||||
imeWs.onmessage = (event) => {
|
||||
const response = JSON.parse(event.data);
|
||||
console.log('IME 响应:', response);
|
||||
};
|
||||
```
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 文本长度限制:1000 字符
|
||||
- 危险控制字符过滤(NULL, ESC)
|
||||
- 使用 `--` 参数分隔符防止命令注入
|
||||
"#
|
||||
)]
|
||||
#[allow(dead_code)]
|
||||
pub async fn computer_ime_proxy(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
I18nPath((user_id, project_id, path)): I18nPath<(String, String, Option<String>)>,
|
||||
) -> impl IntoResponse {
|
||||
let error_response = DesktopErrorResponse {
|
||||
error: "PROXY_REDIRECT".to_string(),
|
||||
message: format!(
|
||||
"请使用 Pingora 代理路径访问 IME 服务,路径: /computer/ime/{}/{}/{}",
|
||||
user_id,
|
||||
project_id,
|
||||
path.as_deref().unwrap_or("connect")
|
||||
),
|
||||
user_id,
|
||||
project_id,
|
||||
};
|
||||
|
||||
(StatusCode::NOT_IMPLEMENTED, Json(error_response))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 辅助函数
|
||||
// ============================================================================
|
||||
|
||||
/// 检查 VNC 服务是否可用
|
||||
///
|
||||
/// 尝试连接到容器的 noVNC 端口,验证服务是否运行
|
||||
#[allow(dead_code)]
|
||||
async fn check_vnc_available(container_ip: &str) -> bool {
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{Duration, timeout};
|
||||
|
||||
let addr = format!("{}:{}", container_ip, NOVNC_PORT);
|
||||
|
||||
match timeout(Duration::from_secs(2), TcpStream::connect(&addr)).await {
|
||||
Ok(Ok(_)) => {
|
||||
info!("[DESKTOP_VNC] VNC reachable: {}", addr);
|
||||
true
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!("[DESKTOP_VNC] VNC connectionfailed: {} - {}", addr, e);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("[DESKTOP_VNC] VNC connectiontimeout: {}", addr);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 单元测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_vnc_url_format() {
|
||||
let ip = "172.17.0.5";
|
||||
let user_id = "user_123";
|
||||
let project_id = "proj_456";
|
||||
|
||||
// Pingora 代理路径
|
||||
let proxy_vnc_url = format!("/computer/vnc/{}/{}/vnc.html", user_id, project_id);
|
||||
let proxy_ws_url = format!("/computer/vnc/{}/{}/websockify", user_id, project_id);
|
||||
|
||||
// 直接访问路径
|
||||
let direct_vnc_url = format!("http://{}:{}/vnc.html", ip, NOVNC_PORT);
|
||||
let direct_ws_url = format!("ws://{}:{}/websockify", ip, NOVNC_PORT);
|
||||
|
||||
assert_eq!(proxy_vnc_url, "/computer/vnc/user_123/proj_456/vnc.html");
|
||||
assert_eq!(proxy_ws_url, "/computer/vnc/user_123/proj_456/websockify");
|
||||
assert_eq!(direct_vnc_url, "http://172.17.0.5:6080/vnc.html");
|
||||
assert_eq!(direct_ws_url, "ws://172.17.0.5:6080/websockify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_desktop_access_response_serialization() {
|
||||
let response = DesktopAccessResponse {
|
||||
success: true,
|
||||
proxy_vnc_url: "/computer/vnc/user_123/proj_456/vnc.html".to_string(),
|
||||
proxy_websocket_url: "/computer/vnc/user_123/proj_456/websockify".to_string(),
|
||||
direct_vnc_url: "http://172.17.0.5:6080/vnc.html".to_string(),
|
||||
direct_websocket_url: "ws://172.17.0.5:6080/websockify".to_string(),
|
||||
container_id: "abc123".to_string(),
|
||||
container_ip: "172.17.0.5".to_string(),
|
||||
user_id: "user_123".to_string(),
|
||||
project_id: "proj_456".to_string(),
|
||||
message: "Test message".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
assert!(json.contains("proxy_vnc_url"));
|
||||
assert!(json.contains("proxy_websocket_url"));
|
||||
assert!(json.contains("direct_vnc_url"));
|
||||
assert!(json.contains("user_123"));
|
||||
}
|
||||
}
|
||||
290
qiming-rcoder/crates/rcoder/src/handler/debug_handler.rs
Normal file
290
qiming-rcoder/crates/rcoder/src/handler/debug_handler.rs
Normal file
@@ -0,0 +1,290 @@
|
||||
//! 调试 API 处理器
|
||||
//!
|
||||
//! 提供用于问题排查的调试接口
|
||||
//!
|
||||
//! ⚠️ 警告:这些接口仅用于开发和调试,生产环境应禁用或添加权限控制
|
||||
|
||||
use axum::{Json, extract::State, http::HeaderMap};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::router::AppState;
|
||||
use shared_types::{
|
||||
HttpResult,
|
||||
error_codes::{ERR_INTERNAL_SERVER_ERROR, ERR_INVALID_PARAMS},
|
||||
get_i18n_message,
|
||||
};
|
||||
|
||||
use super::utils::get_locale_from_headers;
|
||||
|
||||
/// DuckDB 查询请求
|
||||
#[derive(Debug, Deserialize, Serialize, ToSchema)]
|
||||
pub struct DebugSqlQueryRequest {
|
||||
/// SQL 查询语句
|
||||
///
|
||||
/// 支持的表:
|
||||
/// - `projects`: 项目记录表
|
||||
/// - `containers`: 容器记录表
|
||||
///
|
||||
/// 示例查询:
|
||||
/// - `SELECT * FROM projects`
|
||||
/// - `SELECT * FROM containers`
|
||||
/// - `SELECT project_id, session_id, container_id FROM projects WHERE session_id = 'xxx'`
|
||||
#[schema(example = "SELECT * FROM projects LIMIT 10")]
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
/// DuckDB 查询响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DebugSqlQueryResponse {
|
||||
/// 查询结果的列名
|
||||
pub columns: Vec<String>,
|
||||
/// 查询结果的行数据(每行是一个列名到值的映射)
|
||||
pub rows: Vec<serde_json::Value>,
|
||||
/// 返回的行数
|
||||
pub row_count: usize,
|
||||
/// 执行时间(毫秒)
|
||||
pub execution_time_ms: u64,
|
||||
}
|
||||
|
||||
/// 存储统计信息响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DebugStorageStatsResponse {
|
||||
/// 项目总数
|
||||
pub total_projects: usize,
|
||||
/// 容器总数
|
||||
pub total_containers: usize,
|
||||
/// 活跃会话数
|
||||
pub active_sessions: usize,
|
||||
/// 按服务类型统计的项目数
|
||||
pub projects_by_service_type: std::collections::HashMap<String, usize>,
|
||||
}
|
||||
|
||||
/// 执行 DuckDB SQL 查询(调试用)
|
||||
///
|
||||
/// ⚠️ 此接口仅用于开发和调试目的
|
||||
///
|
||||
/// 支持任意 SELECT 查询,用于查看内存数据库中的数据状态
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/debug/sql",
|
||||
request_body(
|
||||
content = DebugSqlQueryRequest,
|
||||
description = "SQL 查询请求",
|
||||
content_type = "application/json"
|
||||
),
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "查询成功",
|
||||
body = HttpResult<DebugSqlQueryResponse>,
|
||||
example = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"columns": ["project_id", "session_id", "container_id"],
|
||||
"rows": [
|
||||
{"project_id": "user_123", "session_id": "xxx", "container_id": "yyy"}
|
||||
],
|
||||
"row_count": 1,
|
||||
"execution_time_ms": 5
|
||||
},
|
||||
"code": "0"
|
||||
})
|
||||
),
|
||||
(
|
||||
status = 400,
|
||||
description = "SQL 语法错误或不支持的操作",
|
||||
body = HttpResult<String>
|
||||
)
|
||||
),
|
||||
tag = "debug",
|
||||
operation_id = "debug_sql_query",
|
||||
summary = "执行 DuckDB SQL 查询(调试用)",
|
||||
description = "执行任意 SELECT 查询,用于查看内存数据库中的数据状态。\n\n⚠️ 仅用于开发和调试,生产环境应禁用此接口。"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn debug_sql_query(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<DebugSqlQueryRequest>,
|
||||
) -> HttpResult<DebugSqlQueryResponse> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
debug!("[DEBUG_SQL] executing: {}", request.sql);
|
||||
|
||||
// 安全检查:Only SELECT queries are allowed
|
||||
let sql_trimmed = request.sql.trim().to_uppercase();
|
||||
if !sql_trimmed.starts_with("SELECT") {
|
||||
warn!("[DEBUG_SQL] non-SELECT query: {}", request.sql);
|
||||
return HttpResult::error_with_message(
|
||||
ERR_INVALID_PARAMS,
|
||||
locale,
|
||||
&get_i18n_message("error.select_only", locale),
|
||||
);
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
match execute_sql_query(&state.projects, &request.sql) {
|
||||
Ok((columns, rows)) => {
|
||||
let row_count = rows.len();
|
||||
let execution_time_ms = start_time.elapsed().as_millis() as u64;
|
||||
|
||||
debug!(
|
||||
"✅ [DEBUG_SQL] Query succeeded: {} rows, {} ms",
|
||||
row_count, execution_time_ms
|
||||
);
|
||||
|
||||
HttpResult::success(DebugSqlQueryResponse {
|
||||
columns,
|
||||
rows,
|
||||
row_count,
|
||||
execution_time_ms,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
error!("[DEBUG_SQL] Query failed: {}", e);
|
||||
HttpResult::error_with_message(
|
||||
ERR_INTERNAL_SERVER_ERROR,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.query_execution_failed", locale),
|
||||
e
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取存储统计信息(调试用)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/debug/storage/stats",
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "获取统计信息成功",
|
||||
body = HttpResult<DebugStorageStatsResponse>
|
||||
)
|
||||
),
|
||||
tag = "debug",
|
||||
operation_id = "debug_storage_stats",
|
||||
summary = "获取 DuckDB 存储统计信息(调试用)"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn debug_storage_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_headers: HeaderMap,
|
||||
) -> HttpResult<DebugStorageStatsResponse> {
|
||||
let stats = state.projects.get_stats();
|
||||
|
||||
let mut projects_by_service_type = std::collections::HashMap::new();
|
||||
for (st, count) in stats.projects_by_service_type {
|
||||
projects_by_service_type.insert(st.to_string(), count);
|
||||
}
|
||||
|
||||
HttpResult::success(DebugStorageStatsResponse {
|
||||
total_projects: stats.total_projects,
|
||||
total_containers: stats.total_containers,
|
||||
active_sessions: stats.active_sessions,
|
||||
projects_by_service_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// 快捷查询:获取所有项目
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/debug/projects",
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "获取项目列表成功",
|
||||
body = HttpResult<DebugSqlQueryResponse>
|
||||
)
|
||||
),
|
||||
tag = "debug",
|
||||
operation_id = "debug_list_projects",
|
||||
summary = "获取所有项目记录(调试用)"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn debug_list_projects(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> HttpResult<DebugSqlQueryResponse> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let start_time = std::time::Instant::now();
|
||||
let sql = "SELECT project_id, session_id, service_type, container_id, user_id, agent_status_name, created_at, last_activity FROM projects ORDER BY last_activity DESC";
|
||||
|
||||
match execute_sql_query(&state.projects, sql) {
|
||||
Ok((columns, rows)) => HttpResult::success(DebugSqlQueryResponse {
|
||||
columns,
|
||||
row_count: rows.len(),
|
||||
rows,
|
||||
execution_time_ms: start_time.elapsed().as_millis() as u64,
|
||||
}),
|
||||
Err(e) => HttpResult::error_with_message(
|
||||
ERR_INTERNAL_SERVER_ERROR,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.query_execution_failed", locale),
|
||||
e
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 快捷查询:获取所有容器
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/debug/containers",
|
||||
responses(
|
||||
(
|
||||
status = 200,
|
||||
description = "获取容器列表成功",
|
||||
body = HttpResult<DebugSqlQueryResponse>
|
||||
)
|
||||
),
|
||||
tag = "debug",
|
||||
operation_id = "debug_list_containers",
|
||||
summary = "获取所有容器记录(调试用)"
|
||||
)]
|
||||
#[axum::debug_handler]
|
||||
pub async fn debug_list_containers(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> HttpResult<DebugSqlQueryResponse> {
|
||||
let locale = get_locale_from_headers(&headers);
|
||||
let start_time = std::time::Instant::now();
|
||||
let sql = "SELECT container_id, container_name, container_ip, service_type, status, created_at, last_activity FROM containers ORDER BY last_activity DESC";
|
||||
|
||||
match execute_sql_query(&state.projects, sql) {
|
||||
Ok((columns, rows)) => HttpResult::success(DebugSqlQueryResponse {
|
||||
columns,
|
||||
row_count: rows.len(),
|
||||
rows,
|
||||
execution_time_ms: start_time.elapsed().as_millis() as u64,
|
||||
}),
|
||||
Err(e) => HttpResult::error_with_message(
|
||||
ERR_INTERNAL_SERVER_ERROR,
|
||||
locale,
|
||||
&format!(
|
||||
"{}: {}",
|
||||
get_i18n_message("error.query_execution_failed", locale),
|
||||
e
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行 SQL 查询的内部函数
|
||||
fn execute_sql_query(
|
||||
adapter: &crate::storage::ProjectAdapter,
|
||||
sql: &str,
|
||||
) -> Result<(Vec<String>, Vec<serde_json::Value>), String> {
|
||||
// 获取底层 DuckDB 存储
|
||||
adapter.execute_raw_query(sql)
|
||||
}
|
||||
39
qiming-rcoder/crates/rcoder/src/handler/health_handler.rs
Normal file
39
qiming-rcoder/crates/rcoder/src/handler/health_handler.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
//! 健康检查处理器
|
||||
|
||||
use axum::Json;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 健康检查响应结构
|
||||
///
|
||||
/// 统一的服务健康检查响应格式
|
||||
#[derive(serde::Serialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
/// 服务状态
|
||||
#[schema(example = "healthy")]
|
||||
pub status: String,
|
||||
/// 时间戳
|
||||
#[schema(example = "2024-01-15T10:30:00Z")]
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
/// 服务名称
|
||||
#[schema(example = "rcoder-ai-service")]
|
||||
pub service: String,
|
||||
}
|
||||
|
||||
/// 健康检查端点
|
||||
///
|
||||
/// 检查服务的健康状态
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
responses(
|
||||
(status = 200, description = "服务健康状态", body = HealthResponse)
|
||||
),
|
||||
tag = "system"
|
||||
)]
|
||||
pub async fn health_check() -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "healthy".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
service: "rcoder-ai-service".to_string(),
|
||||
})
|
||||
}
|
||||
37
qiming-rcoder/crates/rcoder/src/handler/mod.rs
Normal file
37
qiming-rcoder/crates/rcoder/src/handler/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! HTTP 路由和处理器模块
|
||||
mod agent_cancel_handler;
|
||||
mod agent_session_notification;
|
||||
mod agent_status_handler;
|
||||
mod agent_stop_handler;
|
||||
mod chat_handler;
|
||||
mod computer_agent_status_handler;
|
||||
mod computer_agent_stop_handler;
|
||||
mod computer_chat_handler;
|
||||
mod computer_desktop_handler;
|
||||
mod health_handler;
|
||||
pub mod pod_handler;
|
||||
pub mod proxy_api;
|
||||
pub mod proxy_handler_api;
|
||||
pub mod utils;
|
||||
|
||||
// 调试处理器(仅在启用 debug feature 时可用)
|
||||
#[cfg(feature = "debug")]
|
||||
mod debug_handler;
|
||||
|
||||
pub use agent_cancel_handler::*;
|
||||
pub use agent_session_notification::*;
|
||||
pub use agent_status_handler::*;
|
||||
pub use agent_stop_handler::*;
|
||||
pub use chat_handler::*;
|
||||
pub use computer_agent_status_handler::*;
|
||||
pub use computer_agent_stop_handler::*;
|
||||
pub use computer_chat_handler::*;
|
||||
pub use computer_desktop_handler::*;
|
||||
pub use health_handler::*;
|
||||
pub use pod_handler::*;
|
||||
pub use proxy_api::*;
|
||||
pub use proxy_handler_api::*;
|
||||
|
||||
// 仅在启用 debug feature 时导出 debug handler
|
||||
#[cfg(feature = "debug")]
|
||||
pub use debug_handler::*;
|
||||
2383
qiming-rcoder/crates/rcoder/src/handler/pod_handler.rs
Normal file
2383
qiming-rcoder/crates/rcoder/src/handler/pod_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
196
qiming-rcoder/crates/rcoder/src/handler/proxy_api.rs
Normal file
196
qiming-rcoder/crates/rcoder/src/handler/proxy_api.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
//! Pingora 反向代理 API 数据结构
|
||||
//!
|
||||
//! 为 OpenAPI 文档提供 Pingora 代理接口的数据结构定义。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Pingora 代理路径参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct ProxyPathParams {
|
||||
/// 目标端口号
|
||||
#[schema(example = 3000)]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
/// Pingora 代理路径和尾部参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
#[allow(dead_code)] // 字段由 axum 框架自动提取使用
|
||||
pub struct ProxyPathWithTailParams {
|
||||
/// 目标端口号
|
||||
#[schema(example = 3000)]
|
||||
pub port: u16,
|
||||
/// 路径尾部 (剩余的路径部分)
|
||||
#[schema(example = "/api/users")]
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Pingora 代理响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProxyResponse {
|
||||
/// 代理状态
|
||||
#[schema(example = true)]
|
||||
pub success: bool,
|
||||
/// 目标端口
|
||||
#[schema(example = 3000)]
|
||||
pub target_port: u16,
|
||||
/// 目标主机
|
||||
#[schema(example = "127.0.0.1")]
|
||||
pub target_host: String,
|
||||
/// 目标URL
|
||||
#[schema(example = "http://127.0.0.1:3000/api/users")]
|
||||
pub target_url: String,
|
||||
/// 响应时间 (毫秒)
|
||||
#[schema(example = 45)]
|
||||
pub response_time_ms: Option<u64>,
|
||||
/// 负载均衡器信息
|
||||
pub load_balancer: LoadBalancerInfo,
|
||||
}
|
||||
|
||||
/// 负载均衡器信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct LoadBalancerInfo {
|
||||
/// 负载均衡算法
|
||||
#[schema(example = "round-robin")]
|
||||
pub algorithm: String,
|
||||
/// 是否启用健康检查
|
||||
#[schema(example = true)]
|
||||
pub health_check_enabled: bool,
|
||||
/// 后端服务数量
|
||||
#[schema(example = 3)]
|
||||
pub backend_count: usize,
|
||||
}
|
||||
|
||||
/// Pingora 代理状态信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProxyStatus {
|
||||
/// 代理服务状态
|
||||
#[schema(example = "running")]
|
||||
pub status: String,
|
||||
/// 监听端口
|
||||
#[schema(example = 8080)]
|
||||
pub listen_port: u16,
|
||||
/// 默认后端端口
|
||||
#[schema(example = 3000)]
|
||||
pub default_backend_port: u16,
|
||||
/// 默认后端主机
|
||||
#[schema(example = "127.0.0.1")]
|
||||
pub default_backend_host: String,
|
||||
/// 已配置的后端服务
|
||||
pub backends: Vec<BackendInfo>,
|
||||
/// 负载均衡配置
|
||||
pub load_balancer: LoadBalancerInfo,
|
||||
}
|
||||
|
||||
/// 后端服务信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct BackendInfo {
|
||||
/// 端口号
|
||||
#[schema(example = 3000)]
|
||||
pub port: u16,
|
||||
/// 主机地址
|
||||
#[schema(example = "127.0.0.1")]
|
||||
pub host: String,
|
||||
/// 健康状态
|
||||
#[schema(example = "healthy")]
|
||||
pub health_status: String,
|
||||
/// 最后检查时间
|
||||
#[schema(example = "2025-01-12T10:30:00Z")]
|
||||
pub last_check: String,
|
||||
}
|
||||
|
||||
/// Pingora 代理错误响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProxyErrorResponse {
|
||||
/// 错误代码
|
||||
#[schema(example = "BACKEND_NOT_FOUND")]
|
||||
pub error: String,
|
||||
/// 错误消息
|
||||
#[schema(example = "未找到端口 9999 对应的后端服务")]
|
||||
pub message: String,
|
||||
/// 目标端口
|
||||
#[schema(example = 9999)]
|
||||
pub target_port: u16,
|
||||
/// 请求时间戳
|
||||
#[schema(example = "2025-01-12T10:30:00Z")]
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// 代理统计信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ProxyStats {
|
||||
/// 总请求数
|
||||
#[schema(example = 15420)]
|
||||
pub total_requests: u64,
|
||||
/// 成功请求数
|
||||
#[schema(example = 15200)]
|
||||
pub successful_requests: u64,
|
||||
/// 失败请求数
|
||||
#[schema(example = 220)]
|
||||
pub failed_requests: u64,
|
||||
/// 平均响应时间 (毫秒)
|
||||
#[schema(example = 35.5)]
|
||||
pub avg_response_time_ms: f64,
|
||||
/// 当前活跃连接数
|
||||
#[schema(example = 12)]
|
||||
pub active_connections: u32,
|
||||
/// 按端口统计
|
||||
pub port_stats: Vec<PortStats>,
|
||||
}
|
||||
|
||||
/// 端口统计信息
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PortStats {
|
||||
/// 端口号
|
||||
#[schema(example = 3000)]
|
||||
pub port: u16,
|
||||
/// 请求数
|
||||
#[schema(example = 8560)]
|
||||
pub requests: u64,
|
||||
/// 成功率
|
||||
#[schema(example = 0.987)]
|
||||
pub success_rate: f64,
|
||||
/// 平均响应时间
|
||||
#[schema(example = 28.3)]
|
||||
pub avg_response_time_ms: f64,
|
||||
}
|
||||
|
||||
/// 代理配置信息
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ProxyConfig {
|
||||
/// 监听端口
|
||||
#[schema(example = 8080)]
|
||||
pub listen_port: u16,
|
||||
/// 默认后端端口
|
||||
#[schema(example = 3000)]
|
||||
pub default_backend_port: u16,
|
||||
/// 默认后端主机
|
||||
#[schema(example = "127.0.0.1")]
|
||||
pub default_backend_host: String,
|
||||
/// 负载均衡算法
|
||||
#[schema(example = "round-robin")]
|
||||
pub load_balancing_algorithm: String,
|
||||
/// 健康检查配置
|
||||
pub health_check: HealthCheckConfig,
|
||||
}
|
||||
|
||||
/// 健康检查配置
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthCheckConfig {
|
||||
/// 是否启用
|
||||
#[schema(example = true)]
|
||||
pub enabled: bool,
|
||||
/// 检查间隔 (秒)
|
||||
#[schema(example = 5)]
|
||||
pub interval_seconds: u32,
|
||||
/// 超时时间 (秒)
|
||||
#[schema(example = 3)]
|
||||
pub timeout_seconds: u32,
|
||||
/// 健康阈值
|
||||
#[schema(example = 2)]
|
||||
pub healthy_threshold: u32,
|
||||
/// 不健康阈值
|
||||
#[schema(example = 3)]
|
||||
pub unhealthy_threshold: u32,
|
||||
}
|
||||
585
qiming-rcoder/crates/rcoder/src/handler/proxy_handler_api.rs
Normal file
585
qiming-rcoder/crates/rcoder/src/handler/proxy_handler_api.rs
Normal file
@@ -0,0 +1,585 @@
|
||||
//! Pingora 代理 API 处理函数
|
||||
//!
|
||||
//! 提供 Pingora 代理相关的 API 接口,主要用于文档展示和状态查询。
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::proxy_api::*;
|
||||
use crate::router::AppState;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
/// Pingora 代理状态查询
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/status",
|
||||
tag = "proxy",
|
||||
summary = "获取 Pingora 代理服务状态",
|
||||
description = "返回当前 Pingora 代理服务的运行状态和配置信息",
|
||||
responses(
|
||||
(status = 200, description = "成功获取代理状态", body = ProxyStatus),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<ProxyStatus>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() || state.pingora_service.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service is not enabled or unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let svc = state.pingora_service.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_SERVICE_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy service instance is unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let conf = svc.config().clone();
|
||||
|
||||
// 收集后端列表
|
||||
let backends_arc = svc.backends();
|
||||
let backends_map = backends_arc.read().await;
|
||||
let backend_count = backends_map.len();
|
||||
// 收集后端列表(从缓存快照)
|
||||
let health_map = svc.health_snapshot().await;
|
||||
let backends = backends_map
|
||||
.iter()
|
||||
.map(|(port, host)| {
|
||||
if let Some(health) = health_map.get(port) {
|
||||
let last_check_str = DateTime::<Utc>::from(health.last_check).to_rfc3339();
|
||||
BackendInfo {
|
||||
port: *port,
|
||||
host: host.clone(),
|
||||
health_status: health.status.as_str().to_string(),
|
||||
last_check: last_check_str,
|
||||
}
|
||||
} else {
|
||||
BackendInfo {
|
||||
port: *port,
|
||||
host: host.clone(),
|
||||
health_status: "unknown".to_string(),
|
||||
last_check: Utc::now().to_rfc3339(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let status = ProxyStatus {
|
||||
status: "running".to_string(),
|
||||
listen_port: conf.listen_port,
|
||||
default_backend_port: conf.default_backend_port,
|
||||
default_backend_host: conf.backend_host.clone(),
|
||||
backends,
|
||||
load_balancer: LoadBalancerInfo {
|
||||
algorithm: if svc.use_round_robin {
|
||||
"round-robin".to_string()
|
||||
} else {
|
||||
"ketama".to_string()
|
||||
},
|
||||
health_check_enabled: true,
|
||||
backend_count,
|
||||
},
|
||||
};
|
||||
|
||||
info!(
|
||||
"Query proxy status: port {}, default backend: {}:{} (backend count: {})",
|
||||
status.listen_port, status.default_backend_host, status.default_backend_port, backend_count
|
||||
);
|
||||
|
||||
Ok(Json(status))
|
||||
}
|
||||
|
||||
/// Pingora 代理统计信息
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/stats",
|
||||
tag = "proxy",
|
||||
summary = "获取 Pingora 代理统计信息",
|
||||
description = "返回代理服务的请求统计和性能指标",
|
||||
responses(
|
||||
(status = 200, description = "成功获取统计信息", body = ProxyStats),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_stats(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<ProxyStats>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
// 需要代理配置启用且服务可用
|
||||
if state.config.proxy_config.is_none() || state.pingora_service.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service is not enabled or unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let svc = state.pingora_service.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_SERVICE_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy service instance is unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let m = &svc.metrics;
|
||||
|
||||
let total_requests = m.total_requests.load(Ordering::Relaxed);
|
||||
let successful_requests = m.successful_responses.load(Ordering::Relaxed);
|
||||
let failed_requests = m.failed_responses.load(Ordering::Relaxed);
|
||||
let avg_response_time_ms = m.avg_response_time_ms();
|
||||
|
||||
// 按端口统计
|
||||
let snaps = m.port_snapshots();
|
||||
let port_stats = snaps
|
||||
.into_iter()
|
||||
.map(|ps| {
|
||||
let total = ps.successes + ps.failures;
|
||||
let success_rate = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(ps.successes as f64) / (total as f64)
|
||||
};
|
||||
let avg_ms = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(ps.total_response_time_ns as f64) / 1_000_000.0 / (total as f64)
|
||||
};
|
||||
PortStats {
|
||||
port: ps.port,
|
||||
requests: ps.requests,
|
||||
success_rate,
|
||||
avg_response_time_ms: avg_ms,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let stats = ProxyStats {
|
||||
total_requests,
|
||||
successful_requests,
|
||||
failed_requests,
|
||||
avg_response_time_ms,
|
||||
active_connections: m.active_connections.load(Ordering::Relaxed) as u32,
|
||||
port_stats,
|
||||
};
|
||||
|
||||
info!(
|
||||
"Query proxy stats: total requests {}, success {}, failed {}, avg response time {:.2}ms",
|
||||
stats.total_requests,
|
||||
stats.successful_requests,
|
||||
stats.failed_requests,
|
||||
stats.avg_response_time_ms
|
||||
);
|
||||
|
||||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
/// Pingora 代理配置查询
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/config",
|
||||
tag = "proxy",
|
||||
summary = "获取 Pingora 代理配置",
|
||||
description = "返回当前代理服务的配置信息",
|
||||
responses(
|
||||
(status = 200, description = "成功获取配置信息", body = ProxyConfig),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<ProxyConfig>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() || state.pingora_service.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service is not enabled or unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let svc = state.pingora_service.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_SERVICE_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy service instance is unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let conf = svc.config();
|
||||
let app_conf = &state.config;
|
||||
let hc_conf = &app_conf
|
||||
.proxy_config
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_CONFIG_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy config unavailable".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.health_check;
|
||||
|
||||
let config = ProxyConfig {
|
||||
listen_port: conf.listen_port,
|
||||
default_backend_port: conf.default_backend_port,
|
||||
default_backend_host: conf.backend_host.clone(),
|
||||
load_balancing_algorithm: if svc.use_round_robin {
|
||||
"round-robin".to_string()
|
||||
} else {
|
||||
"ketama".to_string()
|
||||
},
|
||||
health_check: HealthCheckConfig {
|
||||
enabled: hc_conf.enabled,
|
||||
interval_seconds: hc_conf.interval_seconds as u32,
|
||||
timeout_seconds: hc_conf.timeout_seconds as u32,
|
||||
healthy_threshold: hc_conf.healthy_threshold,
|
||||
unhealthy_threshold: hc_conf.unhealthy_threshold,
|
||||
},
|
||||
};
|
||||
|
||||
info!(
|
||||
"Query proxy config: listen port {}, default backend: {}:{}, LB algorithm: {}",
|
||||
config.listen_port,
|
||||
config.default_backend_host,
|
||||
config.default_backend_port,
|
||||
config.load_balancing_algorithm
|
||||
);
|
||||
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
/// 代理到指定端口(重定向到 Pingora)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/{port}",
|
||||
tag = "proxy",
|
||||
summary = "重定向到 Pingora 代理(无路径)",
|
||||
description = r#"
|
||||
重定向请求到 Pingora 代理服务,无额外路径。
|
||||
|
||||
## 工作原理
|
||||
此接口会返回 307 重定向,将请求转发到 Pingora 代理服务的实际端口。
|
||||
|
||||
## 实际代理路径
|
||||
真正的代理由 Pingora 处理,路径格式为:
|
||||
```
|
||||
GET /proxy/{port}/
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
```bash
|
||||
# 访问此接口
|
||||
GET /proxy/3000
|
||||
|
||||
# 返回 307 重定向到:
|
||||
# http://127.0.0.1:{pingora_port}/proxy/3000/
|
||||
|
||||
# Pingora 代理到:
|
||||
# 127.0.0.1:3000/
|
||||
```
|
||||
"#,
|
||||
params(
|
||||
("port" = u16, Path, description = "目标端口号")
|
||||
),
|
||||
responses(
|
||||
(status = 307, description = "重定向到 Pingora 代理服务", body = String),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_to_port(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(port): Path<u16>,
|
||||
) -> Result<axum::response::Response, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service not enabled".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let proxy_config = state.config.proxy_config.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_CONFIG_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy config unavailable".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let listen_port = proxy_config.listen_port;
|
||||
|
||||
// 重定向到 Pingora 真实代理端口
|
||||
let location = format!("http://127.0.0.1:{}/proxy/{}", listen_port, port);
|
||||
|
||||
let resp = axum::http::Response::builder()
|
||||
.status(StatusCode::TEMPORARY_REDIRECT)
|
||||
.header(axum::http::header::LOCATION, location)
|
||||
.body(axum::body::Body::empty())
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "RESPONSE_BUILD_ERROR".to_string(),
|
||||
message: format!("failed to build response: {}", e),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// 代理到指定端口和路径(重定向到 Pingora)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy/{port}/{*path}",
|
||||
tag = "proxy",
|
||||
summary = "重定向到 Pingora 代理(含路径)",
|
||||
description = r#"
|
||||
重定向请求到 Pingora 代理服务,包含完整路径信息。
|
||||
|
||||
## 工作原理
|
||||
此接口会返回 307 重定向,将请求转发到 Pingora 代理服务的实际端口和路径。
|
||||
|
||||
## 实际代理路径
|
||||
真正的代理由 Pingora 处理,路径格式为:
|
||||
```
|
||||
GET /proxy/{port}/{path}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
```bash
|
||||
# 访问此接口
|
||||
GET /proxy/8080/api/users
|
||||
|
||||
# 返回 307 重定向到:
|
||||
# http://127.0.0.1:{pingora_port}/proxy/8080/api/users
|
||||
|
||||
# Pingora 代理到:
|
||||
# 127.0.0.1:8080/api/users
|
||||
```
|
||||
"#,
|
||||
params(
|
||||
("port" = u16, Path, description = "目标端口号"),
|
||||
("path" = String, Path, description = "目标路径")
|
||||
),
|
||||
responses(
|
||||
(status = 307, description = "重定向到 Pingora 代理服务", body = String),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_to_port_with_path(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path((port, path)): Path<(u16, String)>,
|
||||
) -> Result<axum::response::Response, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service not enabled".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let proxy_config = state.config.proxy_config.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_CONFIG_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy config unavailable".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let listen_port = proxy_config.listen_port;
|
||||
|
||||
let target_path = if path.is_empty() || path == "/" {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}", path)
|
||||
};
|
||||
|
||||
// 重定向到 Pingora 真实代理端口(保持相同的路径)
|
||||
let location = format!(
|
||||
"http://127.0.0.1:{}/proxy/{}{}",
|
||||
listen_port, port, target_path
|
||||
);
|
||||
|
||||
let resp = axum::http::Response::builder()
|
||||
.status(StatusCode::TEMPORARY_REDIRECT)
|
||||
.header(axum::http::header::LOCATION, location)
|
||||
.body(axum::body::Body::empty())
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "RESPONSE_BUILD_ERROR".to_string(),
|
||||
message: format!("failed to build response: {}", e),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// 通用代理请求处理器
|
||||
async fn proxy_request_handler(
|
||||
state: Arc<AppState>,
|
||||
port: u16,
|
||||
path: Option<String>,
|
||||
) -> Result<Json<ProxyResponse>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
if state.config.proxy_config.is_none() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_DISABLED".to_string(),
|
||||
message: "Pingora proxy service not enabled".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let proxy_config = state.config.proxy_config.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "PROXY_CONFIG_UNAVAILABLE".to_string(),
|
||||
message: "Pingora proxy config unavailable".to_string(),
|
||||
target_port: port,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
let target_host = &proxy_config.backend_host;
|
||||
let target_path = path.unwrap_or_else(|| "/".to_string());
|
||||
let target_url = format!("http://{}:{}{}", target_host, port, target_path);
|
||||
|
||||
debug!("Mock proxy request: {} -> {}", port, target_url);
|
||||
|
||||
// 这里只是用于文档展示,实际的代理由 Pingora 服务器处理
|
||||
// 如果用户访问这些接口,我们会返回信息,说明实际的代理在 Pingora 服务器端口
|
||||
|
||||
let response = ProxyResponse {
|
||||
success: true,
|
||||
target_port: port,
|
||||
target_host: target_host.clone(),
|
||||
target_url: target_url.clone(),
|
||||
response_time_ms: Some(35),
|
||||
load_balancer: LoadBalancerInfo {
|
||||
algorithm: "round-robin".to_string(),
|
||||
health_check_enabled: true,
|
||||
backend_count: 1,
|
||||
},
|
||||
};
|
||||
|
||||
info!(
|
||||
"Proxy request documentation demo: port {}, path {}, target: {}",
|
||||
port, target_path, target_url
|
||||
);
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
/// 查询参数
|
||||
#[derive(Debug, Deserialize, utoipa::IntoParams)]
|
||||
pub struct ProxyQueryParams {
|
||||
/// 端口号(用于向后兼容)
|
||||
#[param(example = 3000)]
|
||||
pub port: Option<u16>,
|
||||
/// 路径(可选)
|
||||
#[param(example = "/api/users")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// 使用查询参数的代理方式(向后兼容)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/proxy",
|
||||
tag = "proxy",
|
||||
summary = "使用查询参数代理(向后兼容)",
|
||||
description = "通过查询参数指定目标端口和路径,保持向后兼容性",
|
||||
params(
|
||||
ProxyQueryParams
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "代理成功", body = ProxyResponse),
|
||||
(status = 400, description = "缺少端口参数", body = ProxyErrorResponse),
|
||||
(status = 503, description = "代理服务未启用", body = ProxyErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn proxy_with_query_params(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<ProxyQueryParams>,
|
||||
) -> Result<Json<ProxyResponse>, (StatusCode, Json<ProxyErrorResponse>)> {
|
||||
let port = params.port.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ProxyErrorResponse {
|
||||
error: "MISSING_PORT".to_string(),
|
||||
message: "Missing port parameter".to_string(),
|
||||
target_port: 0,
|
||||
timestamp: Utc::now().to_rfc3339(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let path = params.path.clone().unwrap_or_else(|| "/".to_string());
|
||||
warn!(
|
||||
"Using deprecated query parameter proxy method, recommended path format: /proxy/{}/{}",
|
||||
port, path
|
||||
);
|
||||
|
||||
proxy_request_handler(state, port, Some(path)).await
|
||||
}
|
||||
165
qiming-rcoder/crates/rcoder/src/handler/utils/grpc_addr.rs
Normal file
165
qiming-rcoder/crates/rcoder/src/handler/utils/grpc_addr.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! gRPC 地址解析工具
|
||||
//!
|
||||
//! 从 service_url 提取 gRPC 连接地址。
|
||||
|
||||
use crate::AppError;
|
||||
use shared_types::GRPC_DEFAULT_PORT;
|
||||
use shared_types::error_codes::ERR_GRPC_ADDR_ERROR;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// 从 service_url 提取 gRPC 地址(使用指定端口)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `service_url`: 服务 URL,格式如 `http://192.168.1.100:8086`
|
||||
/// - `grpc_port`: gRPC 端口号
|
||||
///
|
||||
/// # 返回
|
||||
/// 格式化的 gRPC 地址(`host:port` 格式)
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let addr = extract_grpc_addr_with_port("http://192.168.1.100:8086", 50051)?;
|
||||
/// assert_eq!(addr, "192.168.1.100:50051");
|
||||
/// ```
|
||||
pub fn extract_grpc_addr_with_port(service_url: &str, grpc_port: u16) -> Result<String, AppError> {
|
||||
// 自动添加 scheme 如果缺失(为了能够通过 Url::parse 解析)
|
||||
let url_str = if service_url.contains("://") {
|
||||
service_url.to_string()
|
||||
} else {
|
||||
format!("http://{}", service_url)
|
||||
};
|
||||
|
||||
let url = url::Url::parse(&url_str).map_err(|e| {
|
||||
AppError::with_message(ERR_GRPC_ADDR_ERROR, format!("Invalid service_url: {}", e))
|
||||
})?;
|
||||
|
||||
let host = url.host_str().ok_or_else(|| {
|
||||
AppError::with_i18n_key(ERR_GRPC_ADDR_ERROR, "error.grpc_service_url_missing_host")
|
||||
})?;
|
||||
|
||||
Ok(format!("{}:{}", host, grpc_port))
|
||||
}
|
||||
|
||||
/// 从 service_url 提取 gRPC 地址(使用默认端口)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `service_url`: 服务 URL,格式如 `http://192.168.1.100:8086`
|
||||
///
|
||||
/// # 返回
|
||||
/// 格式化的 gRPC 地址(`host:GRPC_DEFAULT_PORT` 格式)
|
||||
pub fn extract_grpc_addr(service_url: &str) -> Result<String, AppError> {
|
||||
extract_grpc_addr_with_port(service_url, GRPC_DEFAULT_PORT)
|
||||
}
|
||||
|
||||
/// 从 Docker API 实时获取容器 IP
|
||||
///
|
||||
/// 使用容器名称(如 `computer-agent-runner-user_123`)查询,
|
||||
/// 因为 container_id 在容器重启后会改变,但 container_name 是稳定的。
|
||||
///
|
||||
/// 查询顺序:Runtime find_container(带内部缓存)
|
||||
///
|
||||
/// # 参数
|
||||
/// - `container_name`: 容器名称
|
||||
/// - `fallback_ip`: 回退 IP 地址
|
||||
/// - `rcoder_prefix`: RCoder 服务的容器前缀(从配置读取)
|
||||
/// - `computer_prefix`: ComputerAgentRunner 服务的容器前缀(从配置读取)
|
||||
pub async fn get_realtime_container_ip(
|
||||
container_name: &str,
|
||||
fallback_ip: &str,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
) -> Result<String, String> {
|
||||
// 查询 Runtime API
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get runtime: {}", e))?;
|
||||
|
||||
// 使用配置化的前缀,而不是硬编码的 ServiceType::container_prefix()
|
||||
let (identifier, service_type) = if let Some(id) =
|
||||
container_name.strip_prefix(&format!("{}-", computer_prefix))
|
||||
{
|
||||
(id, shared_types::ServiceType::ComputerAgentRunner)
|
||||
} else if let Some(id) = container_name.strip_prefix(&format!("{}-", rcoder_prefix)) {
|
||||
(id, shared_types::ServiceType::RCoder)
|
||||
} else {
|
||||
return Ok(fallback_ip.to_string());
|
||||
};
|
||||
|
||||
// 通过 Runtime 的 find_container 查询容器 IP
|
||||
// find_container 会直接调用 Docker API 获取最新的容器信息
|
||||
match runtime.find_container(identifier, &service_type).await {
|
||||
Ok(Some(info)) if !info.container_ip.is_empty() => {
|
||||
debug!(
|
||||
"🔍 [IP_QUERY] Got container IP: container_name={}, ip={}",
|
||||
container_name, info.container_ip
|
||||
);
|
||||
Ok(info.container_ip)
|
||||
}
|
||||
Ok(_) => {
|
||||
// find_container 返回空或 IP 为空,使用 fallback
|
||||
info!(
|
||||
"⚠️ [IP_QUERY] find_container returned empty, using fallback: container_name={}",
|
||||
container_name
|
||||
);
|
||||
Ok(fallback_ip.to_string())
|
||||
}
|
||||
Err(e) => Err(format!("runtime find_container failed: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_http() {
|
||||
let result = extract_grpc_addr("http://192.168.1.100:8086").unwrap();
|
||||
assert_eq!(result, "192.168.1.100:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_https() {
|
||||
let result = extract_grpc_addr("https://example.com:443").unwrap();
|
||||
assert_eq!(result, "example.com:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_with_custom_port() {
|
||||
let result = extract_grpc_addr_with_port("http://192.168.1.100:8086", 9999).unwrap();
|
||||
assert_eq!(result, "192.168.1.100:9999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_without_port_in_url() {
|
||||
let result = extract_grpc_addr("http://192.168.1.100").unwrap();
|
||||
assert_eq!(result, "192.168.1.100:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_missing_scheme() {
|
||||
// 测试缺失 scheme,应该被自动补全为 http:// 并解析
|
||||
let result = extract_grpc_addr("192.168.1.100:8086").unwrap();
|
||||
assert_eq!(result, "192.168.1.100:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_ipv6() {
|
||||
// 测试 IPv6 地址
|
||||
let result = extract_grpc_addr("http://[::1]:8086").unwrap();
|
||||
assert_eq!(result, "[::1]:50051");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_malformed() {
|
||||
// 测试无效 URL
|
||||
let result = extract_grpc_addr("http://");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_empty() {
|
||||
// 测试空字符串
|
||||
let result = extract_grpc_addr("");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
502
qiming-rcoder/crates/rcoder/src/handler/utils/i18n_extractors.rs
Normal file
502
qiming-rcoder/crates/rcoder/src/handler/utils/i18n_extractors.rs
Normal file
@@ -0,0 +1,502 @@
|
||||
//! Locale-aware request extractors.
|
||||
//!
|
||||
//! These wrappers map Axum extractor rejections into unified `AppError` so
|
||||
//! HTTP error responses are consistently localized through `AppError::into_response`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
FromRequest, FromRequestParts, Json, Path, Query, Request,
|
||||
rejection::{JsonRejection, PathRejection, QueryRejection},
|
||||
},
|
||||
http::request::Parts,
|
||||
};
|
||||
use axum::http::Uri;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::AppError;
|
||||
|
||||
/// Locale-aware JSON extractor.
|
||||
pub struct I18nJson<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for I18nJson<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
Json<T>: FromRequest<S, Rejection = JsonRejection>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Json::<T>::from_request(req, state).await {
|
||||
Ok(Json(value)) => Ok(Self(value)),
|
||||
Err(rejection) => Err(map_json_rejection(rejection)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Locale-aware query extractor.
|
||||
pub struct I18nQuery<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequestParts<S> for I18nQuery<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
Query<T>: FromRequestParts<S, Rejection = QueryRejection>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Query::<T>::from_request_parts(parts, state).await {
|
||||
Ok(Query(value)) => Ok(Self(value)),
|
||||
Err(rejection) => Err(map_query_rejection(rejection)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Locale-aware path extractor.
|
||||
pub struct I18nPath<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequestParts<S> for I18nPath<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
Path<T>: FromRequestParts<S, Rejection = PathRejection>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
match Path::<T>::from_request_parts(parts, state).await {
|
||||
Ok(Path(value)) => Ok(Self(value)),
|
||||
Err(rejection) => Err(map_path_rejection(rejection)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 🆕 JSON 或 Query 参数提取器
|
||||
///
|
||||
/// 支持两种输入方式:
|
||||
/// 1. JSON body: `{"project_id": "xxx"}`
|
||||
/// 2. Query params: `?project_id=xxx`
|
||||
///
|
||||
/// 如果两者同时存在,优先使用 JSON body。
|
||||
///
|
||||
/// 适用于需要同时兼容 GET(query)和 POST(body)两种调用方式的接口。
|
||||
pub struct I18nJsonOrQuery<T>(pub T);
|
||||
|
||||
impl<S, T> FromRequest<S> for I18nJsonOrQuery<T>
|
||||
where
|
||||
S: Send + Sync,
|
||||
T: DeserializeOwned + serde::Serialize + Send,
|
||||
Json<T>: FromRequest<S, Rejection = JsonRejection>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
// 1. 使用 http::Uri 提取 query string
|
||||
let uri: Uri = req.uri().clone();
|
||||
let query_string = uri.query().unwrap_or("");
|
||||
|
||||
// 2. 解析 query string 为 serde_json::Value
|
||||
let query_value: serde_json::Value = if query_string.is_empty() {
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
} else {
|
||||
// 使用 serde_urlencoded 解析为 HashMap,然后转为 Value
|
||||
match serde_urlencoded::from_str::<HashMap<String, serde_json::Value>>(query_string) {
|
||||
Ok(map) => serde_json::Value::Object(map.into_iter().collect()),
|
||||
Err(_) => {
|
||||
return Err(AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 3. 尝试解析 JSON body
|
||||
match Json::<T>::from_request(req, state).await {
|
||||
Ok(Json(json_value)) => {
|
||||
// 4. JSON 存在,合并两者(JSON 优先)
|
||||
let json_value_as_value = serde_json::to_value(&json_value)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let merged = deep_merge(query_value, json_value_as_value);
|
||||
// 反序列化为 T(只需要 DeserializeOwned)
|
||||
let result = serde_json::from_value(merged)
|
||||
.map_err(|_| AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
))?;
|
||||
Ok(Self(result))
|
||||
}
|
||||
Err(_) => {
|
||||
// 5. JSON 解析失败,检查 query string 是否为空
|
||||
if query_string.is_empty() {
|
||||
// 两者都为空,返回参数错误
|
||||
return Err(AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
));
|
||||
}
|
||||
// 使用 query string 反序列化
|
||||
let result = serde_json::from_value(query_value)
|
||||
.map_err(|_| AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
))?;
|
||||
Ok(Self(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> I18nJsonOrQuery<T>
|
||||
where
|
||||
T: garde::Validate,
|
||||
T::Context: Default,
|
||||
{
|
||||
/// 校验并转换为 AppError
|
||||
///
|
||||
/// 使用方法:
|
||||
/// ```ignore
|
||||
/// let I18nJsonOrQuery(request) = I18nJsonOrQuery(request).validate_into_app_error()?;
|
||||
/// ```
|
||||
pub fn validate_into_app_error(self) -> Result<Self, AppError> {
|
||||
self.0.validate().map_err(shared_types::garde_err_to_app_error)?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 深度合并两个 JSON 对象
|
||||
///
|
||||
/// base: 基础值(query string)
|
||||
/// override: 覆盖值(JSON body)
|
||||
///
|
||||
/// 规则:override 中的非 null 值会覆盖 base 中的对应值
|
||||
fn deep_merge(base: serde_json::Value, override_: serde_json::Value) -> serde_json::Value {
|
||||
match (base, override_) {
|
||||
(serde_json::Value::Object(mut base_map), serde_json::Value::Object(override_map)) => {
|
||||
for (key, override_value) in override_map {
|
||||
let base_value = base_map.remove(&key).unwrap_or(serde_json::Value::Null);
|
||||
let merged_value = if override_value.is_null() {
|
||||
// override 值为 null,使用 base 的值
|
||||
base_value
|
||||
} else if override_value.is_object() && base_value.is_object() {
|
||||
// 两者都是对象,递归合并
|
||||
deep_merge(base_value, override_value)
|
||||
} else {
|
||||
// override 有值(非 null 且不是对象),直接使用 override
|
||||
override_value
|
||||
};
|
||||
base_map.insert(key, merged_value);
|
||||
}
|
||||
serde_json::Value::Object(base_map)
|
||||
}
|
||||
// 如果 override 不是对象,直接使用 override
|
||||
(_, override_) => override_,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Option<String> 中安全提取非空字符串
|
||||
pub fn require_field<'a>(value: &'a Option<String>, field_name: &str) -> Result<&'a str, AppError> {
|
||||
value
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.as_str())
|
||||
.ok_or_else(|| AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_VALIDATION,
|
||||
&format!("{} is required", field_name),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_json_rejection(rejection: JsonRejection) -> AppError {
|
||||
use shared_types::error_codes::{ERR_INVALID_PARAMS, ERR_VALIDATION};
|
||||
|
||||
match rejection {
|
||||
JsonRejection::JsonDataError(_) => {
|
||||
AppError::with_i18n_key(ERR_VALIDATION, "error.validation")
|
||||
}
|
||||
JsonRejection::JsonSyntaxError(_)
|
||||
| JsonRejection::MissingJsonContentType(_)
|
||||
| JsonRejection::BytesRejection(_) => {
|
||||
AppError::with_i18n_key(ERR_INVALID_PARAMS, "error.invalid_params")
|
||||
}
|
||||
_ => AppError::with_i18n_key(ERR_INVALID_PARAMS, "error.invalid_params"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_query_rejection(_rejection: QueryRejection) -> AppError {
|
||||
AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
)
|
||||
}
|
||||
|
||||
fn map_path_rejection(_rejection: PathRejection) -> AppError {
|
||||
AppError::with_i18n_key(
|
||||
shared_types::error_codes::ERR_INVALID_PARAMS,
|
||||
"error.invalid_params",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::to_bytes,
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{I18nJson, I18nJsonOrQuery, I18nPath, I18nQuery};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Payload {
|
||||
value: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryParams {
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
/// 模拟 StopAgentQuery 结构体
|
||||
/// 注意:project_id 使用 Option 以测试缺失字段的情况
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
struct StopAgentQuery {
|
||||
#[serde(default)]
|
||||
project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pod_id: Option<String>,
|
||||
#[serde(default)]
|
||||
tenant_id: Option<String>,
|
||||
#[serde(default)]
|
||||
space_id: Option<String>,
|
||||
#[serde(default)]
|
||||
isolation_type: Option<String>,
|
||||
}
|
||||
|
||||
async fn json_handler(I18nJson(payload): I18nJson<Payload>) -> impl IntoResponse {
|
||||
payload.value.to_string()
|
||||
}
|
||||
|
||||
async fn query_handler(I18nQuery(params): I18nQuery<QueryParams>) -> impl IntoResponse {
|
||||
params.limit.to_string()
|
||||
}
|
||||
|
||||
async fn path_handler(I18nPath(id): I18nPath<u32>) -> impl IntoResponse {
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
/// I18nJsonOrQuery 测试处理器
|
||||
async fn stop_handler(I18nJsonOrQuery(params): I18nJsonOrQuery<StopAgentQuery>) -> impl IntoResponse {
|
||||
format!("project_id={}", params.project_id.unwrap_or_default())
|
||||
}
|
||||
|
||||
// ==================== I18nJsonOrQuery 测试 ====================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_with_only_json_body() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"project_id": "test_project"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"project_id=test_project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_with_only_query_string() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop?project_id=test_project")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"project_id=test_project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_with_json_body_and_query_string_prefers_json() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
// 发送 JSON body 和 query string,JSON 应该优先
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop?project_id=from_query")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"project_id": "from_json"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"project_id=from_json");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_with_query_string_and_all_fields() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop?project_id=proj123&pod_id=pod456&tenant_id=tenant789")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&body[..], b"project_id=proj123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_empty_body_and_empty_query_returns_error() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 应该返回 BAD_REQUEST 因为既没有 JSON body 也没有有效的 query string
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_invalid_json_returns_error() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"project_id":}"#)) // invalid JSON
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_or_query_missing_project_id_in_json() {
|
||||
let app = Router::new().route("/stop", post(stop_handler));
|
||||
|
||||
// JSON body 缺少 project_id 字段,但 serde 会成功解析(project_id 是 String 类型会默认null)
|
||||
// 不过 StopAgentQuery 的 project_id 是必填字段,这里用 empty string 测试
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/stop")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 这里会成功解析,但 project_id 为空字符串
|
||||
// 实际的验证逻辑在 handler 中,不在 extractor 中
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
// ==================== 原有测试 ====================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_json_rejection_returns_bad_request() {
|
||||
let app = Router::new().route("/json", post(json_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/json")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"value":"oops"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_query_rejection_returns_bad_request() {
|
||||
let app = Router::new().route("/query", get(query_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/query?limit=abc")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_i18n_path_rejection_returns_bad_request() {
|
||||
let app = Router::new().route("/path/{id}", get(path_handler));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/path/not-a-number")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
125
qiming-rcoder/crates/rcoder/src/handler/utils/locale.rs
Normal file
125
qiming-rcoder/crates/rcoder/src/handler/utils/locale.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
//! 语言检测工具
|
||||
//!
|
||||
//! 从 HTTP 请求中提取语言偏好
|
||||
//! 优先级:HTTP Header > 环境变量 > 默认值 (en-US)
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use shared_types::{SUPPORTED_LOCALES, parse_accept_language};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 默认语言(硬编码回退值)
|
||||
const FALLBACK_LOCALE: &str = "en-US";
|
||||
|
||||
/// 缓存的环境变量默认语言
|
||||
static DEFAULT_LOCALE_CACHED: OnceLock<&'static str> = OnceLock::new();
|
||||
|
||||
/// 从环境变量获取默认语言(带缓存)
|
||||
///
|
||||
/// 环境变量名:DEFAULT_LOCALE
|
||||
/// 如果未设置或无效,返回硬编码默认值 "en-US"
|
||||
///
|
||||
/// 使用 OnceLock 缓存,只在首次调用时读取环境变量
|
||||
fn get_default_locale_from_env() -> &'static str {
|
||||
*DEFAULT_LOCALE_CACHED.get_or_init(|| {
|
||||
if let Ok(locale) = std::env::var("DEFAULT_LOCALE") {
|
||||
// 验证是否为支持的语言
|
||||
if SUPPORTED_LOCALES.contains(&locale.as_str()) {
|
||||
// 匹配已知的静态字符串,避免内存泄漏
|
||||
match locale.as_str() {
|
||||
"zh-CN" => return "zh-CN",
|
||||
"zh-TW" => return "zh-TW",
|
||||
"en-US" => return "en-US",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
FALLBACK_LOCALE
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 HTTP 请求头获取语言
|
||||
///
|
||||
/// 语言检测优先级:
|
||||
/// 1. HTTP Accept-Language Header
|
||||
/// 2. 环境变量 DEFAULT_LOCALE
|
||||
/// 3. 硬编码默认值 "en-US"
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `headers` - HTTP 请求头
|
||||
///
|
||||
/// # Returns
|
||||
/// 语言代码,如 "zh-CN", "en-US"
|
||||
pub fn get_locale_from_headers(headers: &HeaderMap) -> &'static str {
|
||||
// 1. 首先尝试从 HTTP Header 获取
|
||||
if let Some(header) = headers.get("Accept-Language").and_then(|v| v.to_str().ok()) {
|
||||
// parse_accept_language 返回的总是支持的语言或默认值
|
||||
return parse_accept_language(Some(header));
|
||||
}
|
||||
|
||||
// 2. 尝试从环境变量获取(已缓存)
|
||||
get_default_locale_from_env()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::header::HeaderName;
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_zh_cn() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"zh-CN".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(get_locale_from_headers(&headers), "zh-CN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_zh_tw() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"zh-TW".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(get_locale_from_headers(&headers), "zh-TW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_zh_hk() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"zh-HK".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(get_locale_from_headers(&headers), "zh-TW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_en_us() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"en-US".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(get_locale_from_headers(&headers), "en-US");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_none() {
|
||||
let headers = HeaderMap::new();
|
||||
// 无 Header 且无环境变量时,应返回默认值 en-US
|
||||
assert_eq!(get_locale_from_headers(&headers), "en-US");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_locale_from_headers_unsupported() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
HeaderName::from_static("accept-language"),
|
||||
"fr-FR".parse().unwrap(),
|
||||
);
|
||||
// 不支持的语言应返回默认值
|
||||
assert_eq!(get_locale_from_headers(&headers), "en-US");
|
||||
}
|
||||
}
|
||||
15
qiming-rcoder/crates/rcoder/src/handler/utils/mod.rs
Normal file
15
qiming-rcoder/crates/rcoder/src/handler/utils/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Handler 工具模块
|
||||
//!
|
||||
//! 提供 handler 层共享的工具函数和常量。
|
||||
|
||||
mod grpc_addr;
|
||||
mod i18n_extractors;
|
||||
mod locale;
|
||||
mod paths;
|
||||
|
||||
pub use grpc_addr::{
|
||||
extract_grpc_addr, extract_grpc_addr_with_port, get_realtime_container_ip,
|
||||
};
|
||||
pub use i18n_extractors::{I18nJson, I18nJsonOrQuery, I18nPath, I18nQuery, require_field};
|
||||
pub use locale::get_locale_from_headers;
|
||||
pub use paths::{COMPUTER_WORKSPACE_ROOT, WORKSPACE_ROOT, build_workspace_path, build_computer_workspace_path, project_dir, user_dir};
|
||||
244
qiming-rcoder/crates/rcoder/src/handler/utils/paths.rs
Normal file
244
qiming-rcoder/crates/rcoder/src/handler/utils/paths.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
//! Computer Use 模式的路径常量和辅助函数
|
||||
//!
|
||||
//! 统一管理容器内的项目工作空间路径,避免硬编码分散在各处。
|
||||
|
||||
/// 容器内 RCoder 项目工作空间的根目录
|
||||
///
|
||||
/// 容器内的目录结构(isolation_type=project):
|
||||
/// ```text
|
||||
/// /app/project_workspace/
|
||||
/// └── {project_id}/
|
||||
/// └── (项目文件)
|
||||
/// ```
|
||||
///
|
||||
/// 容器内的目录结构(isolation_type=tenant/space):
|
||||
/// ```text
|
||||
/// /app/project_workspace/
|
||||
/// └── {tenant_id}/
|
||||
/// └── {space_id}/
|
||||
/// └── {project_id}/
|
||||
/// └── (项目文件)
|
||||
/// ```
|
||||
pub const WORKSPACE_ROOT: &str = "/app/project_workspace";
|
||||
|
||||
/// 容器内 Computer Use 项目工作空间的根目录
|
||||
///
|
||||
/// 容器内的目录结构(isolation_type=project):
|
||||
/// ```text
|
||||
/// /app/computer-project-workspace/
|
||||
/// └── {user_id}/
|
||||
/// └── {project_id}/
|
||||
/// └── (项目文件)
|
||||
/// ```
|
||||
///
|
||||
/// 容器内的目录结构(isolation_type=tenant/space):
|
||||
/// ```text
|
||||
/// /app/computer-project-workspace/
|
||||
/// └── {tenant_id}/
|
||||
/// └── {space_id}/
|
||||
/// └── {project_id}/
|
||||
/// └── (项目文件)
|
||||
/// ```
|
||||
pub const COMPUTER_WORKSPACE_ROOT: &str = "/app/computer-project-workspace";
|
||||
|
||||
/// 构建用户目录路径(Computer Use 模式)
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let path = user_dir("user123");
|
||||
/// assert_eq!(path, "/app/computer-project-workspace/user123");
|
||||
/// ```
|
||||
pub fn user_dir(user_id: &str) -> String {
|
||||
format!("{}/{}", COMPUTER_WORKSPACE_ROOT, user_id)
|
||||
}
|
||||
|
||||
/// 构建项目目录路径(Computer Use 模式,project 隔离)
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let path = project_dir("user123", "project456");
|
||||
/// assert_eq!(path, "/app/computer-project-workspace/user123/project456");
|
||||
/// ```
|
||||
pub fn project_dir(user_id: &str, project_id: &str) -> String {
|
||||
format!("{}/{}/{}", COMPUTER_WORKSPACE_ROOT, user_id, project_id)
|
||||
}
|
||||
|
||||
/// 根据隔离类型构建 RCoder 工作空间路径
|
||||
///
|
||||
/// # 参数
|
||||
/// - `isolation_type`: 隔离类型,可选值为 "tenant"、"space"、"project"
|
||||
/// - `tenant_id`: 租户 ID(当 isolation_type 为 tenant 或 space 时必需)
|
||||
/// - `space_id`: 空间 ID(当 isolation_type 为 tenant 或 space 时必需)
|
||||
/// - `project_id`: 项目 ID(必需)
|
||||
///
|
||||
/// # 返回
|
||||
/// 拼接后的容器内路径
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// // project 隔离(默认)
|
||||
/// build_workspace_path(Some("project"), None, None, "proj_123")
|
||||
/// // 返回: "/app/project_workspace/proj_123"
|
||||
///
|
||||
/// // tenant 隔离
|
||||
/// build_workspace_path(Some("tenant"), Some("t1"), Some("s1"), "proj_123")
|
||||
/// // 返回: "/app/project_workspace/t1/s1/proj_123"
|
||||
/// ```
|
||||
pub fn build_workspace_path(
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
project_id: &str,
|
||||
) -> String {
|
||||
// 大小写不敏感:统一转小写后匹配
|
||||
let normalized = isolation_type.map(|s| s.to_lowercase());
|
||||
match normalized.as_deref() {
|
||||
Some("tenant") | Some("space") => {
|
||||
// tenant/space: /app/project_workspace/{tenant_id}/{space_id}/{project_id}
|
||||
let tid = tenant_id.unwrap_or("default");
|
||||
let sid = space_id.unwrap_or("default");
|
||||
format!("{}/{}/{}/{}", WORKSPACE_ROOT, tid, sid, project_id)
|
||||
}
|
||||
_ => {
|
||||
// project (默认): /app/project_workspace/{project_id}
|
||||
format!("{}/{}", WORKSPACE_ROOT, project_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据隔离类型构建 Computer 工作空间路径
|
||||
///
|
||||
/// # 参数
|
||||
/// - `isolation_type`: 隔离类型,可选值为 "tenant"、"space"、"project"
|
||||
/// - `tenant_id`: 租户 ID(当 isolation_type 为 tenant 或 space 时必需)
|
||||
/// - `space_id`: 空间 ID(当 isolation_type 为 tenant 或 space 时必需)
|
||||
/// - `user_id`: 用户 ID(当 isolation_type 为 project 时使用)
|
||||
/// - `project_id`: 项目 ID(必需)
|
||||
///
|
||||
/// # 返回
|
||||
/// 拼接后的容器内路径
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// // project 隔离(默认)
|
||||
/// build_computer_workspace_path(Some("project"), None, None, "user_123", "proj_456")
|
||||
/// // 返回: "/app/computer-project-workspace/user_123/proj_456"
|
||||
///
|
||||
/// // tenant 隔离
|
||||
/// build_computer_workspace_path(Some("tenant"), Some("t1"), Some("s1"), "user_123", "proj_456")
|
||||
/// // 返回: "/app/computer-project-workspace/t1/s1/proj_456"
|
||||
/// ```
|
||||
pub fn build_computer_workspace_path(
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
) -> String {
|
||||
// 大小写不敏感:统一转小写后匹配
|
||||
let normalized = isolation_type.map(|s| s.to_lowercase());
|
||||
match normalized.as_deref() {
|
||||
Some("tenant") | Some("space") => {
|
||||
// tenant/space: /app/computer-project-workspace/{tenant_id}/{space_id}/{project_id}
|
||||
let tid = tenant_id.unwrap_or("default");
|
||||
let sid = space_id.unwrap_or("default");
|
||||
format!("{}/{}/{}/{}", COMPUTER_WORKSPACE_ROOT, tid, sid, project_id)
|
||||
}
|
||||
_ => {
|
||||
// project (默认): /app/computer-project-workspace/{user_id}/{project_id}
|
||||
format!("{}/{}/{}", COMPUTER_WORKSPACE_ROOT, user_id, project_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_user_dir() {
|
||||
assert_eq!(
|
||||
user_dir("user123"),
|
||||
"/app/computer-project-workspace/user123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_dir() {
|
||||
assert_eq!(
|
||||
project_dir("user123", "project456"),
|
||||
"/app/computer-project-workspace/user123/project456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_path_project() {
|
||||
// project 隔离(默认)
|
||||
assert_eq!(
|
||||
build_workspace_path(None, None, None, "proj_123"),
|
||||
"/app/project_workspace/proj_123"
|
||||
);
|
||||
assert_eq!(
|
||||
build_workspace_path(Some("project"), None, None, "proj_123"),
|
||||
"/app/project_workspace/proj_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_path_tenant() {
|
||||
// tenant 隔离
|
||||
assert_eq!(
|
||||
build_workspace_path(Some("tenant"), Some("t1"), Some("s1"), "proj_123"),
|
||||
"/app/project_workspace/t1/s1/proj_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_path_space() {
|
||||
// space 隔离
|
||||
assert_eq!(
|
||||
build_workspace_path(Some("space"), Some("t1"), Some("s1"), "proj_123"),
|
||||
"/app/project_workspace/t1/s1/proj_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_workspace_path_defaults() {
|
||||
// tenant/space 模式下使用默认值
|
||||
assert_eq!(
|
||||
build_workspace_path(Some("tenant"), None, None, "proj_123"),
|
||||
"/app/project_workspace/default/default/proj_123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_computer_workspace_path_project() {
|
||||
// project 隔离(默认)
|
||||
assert_eq!(
|
||||
build_computer_workspace_path(None, None, None, "user_123", "proj_456"),
|
||||
"/app/computer-project-workspace/user_123/proj_456"
|
||||
);
|
||||
assert_eq!(
|
||||
build_computer_workspace_path(Some("project"), None, None, "user_123", "proj_456"),
|
||||
"/app/computer-project-workspace/user_123/proj_456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_computer_workspace_path_tenant() {
|
||||
// tenant 隔离
|
||||
assert_eq!(
|
||||
build_computer_workspace_path(Some("tenant"), Some("t1"), Some("s1"), "user_123", "proj_456"),
|
||||
"/app/computer-project-workspace/t1/s1/proj_456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_computer_workspace_path_space() {
|
||||
// space 隔离
|
||||
assert_eq!(
|
||||
build_computer_workspace_path(Some("space"), Some("t1"), Some("s1"), "user_123", "proj_456"),
|
||||
"/app/computer-project-workspace/t1/s1/proj_456"
|
||||
);
|
||||
}
|
||||
}
|
||||
27
qiming-rcoder/crates/rcoder/src/lib.rs
Normal file
27
qiming-rcoder/crates/rcoder/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! rcoder 库
|
||||
//!
|
||||
//! 提供 ACP 协议集成和 AI 代理管理功能
|
||||
|
||||
pub mod cleanup_task;
|
||||
mod config;
|
||||
pub mod grpc;
|
||||
mod handler;
|
||||
pub mod middleware;
|
||||
pub mod router;
|
||||
mod service;
|
||||
pub mod storage;
|
||||
mod utils;
|
||||
pub mod vnc;
|
||||
|
||||
// 重新导出主要的类型和函数
|
||||
pub use storage::{DataBridge, ProjectAdapter};
|
||||
pub use utils::*;
|
||||
|
||||
// 重新导出 shared_types 中的类型
|
||||
pub use shared_types::{
|
||||
AgentSessionUpdate, AgentStatus, AgentStatusResponse, AppError, Attachment, AttachmentError,
|
||||
AttachmentSource, AudioAttachment, CancelNotificationResponse, ChatPrompt, ChatPromptResponse,
|
||||
ChatResponse, DocumentAttachment, HttpResult, ImageAttachment, ImageDimensions,
|
||||
ModelProviderConfig, ModelProviderSafeInfo, ProjectAndAgentInfo, SessionMessageType,
|
||||
SessionNotify, SessionPromptEnd, SessionPromptStart, TextAttachment, UnifiedSessionMessage,
|
||||
};
|
||||
767
qiming-rcoder/crates/rcoder/src/main.rs
Normal file
767
qiming-rcoder/crates/rcoder/src/main.rs
Normal file
@@ -0,0 +1,767 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use clap::Parser;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use hyper::server::conn::http1;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use hyper_util::service::TowerToHyperService;
|
||||
|
||||
// 🆕 使用共享的遥测模块
|
||||
use rcoder_telemetry::{TelemetryConfig, TelemetryGuard};
|
||||
|
||||
mod config;
|
||||
mod config_watcher;
|
||||
mod handler;
|
||||
|
||||
mod cleanup_task;
|
||||
mod middleware;
|
||||
mod router;
|
||||
mod service;
|
||||
mod utils;
|
||||
|
||||
use rcoder::*;
|
||||
|
||||
use config::{CliArgs, load_config_with_args};
|
||||
use rcoder_proxy::{PingoraServerManager, ProxyConfig};
|
||||
use router::AppState;
|
||||
use service::{
|
||||
ContainerStatusCheckerConfig, ContainerSyncConfig, VncSyncConfig,
|
||||
start_container_status_checker, start_container_sync_task, start_vnc_sync_task,
|
||||
};
|
||||
|
||||
// 导入统一的容器停止模块
|
||||
use docker_manager::container_stop;
|
||||
use docker_manager::runtime_selection::RuntimeType;
|
||||
|
||||
// 路由创建函数已移动到 handler 模块
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
// ✅ 初始化 Rustls CryptoProvider(必须在最前面,在任何可能使用 TLS 的代码之前)
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
|
||||
// 解析命令行参数(移到最前面,以便尽早加载配置)
|
||||
let cli_args = CliArgs::parse();
|
||||
|
||||
// 加载配置(包含命令行参数)
|
||||
let config = load_config_with_args(cli_args)?;
|
||||
|
||||
// 🆕 Initializing telemetry system(使用 rcoder-telemetry,包含控制台 + 文件日志)
|
||||
// 使用配置文件中的日志保留天数,与容器日志清理保持一致
|
||||
let file_log_config = rcoder_telemetry::FileLogConfig::new("logs", "rcoder")
|
||||
.with_max_files(config.cleanup_config.log_cleanup.log_retention_days as usize);
|
||||
|
||||
let telemetry_config =
|
||||
TelemetryConfig::from_env("rcoder").with_file_log_config(file_log_config);
|
||||
let telemetry: TelemetryGuard = rcoder_telemetry::init(telemetry_config).await?;
|
||||
let telemetry = Arc::new(telemetry);
|
||||
|
||||
info!("Starting rcoder - AI-powered development platform");
|
||||
info!(
|
||||
"📋 Log config: keeping log files for {} days",
|
||||
config.cleanup_config.log_cleanup.log_retention_days
|
||||
);
|
||||
|
||||
// 创建项目工作目录
|
||||
tokio::fs::create_dir_all(&config.projects_dir).await?;
|
||||
info!("Projects directory: {:?}", config.projects_dir);
|
||||
|
||||
// 🔄 初始化宿主机路径解析器(自动检测模式)
|
||||
// K8s 模式下不需要 Docker socket,跳过路径解析器初始化
|
||||
info!("starting to detect mount path...");
|
||||
let runtime_type = docker_manager::runtime_selection::RuntimeType::from_env();
|
||||
|
||||
if runtime_type == docker_manager::runtime_selection::RuntimeType::Kubernetes {
|
||||
info!("[K8S] Kubernetes runtime mode, skipping Docker socket path resolver");
|
||||
} else {
|
||||
let docker_socket_path = std::env::var("DOCKER_SOCKET_PATH").unwrap_or_else(|_| {
|
||||
info!("DOCKER_SOCKET_PATH not set, using default: /var/run/docker.sock");
|
||||
"/var/run/docker.sock".to_string()
|
||||
});
|
||||
|
||||
info!("Docker socket: {}", docker_socket_path);
|
||||
|
||||
let _path_resolver =
|
||||
match utils::HostPathResolver::new_with_docker_socket(Some(docker_socket_path.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(resolver) => {
|
||||
info!("path resolver initialized successfully");
|
||||
info!(
|
||||
" Container workspace: {:?}",
|
||||
resolver.container_workspace_base()
|
||||
);
|
||||
info!(
|
||||
"work directory: {:?}",
|
||||
resolver.host_workspace_base()
|
||||
);
|
||||
Some(resolver)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("path resolver initialization failed: {}", e);
|
||||
error!("please check config:");
|
||||
error!("1. Docker socket path: {}", docker_socket_path);
|
||||
error!("2. Docker socket already mounted in container");
|
||||
error!("3. container has Docker API access");
|
||||
error!("4. project work directory mounted");
|
||||
|
||||
// 显示详细的错误信息和解决建议
|
||||
show_docker_configuration_help(&docker_socket_path);
|
||||
|
||||
// 返回错误,停止启动
|
||||
return Err(anyhow::anyhow!("Container self-check failed, unable to initialize path resolver"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 🧹 启动时清理上次可能遗留的容器
|
||||
// 先使用正确配置初始化全局 DockerManager
|
||||
info!("initialize Docker Manager (with config)...");
|
||||
|
||||
// 从应用配置创建 DockerManagerConfig
|
||||
let docker_manager_config = if let Some(docker_config) = &config.docker_config {
|
||||
info!("using Docker config, merging config");
|
||||
let mut default_config = docker_manager::DockerManagerConfig::default();
|
||||
|
||||
// 合并应用配置中的多镜像配置
|
||||
let app_multi_config = docker_config.get_multi_image_config();
|
||||
default_config.multi_image_config = app_multi_config;
|
||||
|
||||
// 应用其他配置
|
||||
default_config.auto_cleanup = docker_config
|
||||
.auto_cleanup
|
||||
.unwrap_or(default_config.auto_cleanup);
|
||||
if let Some(ttl) = docker_config.container_ttl_seconds {
|
||||
default_config.container_ttl_seconds = Some(ttl);
|
||||
}
|
||||
|
||||
// 应用网络基础名称配置
|
||||
info!(
|
||||
"🔍 [DEBUG] docker_config.network_base_name = {:?}",
|
||||
docker_config.network_base_name
|
||||
);
|
||||
if let Some(ref network_base_name) = docker_config.network_base_name {
|
||||
info!("using config: {}", network_base_name);
|
||||
default_config.network_base_name = network_base_name.clone();
|
||||
} else {
|
||||
info!(
|
||||
"⚠️ No network_base_name in config, using default: {}",
|
||||
default_config.network_base_name
|
||||
);
|
||||
}
|
||||
|
||||
// 🔧 应用超时配置
|
||||
if let Some(timeout) = docker_config.api_timeout_seconds {
|
||||
default_config.api_timeout_seconds = timeout;
|
||||
info!("using config: API timeout: {} seconds", timeout);
|
||||
}
|
||||
if let Some(timeout) = docker_config.api_timeout_quick_seconds {
|
||||
default_config.api_timeout_quick_seconds = timeout;
|
||||
info!("using config: timeout: {} seconds", timeout);
|
||||
}
|
||||
|
||||
// 🔧 应用缓存 TTL 配置
|
||||
if let Some(ttl) = docker_config.cache_status_ttl_seconds {
|
||||
default_config.cache_status_ttl_seconds = ttl;
|
||||
info!(
|
||||
"using config: status cache TTL: {} seconds",
|
||||
ttl
|
||||
);
|
||||
}
|
||||
if let Some(ttl) = docker_config.cache_network_ttl_seconds {
|
||||
default_config.cache_network_ttl_seconds = ttl;
|
||||
info!("using config: network cache TTL: {} seconds", ttl);
|
||||
}
|
||||
if let Some(capacity) = docker_config.cache_max_capacity {
|
||||
default_config.cache_max_capacity = capacity as u64;
|
||||
info!("using config: cache max capacity: {}", capacity);
|
||||
}
|
||||
|
||||
default_config
|
||||
} else {
|
||||
info!("⚠️ no Docker config, using default config");
|
||||
docker_manager::DockerManagerConfig::default()
|
||||
};
|
||||
|
||||
// 使用自定义配置初始化全局 DockerManager
|
||||
if let Err(e) =
|
||||
docker_manager::global::init_global_docker_manager_with_config(docker_manager_config).await
|
||||
{
|
||||
error!("Docker Manager initializefailed: {}", e);
|
||||
return Err(anyhow::anyhow!("Docker Manager initialization failed: {}", e));
|
||||
}
|
||||
|
||||
info!("checking cleanup for container (enabled)...");
|
||||
if config.cleanup_config.enabled {
|
||||
match docker_manager::runtime::RuntimeManager::runtime_type() {
|
||||
RuntimeType::Docker => {
|
||||
let docker_manager = match docker_manager::global::get_global_docker_manager().await {
|
||||
Ok(dm) => {
|
||||
info!("Docker Manager initialized successfully (with config)");
|
||||
dm
|
||||
}
|
||||
Err(e) => {
|
||||
error!("get Docker Manager failed: {}", e);
|
||||
return Err(anyhow::anyhow!("Failed to get Docker Manager: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
let multi_image_config = if let Some(docker_config) = &config.docker_config {
|
||||
docker_config.get_multi_image_config()
|
||||
} else {
|
||||
shared_types::create_default_multi_image_config()
|
||||
};
|
||||
|
||||
match container_stop::startup_cleanup_all_enabled_services(
|
||||
&docker_manager,
|
||||
&multi_image_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let enabled_services = shared_types::get_enabled_service_types(&multi_image_config);
|
||||
if result.successfully_removed > 0 {
|
||||
info!(
|
||||
"✅ Startup cleanup completed, removed {} leftover containers (covering {} service types)",
|
||||
result.successfully_removed,
|
||||
enabled_services.len()
|
||||
);
|
||||
} else {
|
||||
info!("no containers to cleanup");
|
||||
}
|
||||
|
||||
if result.failed_removals > 0 {
|
||||
warn!(
|
||||
"container cleanup failed: failed count={}",
|
||||
result.failed_removals
|
||||
);
|
||||
for failure in &result.failed_removals_details {
|
||||
warn!(
|
||||
" - Container {} ({}): {}",
|
||||
failure.container_id, failure.container_name, failure.error_message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("container cleanup failed: {}, cleanup skipped", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
RuntimeType::Kubernetes => {
|
||||
match docker_manager::runtime::RuntimeManager::get().await {
|
||||
Ok(runtime) => {
|
||||
if let Err(e) = runtime.cleanup_all().await {
|
||||
warn!("k8s startup cleanup failed: {}", e);
|
||||
} else {
|
||||
info!("k8s startup cleanup completed");
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("failed to get runtime for k8s startup cleanup: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!("Container cleanup task already started (cleanup_config.enabled=false)");
|
||||
}
|
||||
|
||||
// 从配置文件读取清理配置
|
||||
let cleanup_config = cleanup_task::CleanupConfig {
|
||||
idle_timeout: Duration::from_secs(config.cleanup_config.idle_timeout_seconds),
|
||||
cleanup_interval: Duration::from_secs(config.cleanup_config.cleanup_interval_seconds),
|
||||
docker_stop_timeout: Duration::from_secs(config.cleanup_config.docker_stop_timeout_seconds),
|
||||
container_protection_duration: Duration::from_secs(
|
||||
config.cleanup_config.container_protection_seconds,
|
||||
),
|
||||
active_window: Duration::from_secs(5 * 60),
|
||||
log_dir: config.cleanup_config.log_cleanup.log_dir.clone(),
|
||||
log_retention_duration: Duration::from_secs(
|
||||
config.cleanup_config.log_cleanup.log_retention_days * 24 * 60 * 60,
|
||||
),
|
||||
};
|
||||
info!(
|
||||
"🧹 Cleanup config: idle_timeout={}s, cleanup_interval={}s, docker_stop_timeout={}s, container_protection={}s, log_dir={}, log_retention={}days",
|
||||
config.cleanup_config.idle_timeout_seconds,
|
||||
config.cleanup_config.cleanup_interval_seconds,
|
||||
config.cleanup_config.docker_stop_timeout_seconds,
|
||||
config.cleanup_config.container_protection_seconds,
|
||||
config.cleanup_config.log_cleanup.log_dir,
|
||||
config.cleanup_config.log_cleanup.log_retention_days
|
||||
);
|
||||
|
||||
// proxy_manager 不需要直接访问 app_state,通过参数传递即可
|
||||
|
||||
// 🆕 创建 API Key 配置的共享引用(用于热更新)
|
||||
// 使用 ArcSwap 实现无锁读取,提升并发性能
|
||||
let api_key_config = Arc::new(ArcSwap::from_pointee(config.api_key_auth.clone()));
|
||||
|
||||
// 启动代理服务(如果启用)
|
||||
let (proxy_handle, pingora_service_opt, _pingora_shutdown_tx) = if let Some(proxy_config) =
|
||||
&config.proxy_config
|
||||
{
|
||||
info!(
|
||||
"Starting Pingora reverse proxy service, listening on port: {}",
|
||||
proxy_config.listen_port
|
||||
);
|
||||
info!(
|
||||
"Proxy route format: /proxy/{{port}}{{/path}} - e.g.: /proxy/{}/health",
|
||||
config.port
|
||||
);
|
||||
|
||||
// 添加调试日志
|
||||
info!("🔧 [Pingora] startinginitialize Pingora config...");
|
||||
info!("🔧 [Pingora] listenport: {}", proxy_config.listen_port);
|
||||
info!(
|
||||
"🔧 [Pingora] Default backend port: {}",
|
||||
proxy_config.default_backend_port
|
||||
);
|
||||
info!("🔧 [Pingora] backend host: {}", proxy_config.backend_host);
|
||||
|
||||
let pingora_config = ProxyConfig {
|
||||
listen_port: proxy_config.listen_port,
|
||||
default_backend_port: proxy_config.default_backend_port,
|
||||
backend_host: proxy_config.backend_host.clone(),
|
||||
port_param: proxy_config.port_param.clone(),
|
||||
config_file: None,
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
info!("[Pingora] Pingora configcreatedsucceeded");
|
||||
|
||||
// 创建 Pingora 服务器管理器,并提取服务引用用于指标读取
|
||||
info!("🔧 [Pingora] created PingoraServerManager...");
|
||||
let mut server_manager = PingoraServerManager::new(pingora_config)
|
||||
.with_api_key_config(Arc::clone(&api_key_config)); // 🆕 传递 API Key 配置
|
||||
let pingora_service = server_manager.service();
|
||||
info!("[Pingora] PingoraServerManager createdsucceeded");
|
||||
info!("[Pingora] API Key config already loaded (no updates)");
|
||||
|
||||
// 启动健康检查循环(按配置)
|
||||
if proxy_config.health_check.enabled {
|
||||
let hc = &proxy_config.health_check;
|
||||
info!(
|
||||
"🔧 [Pingora] Starting health check loop: interval={}s, timeout={}s",
|
||||
hc.interval_seconds, hc.timeout_seconds
|
||||
);
|
||||
pingora_service
|
||||
.start_health_check_loop(hc.interval_seconds, (hc.timeout_seconds * 1000) as u64);
|
||||
info!("[Pingora] health check already started");
|
||||
}
|
||||
|
||||
// 启动 Pingora 服务器(如果启动失败,直接退出程序)
|
||||
info!("[Pingora] starting Pingora server...");
|
||||
let (pingora_shutdown_tx, pingora_shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
info!("📍 [Pingora] calling server_manager.start()...");
|
||||
if let Err(e) = server_manager.start(pingora_shutdown_rx).await {
|
||||
error!(
|
||||
"[Pingora] Pingora proxy start failed, error: {:?}",
|
||||
e
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
info!("[Pingora] server started");
|
||||
});
|
||||
|
||||
info!("[Pingora] already started");
|
||||
|
||||
(
|
||||
Some(handle),
|
||||
Some(pingora_service),
|
||||
Some(pingora_shutdown_tx),
|
||||
)
|
||||
} else {
|
||||
info!("⚠️ [Pingora] proxy_config notconfig, skip Pingora started");
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
// 设置 Ctrl+C 信号处理
|
||||
let shutdown_tx = setup_signal_handlers();
|
||||
let shutdown_rx = shutdown_tx.subscribe();
|
||||
|
||||
// 🆕 启动配置文件监控(支持 API Key 热更新)
|
||||
// 保持 watcher 的所有权,防止被提前 drop
|
||||
let config_path = std::path::PathBuf::from(crate::config::CONFIG_FILE);
|
||||
let _config_watcher =
|
||||
match crate::config_watcher::ConfigWatcher::new(config_path, Arc::clone(&api_key_config)) {
|
||||
Ok(watcher) => {
|
||||
info!("Config file watcher already started, API Key updated");
|
||||
Some(watcher)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"config file watcher start failed: {}, API Key updated",
|
||||
e
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 获取容器前缀(从配置读取,用于 pod_count 和 pod_list)
|
||||
let docker_config = config.docker_config.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Docker config is required for container prefix"))?;
|
||||
let multi_config = docker_config.get_multi_image_config();
|
||||
let selector = docker_manager::image_selector::ImageSelector::new(multi_config);
|
||||
// 使用 block_in_place 在同步上下文中获取异步配置
|
||||
let (container_prefix_rcoder, container_prefix_computer) = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(async {
|
||||
let rcoder_prefix = selector
|
||||
.get_service_config(&shared_types::ServiceType::RCoder)
|
||||
.await
|
||||
.expect("Failed to get RCoder service config")
|
||||
.container_prefix()
|
||||
.to_string();
|
||||
let computer_prefix = selector
|
||||
.get_service_config(&shared_types::ServiceType::ComputerAgentRunner)
|
||||
.await
|
||||
.expect("Failed to get ComputerAgentRunner service config")
|
||||
.container_prefix()
|
||||
.to_string();
|
||||
(rcoder_prefix, computer_prefix)
|
||||
})
|
||||
});
|
||||
|
||||
let state = Arc::new(AppState::new(
|
||||
config.clone(),
|
||||
pingora_service_opt,
|
||||
api_key_config,
|
||||
container_prefix_rcoder,
|
||||
container_prefix_computer,
|
||||
)?);
|
||||
|
||||
// 在主异步运行时中启动清理任务(如果启用)
|
||||
let _cleanup_handle = if config.cleanup_config.enabled {
|
||||
let cleanup_config_clone = cleanup_config.clone();
|
||||
let state_for_cleanup = state.clone();
|
||||
Some(
|
||||
cleanup_task::start_cleanup_task(cleanup_config_clone, state_for_cleanup)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to start cleanup task: {}", e))?,
|
||||
)
|
||||
} else {
|
||||
info!("Container cleanup task already started (cleanup_config.enabled=false)");
|
||||
None
|
||||
};
|
||||
|
||||
// 启动容器状态检查任务(防止长时间任务的容器被误杀)
|
||||
// 🆕 使用增强的配置,包含失败计数器和智能跳过机制
|
||||
let status_checker_config = ContainerStatusCheckerConfig {
|
||||
check_interval: Duration::from_secs(30), // 每 30 秒检查一次
|
||||
query_timeout: Duration::from_secs(5), // 查询超时 5 秒
|
||||
failure_threshold: 3, // 连续失败 3 次后跳过
|
||||
skip_duration: Duration::from_secs(5 * 60), // 跳过 5 分钟
|
||||
health_reset_interval: Duration::from_secs(30 * 60), // 30 分钟清理一次
|
||||
};
|
||||
let _status_checker_handle =
|
||||
start_container_status_checker(status_checker_config, state.clone());
|
||||
info!(
|
||||
"Container status checker already started (interval: 30s, will skip Docker on failure)"
|
||||
);
|
||||
|
||||
// 启动容器状态同步任务(定期检测被外部删除的容器)
|
||||
let container_sync_config = ContainerSyncConfig {
|
||||
sync_interval: Duration::from_secs(60), // 每 60 秒同步一次
|
||||
};
|
||||
let _container_sync_handle = start_container_sync_task(
|
||||
container_sync_config,
|
||||
state.grpc_pool.clone(),
|
||||
);
|
||||
info!(
|
||||
"Container status sync already started (interval: 60s, detect container)"
|
||||
);
|
||||
|
||||
// 🆕 启动 VNC 后端同步任务(定期从 Docker 同步容器 IP 到 Pingora)
|
||||
if let Some(ref pingora_service) = state.pingora_service {
|
||||
let vnc_sync_config = VncSyncConfig {
|
||||
sync_interval: Duration::from_secs(5), // 每 5 秒同步一次
|
||||
};
|
||||
let _vnc_sync_handle = start_vnc_sync_task(
|
||||
pingora_service.clone(),
|
||||
vnc_sync_config,
|
||||
state.container_prefix_rcoder.clone(),
|
||||
state.container_prefix_computer.clone(),
|
||||
);
|
||||
info!(
|
||||
"VNC sync already started (interval: 5s, sync Docker container IP)"
|
||||
);
|
||||
}
|
||||
|
||||
// 创建路由(传入遥测 guard 用于 /metrics 端点)
|
||||
let app = router::create_router(state.clone(), Some(telemetry.clone()));
|
||||
|
||||
// 启动 HTTP 服务器
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("HTTP server failed to bind port {}: {}", config.port, e))?;
|
||||
|
||||
info!("Server starting on port {}", config.port);
|
||||
info!("API endpoints:");
|
||||
info!(" POST /chat - Send chat message to AI agent (legacy)");
|
||||
info!(" GET /progress/:session_id - SSE progress stream for AI tasks (unified stream)");
|
||||
info!(" GET /health - Health check");
|
||||
info!(" NOTE: Plan data is delivered via the unified /progress/{{session_id}} SSE stream");
|
||||
|
||||
if let Some(proxy_config) = &config.proxy_config {
|
||||
info!("Pingora proxy already started");
|
||||
info!("📡 listenport: {}", proxy_config.listen_port);
|
||||
info!("route: /proxy/{{port}}{{/path}} - example: /proxy/3000/api/users");
|
||||
info!("format: query port parameter to proxy request");
|
||||
info!("💡 example:");
|
||||
info!(
|
||||
" http://localhost:{}/proxy/{}/health → http://127.0.0.1:{}/health",
|
||||
proxy_config.listen_port, config.port, config.port
|
||||
);
|
||||
info!(
|
||||
" http://localhost:{}/proxy/{}/health → http://127.0.0.1:{}/health",
|
||||
proxy_config.listen_port, config.port, config.port
|
||||
);
|
||||
info!(
|
||||
" http://localhost:{}/proxy/9000/health → http://127.0.0.1:9000/health (dynamic discovery)",
|
||||
proxy_config.listen_port
|
||||
);
|
||||
}
|
||||
|
||||
// 启动服务器,支持优雅关闭
|
||||
// 使用自定义 Hyper 配置增加请求头大小限制(默认 8KB -> 128KB)
|
||||
info!("🔧 config HTTP max_buf_size = 128KB (to prevent HTTP 431 error)");
|
||||
|
||||
let app = app.into_make_service();
|
||||
let mut shutdown_rx_clone = shutdown_tx.subscribe();
|
||||
|
||||
// 启动自定义 HTTP 服务器
|
||||
let server_handle = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
// 等待关闭信号
|
||||
_ = shutdown_rx_clone.recv() => {
|
||||
info!("🛑 HTTP server closed");
|
||||
break;
|
||||
}
|
||||
// 接受新连接
|
||||
result = listener.accept() => {
|
||||
match result {
|
||||
Ok((stream, addr)) => {
|
||||
let mut app_clone = app.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// 配置 HTTP1,增加 header 大小限制
|
||||
let mut http_builder = http1::Builder::new();
|
||||
http_builder
|
||||
.max_buf_size(128 * 1024) // 128KB buffer(默认约 8KB)
|
||||
.preserve_header_case(true)
|
||||
.title_case_headers(false);
|
||||
|
||||
let io = TokioIo::new(stream);
|
||||
|
||||
// 使用 tower::Service 调用 MakeService
|
||||
use tower::Service;
|
||||
match std::future::poll_fn(|cx| {
|
||||
Service::<std::net::SocketAddr>::poll_ready(&mut app_clone, cx)
|
||||
}).await {
|
||||
Ok(()) => {
|
||||
match Service::<std::net::SocketAddr>::call(&mut app_clone, addr).await {
|
||||
Ok(service) => {
|
||||
let hyper_service = TowerToHyperService::new(service);
|
||||
if let Err(e) = http_builder.serve_connection(io, hyper_service).await {
|
||||
if !e.to_string().contains("connection closed")
|
||||
&& !e.to_string().contains("early eof") {
|
||||
tracing::debug!("HTTP connectionerror ({}): {}", addr, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Infallible 类型,不会发生
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("server error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("connection failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 等待服务器关闭
|
||||
let _ = shutdown_signal(shutdown_rx).await;
|
||||
server_handle.abort();
|
||||
|
||||
// 等待代理服务完成
|
||||
if let Some(handle) = proxy_handle {
|
||||
handle.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 显示 Docker 配置帮助信息
|
||||
fn show_docker_configuration_help(socket_path: &str) {
|
||||
error!("📋 Docker config help:");
|
||||
error!("");
|
||||
error!("add to docker-compose.yml config:");
|
||||
error!("");
|
||||
error!("services:");
|
||||
error!(" rcoder:");
|
||||
error!(" environment:");
|
||||
error!(" - DOCKER_SOCKET_PATH={}", socket_path);
|
||||
error!(" volumes:");
|
||||
error!(" - {}:/var/run/docker.sock:ro", socket_path);
|
||||
error!(" - ./data/rcoder/project_workspace:/app/project_workspace");
|
||||
error!("");
|
||||
error!("🔧 Docker socket path:");
|
||||
error!(" Linux: /var/run/docker.sock");
|
||||
error!(" macOS + Docker Desktop: /var/run/docker.sock");
|
||||
error!(" Rootless Docker: /run/user/$UID/docker.sock");
|
||||
error!("");
|
||||
error!("🛠️ troubleshooting:");
|
||||
error!("1. check Docker: docker ps");
|
||||
error!("2. check socket file exists: ls -l {}", socket_path);
|
||||
error!("3. check docker group: groups $USER | grep docker");
|
||||
error!(
|
||||
" 4. Test Docker API: curl --unix-socket {} http://localhost/info",
|
||||
socket_path
|
||||
);
|
||||
error!("");
|
||||
error!("socket exists, rcoder container may not have access");
|
||||
}
|
||||
|
||||
/// 设置信号处理器
|
||||
fn setup_signal_handlers() -> tokio::sync::broadcast::Sender<()> {
|
||||
let (shutdown_tx, _) = tokio::sync::broadcast::channel(1);
|
||||
|
||||
// 设置全局关闭标志
|
||||
static SHUTDOWN_INITIATED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// 注册 Ctrl+C 信号处理
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{SignalKind, signal};
|
||||
|
||||
let shutdown_tx_clone = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// 注册信号处理器,如果失败则记录警告并优雅降级
|
||||
let sigint_result = signal(SignalKind::interrupt());
|
||||
let sigterm_result = signal(SignalKind::terminate());
|
||||
|
||||
match (sigint_result, sigterm_result) {
|
||||
(Ok(mut sigint), Ok(mut sigterm)) => {
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {
|
||||
if !SHUTDOWN_INITIATED.swap(true, Ordering::SeqCst) {
|
||||
info!(" received SIGINT (Ctrl+C), starting graceful shutdown...");
|
||||
let _ = shutdown_tx_clone.send(());
|
||||
}
|
||||
}
|
||||
_ = sigterm.recv() => {
|
||||
if !SHUTDOWN_INITIATED.swap(true, Ordering::SeqCst) {
|
||||
info!(" received SIGTERM, starting graceful shutdown...");
|
||||
let _ = shutdown_tx_clone.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => {
|
||||
warn!(" unix signal handler failed: {}, shutdown may not be graceful", e);
|
||||
// 注册失败不影响程序运行,仍可通过其他方式关闭(如 tokio::signal::ctrl_c)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let shutdown_tx_clone = shutdown_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::signal;
|
||||
|
||||
if let Ok(()) = signal::ctrl_c().await {
|
||||
if !SHUTDOWN_INITIATED.swap(true, Ordering::SeqCst) {
|
||||
info!(" received Ctrl+C, starting graceful shutdown...");
|
||||
let _ = shutdown_tx_clone.send(());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
shutdown_tx
|
||||
}
|
||||
|
||||
/// 优雅关闭信号处理
|
||||
async fn shutdown_signal(mut shutdown_rx: tokio::sync::broadcast::Receiver<()>) {
|
||||
// 等待关闭信号
|
||||
let _ = shutdown_rx.recv().await;
|
||||
|
||||
info!("starting graceful shutdown...");
|
||||
|
||||
// 执行容器清理
|
||||
if let Err(e) = cleanup_all_containers().await {
|
||||
error!("container cleanup failed: {}", e);
|
||||
} else {
|
||||
info!("container cleanup completed");
|
||||
}
|
||||
|
||||
info!("🛑 RCoder graceful shutdown completed");
|
||||
}
|
||||
|
||||
/// 清理所有动态创建的容器
|
||||
async fn cleanup_all_containers() -> anyhow::Result<()> {
|
||||
info!("🧹 starting cleanup of dynamically created containers...");
|
||||
|
||||
match docker_manager::runtime::RuntimeManager::runtime_type() {
|
||||
RuntimeType::Docker => {
|
||||
let docker_manager = docker_manager::global::get_global_docker_manager()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get global DockerManager: {}", e))?;
|
||||
|
||||
let multi_image_config = shared_types::create_default_multi_image_config();
|
||||
match container_stop::startup_cleanup_all_enabled_services(
|
||||
&docker_manager,
|
||||
&multi_image_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if result.successfully_removed > 0 {
|
||||
info!(
|
||||
"🧹 Cleaned up {} containers (all enabled services)",
|
||||
result.successfully_removed
|
||||
);
|
||||
}
|
||||
|
||||
if result.failed_removals > 0 {
|
||||
warn!(
|
||||
"container cleanup failed: failed count={}",
|
||||
result.failed_removals
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("container cleanup error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
RuntimeType::Kubernetes => {
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get runtime: {}", e))?;
|
||||
runtime
|
||||
.cleanup_all()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to cleanup runtime resources: {}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
159
qiming-rcoder/crates/rcoder/src/middleware/api_key_middleware.rs
Normal file
159
qiming-rcoder/crates/rcoder/src/middleware/api_key_middleware.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use shared_types::{ApiKeyAuthConfig, ApiKeyAuthError, ApiKeyValidator, HttpResult};
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
const API_KEY_HEADER: &str = "x-api-key";
|
||||
|
||||
/// 中间件处理函数(支持配置热更新,使用 ArcSwap 无锁读取)
|
||||
pub async fn api_key_middleware_handler(
|
||||
api_key_config: Arc<ArcSwap<ApiKeyAuthConfig>>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let path = req.uri().path();
|
||||
let headers = req.headers();
|
||||
let locale = shared_types::parse_accept_language(
|
||||
headers.get("accept-language").and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
|
||||
// 提取 API Key
|
||||
let api_key = headers.get(API_KEY_HEADER).and_then(|v| v.to_str().ok());
|
||||
|
||||
// 使用共享验证逻辑(无锁,同步)
|
||||
match ApiKeyValidator::validate(&api_key_config, path, api_key) {
|
||||
Ok(()) => next.run(req).await,
|
||||
|
||||
Err(err) => {
|
||||
match err {
|
||||
ApiKeyAuthError::Invalid | ApiKeyAuthError::Missing => {
|
||||
warn!("🔒 [API_KEY_AUTH] {} for path: {}", err, path);
|
||||
return api_key_error_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
shared_types::error_codes::ERR_API_KEY_AUTH_FAILED,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
ApiKeyAuthError::ConfigError => {
|
||||
tracing::error!("🔒 [API_KEY_AUTH] {}", err);
|
||||
return api_key_error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
shared_types::error_codes::ERR_INTERNAL_SERVER_ERROR,
|
||||
locale,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn api_key_error_response(status: StatusCode, code: &str, locale: &'static str) -> Response {
|
||||
let body = HttpResult::<String>::error_with_locale(code, locale);
|
||||
(status, axum::Json(body)).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::to_bytes;
|
||||
use axum::{Router, routing::get};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[test]
|
||||
fn test_api_key_header_name() {
|
||||
assert_eq!(API_KEY_HEADER, "x-api-key");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_invalid_returns_json_and_localized_message() {
|
||||
let config = Arc::new(ArcSwap::from_pointee(ApiKeyAuthConfig {
|
||||
enabled: true,
|
||||
api_key: "valid-key".to_string(),
|
||||
}));
|
||||
|
||||
let app =
|
||||
Router::new()
|
||||
.route("/chat", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(move |req, next| {
|
||||
api_key_middleware_handler(Arc::clone(&config), req, next)
|
||||
}));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/chat")
|
||||
.header("accept-language", "zh-CN")
|
||||
.header("x-api-key", "wrong")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(axum::http::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
assert_eq!(content_type, Some("application/json"));
|
||||
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(
|
||||
json.get("code").and_then(|v| v.as_str()),
|
||||
Some(shared_types::error_codes::ERR_API_KEY_AUTH_FAILED)
|
||||
);
|
||||
let expected_message = shared_types::get_error_message(
|
||||
shared_types::error_codes::ERR_API_KEY_AUTH_FAILED,
|
||||
"zh-CN",
|
||||
);
|
||||
assert_eq!(
|
||||
json.get("message").and_then(|v| v.as_str()),
|
||||
Some(expected_message.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_invalid_without_accept_language_falls_back_to_en_us() {
|
||||
let config = Arc::new(ArcSwap::from_pointee(ApiKeyAuthConfig {
|
||||
enabled: true,
|
||||
api_key: "valid-key".to_string(),
|
||||
}));
|
||||
|
||||
let app =
|
||||
Router::new()
|
||||
.route("/chat", get(|| async { "ok" }))
|
||||
.layer(axum::middleware::from_fn(move |req, next| {
|
||||
api_key_middleware_handler(Arc::clone(&config), req, next)
|
||||
}));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/chat")
|
||||
.header("x-api-key", "wrong")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let expected_message = shared_types::get_error_message(
|
||||
shared_types::error_codes::ERR_API_KEY_AUTH_FAILED,
|
||||
"en-US",
|
||||
);
|
||||
assert_eq!(
|
||||
json.get("message").and_then(|v| v.as_str()),
|
||||
Some(expected_message.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
1
qiming-rcoder/crates/rcoder/src/middleware/mod.rs
Normal file
1
qiming-rcoder/crates/rcoder/src/middleware/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod api_key_middleware;
|
||||
245
qiming-rcoder/crates/rcoder/src/rcoder_default.yml
Normal file
245
qiming-rcoder/crates/rcoder/src/rcoder_default.yml
Normal file
@@ -0,0 +1,245 @@
|
||||
# RCoder 配置文件
|
||||
# 这是默认配置,会在首次启动时自动生成
|
||||
|
||||
# 默认使用的 AI 代理类型 (Claude/Codex)
|
||||
default_agent: "Claude"
|
||||
|
||||
# 项目工作目录
|
||||
projects_dir: "./project_workspace"
|
||||
|
||||
# 主服务端口
|
||||
port: 8087
|
||||
|
||||
# 容器清理配置
|
||||
cleanup_config:
|
||||
# 是否启用容器清理功能(默认启用)
|
||||
# 设置为 false 可以禁用自动清理闲置容器
|
||||
# - true: 启用(默认,自动清理闲置容器)
|
||||
# - false: 禁用
|
||||
enabled: true
|
||||
# 闲置超时时间(秒),超过此时间未活动的容器将被清理
|
||||
# 默认值: 600 (10分钟)
|
||||
idle_timeout_seconds: 600
|
||||
# 清理任务执行间隔(秒)
|
||||
# 默认值: 300 (5分钟)
|
||||
cleanup_interval_seconds: 300
|
||||
# Docker 容器停止超时时间(秒)
|
||||
# 默认值: 30
|
||||
docker_stop_timeout_seconds: 30
|
||||
# 日志清理配置
|
||||
log_cleanup:
|
||||
# 日志目录路径(容器内路径)
|
||||
# 默认值: /app/logs/container
|
||||
log_dir: "/app/logs/container"
|
||||
# 日志保留天数
|
||||
# 默认值: 10
|
||||
log_retention_days: 10
|
||||
|
||||
# Pingora 反向代理配置
|
||||
proxy_config:
|
||||
# 代理服务监听端口 (用于接收外部请求)
|
||||
listen_port: 8088
|
||||
# 默认后端服务端口 (当请求未指定端口时使用)
|
||||
default_backend_port: 8087
|
||||
# 后端服务主机地址
|
||||
backend_host: "127.0.0.1"
|
||||
# URL 中端口参数的名称 (用于从路径中提取端口号)
|
||||
port_param: "port"
|
||||
# 健康检查配置
|
||||
health_check:
|
||||
enabled: true
|
||||
interval_seconds: 5
|
||||
timeout_seconds: 1
|
||||
healthy_threshold: 2
|
||||
unhealthy_threshold: 3
|
||||
|
||||
# Docker 配置
|
||||
docker_config:
|
||||
# 多镜像配置系统
|
||||
multi_image_config:
|
||||
# 全局默认配置
|
||||
global_defaults:
|
||||
# 默认镜像仓库前缀
|
||||
registry_prefix: "nuwax-docker-images-registry.cn-hangzhou.cr.aliyuncs.com/dev"
|
||||
# 其他默认镜像配置(可选)
|
||||
# image: "custom/default-image:latest"
|
||||
# arm64_image: "custom/default-image:arm64"
|
||||
# amd64_image: "custom/default-image:amd64"
|
||||
# default_image: "custom/default-image:latest"
|
||||
|
||||
# 服务镜像配置
|
||||
services:
|
||||
# RCoder 主服务配置
|
||||
rcoder:
|
||||
service_type: "RCoder"
|
||||
# 镜像配置(使用架构特定镜像)
|
||||
# image: "nuwax-docker-images-registry.cn-hangzhou.cr.aliyuncs.com/nuwax/rcoder:latest" # 通用镜像,"nuwax"是生产环境镜像, "nuwax-test"和"dev"都是测试镜像,默认生产环境镜像
|
||||
arm64_image: "nuwax-docker-images-registry.cn-hangzhou.cr.aliyuncs.com/nuwax/rcoder:latest"
|
||||
amd64_image: "nuwax-docker-images-registry.cn-hangzhou.cr.aliyuncs.com/nuwax/rcoder:latest"
|
||||
default_image: "nuwax-docker-images-registry.cn-hangzhou.cr.aliyuncs.com/nuwax/rcoder:latest"
|
||||
image_tag_prefix: "rcoder"
|
||||
enabled: true
|
||||
|
||||
# 容器内挂载路径模板(支持变量替换)
|
||||
# 默认值: "/app/project_workspace/{project_id}"
|
||||
# 支持变量: {project_id}, {user_id}, {service_type}
|
||||
container_path_template: "/app/project_workspace/{project_id}"
|
||||
|
||||
# 环境变量
|
||||
environment:
|
||||
RUST_LOG: "info"
|
||||
SERVICE_MODE: "full"
|
||||
API_PORT: "8086"
|
||||
|
||||
# 容器启动命令
|
||||
command:
|
||||
- "/app/bin/agent_runner"
|
||||
- "--port"
|
||||
- "8086"
|
||||
|
||||
# 容器入口点(可选,如果为空则使用镜像默认)
|
||||
# entrypoint: []
|
||||
|
||||
# 资源限制配置
|
||||
resource_limits:
|
||||
memory_limit: 2147483648 # 2GB (字节)
|
||||
cpu_limit: 2.0 # 2 核 CPU
|
||||
swap_limit: 4294967296 # 4GB 交换空间 (字节)
|
||||
|
||||
# 容器工作目录
|
||||
work_dir: "/app"
|
||||
|
||||
# 网络模式
|
||||
network_mode: "bridge"
|
||||
|
||||
# 卷挂载配置
|
||||
# 注释掉默认挂载配置,临时测试用,改为空数组
|
||||
# mounts:
|
||||
# - container_path: "/app/specs"
|
||||
# host_path: "/app/specs"
|
||||
# read_only: true
|
||||
# mount_type: "bind"
|
||||
mounts: []
|
||||
|
||||
# ComputerAgentRunner 服务配置
|
||||
computer-agent-runner:
|
||||
service_type: "ComputerAgentRunner"
|
||||
# 镜像配置(使用架构特定镜像)
|
||||
arm64_image: "nuwax-docker-images-registry.cn-hangzhou.cr.aliyuncs.com/dev/rcoder-agent-runner:latest"
|
||||
amd64_image: "nuwax-docker-images-registry.cn-hangzhou.cr.aliyuncs.com/dev/rcoder-agent-runner:latest"
|
||||
default_image: "nuwax-docker-images-registry.cn-hangzhou.cr.aliyuncs.com/dev/rcoder-agent-runner:latest"
|
||||
image_tag_prefix: "rcoder-computer-agent-runner"
|
||||
enabled: true
|
||||
|
||||
# 容器内挂载路径模板(支持变量替换)
|
||||
# 默认值: "/app/project_workspace/{project_id}"
|
||||
# 支持变量: {project_id}, {user_id}, {service_type}
|
||||
container_path_template: "/app/project_workspace/{project_id}"
|
||||
|
||||
# 环境变量
|
||||
environment:
|
||||
RUST_LOG: "debug"
|
||||
SERVICE_MODE: "agent-only"
|
||||
AGENT_PORT: "8086"
|
||||
|
||||
# 容器启动命令
|
||||
command:
|
||||
- "/app/bin/agent_runner"
|
||||
- "--port"
|
||||
- "8086"
|
||||
|
||||
# 容器入口点(可选,如果为空则使用镜像默认)
|
||||
# entrypoint: []
|
||||
|
||||
# 资源限制配置(AgentRunner 通常需要更多资源)
|
||||
resource_limits:
|
||||
memory_limit: 4294967296 # 4GB (字节)
|
||||
cpu_limit: 3.0 # 3 核 CPU
|
||||
swap_limit: 8589934592 # 8GB 交换空间 (字节)
|
||||
|
||||
# 容器工作目录
|
||||
work_dir: "/app"
|
||||
|
||||
# 网络模式
|
||||
network_mode: "bridge"
|
||||
|
||||
# 卷挂载配置
|
||||
# 注释掉默认挂载配置,临时测试用,改为空数组
|
||||
# mounts:
|
||||
# - container_path: "/app/specs"
|
||||
# host_path: "/app/specs"
|
||||
# read_only: true
|
||||
# mount_type: "bind"
|
||||
mounts: []
|
||||
|
||||
# 镜像选择策略
|
||||
selection_strategy: "ServiceOnly"
|
||||
|
||||
# 缓存配置
|
||||
cache_config:
|
||||
enabled: true
|
||||
ttl_seconds: 3600
|
||||
max_entries: 50
|
||||
|
||||
# 其他 Docker 配置
|
||||
# 网络基础名称(不含 project name 前缀)
|
||||
# Docker Compose 会自动添加 project name 前缀,实际网络名称为 {project_name}_{network_base_name}
|
||||
# 例如: network_base_name="agent-network" 时,实际网络为 "rcoder_agent-network"
|
||||
network_base_name: "agent-network"
|
||||
|
||||
# 默认网络模式
|
||||
network_mode: "bridge"
|
||||
|
||||
# 默认工作目录
|
||||
work_dir: "/app"
|
||||
|
||||
# 是否启用自动清理
|
||||
auto_cleanup: true
|
||||
|
||||
# 容器存活时间(秒)
|
||||
container_ttl_seconds: 3600
|
||||
|
||||
# 🔧 Docker API 调用超时时间(秒)
|
||||
# 用于大多数 Docker API 调用的超时保护
|
||||
api_timeout_seconds: 10
|
||||
|
||||
# 🔧 快速操作超时时间(秒)
|
||||
# 用于状态查询等轻量级操作的超时保护
|
||||
api_timeout_quick_seconds: 5
|
||||
|
||||
# 🔧 状态缓存 TTL(秒)
|
||||
# 用于缓存容器状态信息(container_id, container_name, status, is_running)
|
||||
# 建议值:
|
||||
# - 高频查询环境: 30-60
|
||||
# - 平衡模式: 10-15(默认)
|
||||
# - 实时要求高: 5-10
|
||||
cache_status_ttl_seconds: 10
|
||||
|
||||
# 🔧 网络缓存 TTL(秒)
|
||||
# 用于缓存容器网络信息(network_name -> ip_address)
|
||||
# 建议值:
|
||||
# - 高频查询环境: 60-120
|
||||
# - 平衡模式: 15-30(默认)
|
||||
# - 实时要求高: 10-15
|
||||
cache_network_ttl_seconds: 15
|
||||
|
||||
# 🔧 缓存最大容量
|
||||
# 用于 DockerApiCache 的 status_cache 和 network_cache 的最大条目数
|
||||
# 建议值:
|
||||
# - 小型环境: 1000-5000
|
||||
# - 中型环境: 5000-10000(默认)
|
||||
# - 大型环境: 10000-50000
|
||||
cache_max_capacity: 10000
|
||||
|
||||
# API Key 鉴权配置(可选)
|
||||
api_key_auth:
|
||||
# 是否启用 API Key 鉴权(默认关闭)
|
||||
# 启用后,所有 HTTP 请求必须携带正确的 x-api-key header
|
||||
# 豁免端点:/health, /metrics, /api/docs, /proxy/status, /proxy/stats
|
||||
enabled: false
|
||||
|
||||
# API Key 值(首次生成时自动创建随机密钥)
|
||||
# 格式:sk-{32位十六进制字符串}
|
||||
# 示例:sk-a1b2c3d4e5f6789012345678abcdef01
|
||||
# 可以通过环境变量 RCODER_API_KEY 覆盖此配置
|
||||
api_key: "{{GENERATED_API_KEY}}"
|
||||
504
qiming-rcoder/crates/rcoder/src/router.rs
Normal file
504
qiming-rcoder/crates/rcoder/src/router.rs
Normal file
@@ -0,0 +1,504 @@
|
||||
use arc_swap::ArcSwap;
|
||||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
http::Request,
|
||||
middleware::Next,
|
||||
response::IntoResponse,
|
||||
response::Response,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use shared_types::ProjectAndContainerInfo;
|
||||
|
||||
use crate::{
|
||||
config::{ApiKeyAuthConfig, AppConfig},
|
||||
handler,
|
||||
storage::ProjectAdapter,
|
||||
};
|
||||
use rcoder_telemetry::{HttpMetricsLayer, TelemetryGuard};
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
async fn locale_context_middleware(mut req: Request<axum::body::Body>, next: Next) -> Response {
|
||||
let locale = shared_types::parse_accept_language(
|
||||
req.headers()
|
||||
.get("accept-language")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
);
|
||||
|
||||
req.extensions_mut().insert(locale);
|
||||
|
||||
shared_types::scope_request_locale(locale, async move { next.run(req).await }).await
|
||||
}
|
||||
|
||||
/// 会话信息结构
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionInfo {
|
||||
pub session_id: String,
|
||||
pub user_id: String,
|
||||
pub project_id: Option<String>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_activity: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// 应用状态
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// 应用配置
|
||||
pub config: AppConfig,
|
||||
/// 项目适配器 - 统一管理项目、会话和容器数据(替代原有的 3 个 DashMap)
|
||||
pub projects: ProjectAdapter,
|
||||
/// Pingora 代理服务引用(用于读取真实指标)
|
||||
pub pingora_service: Option<Arc<rcoder_proxy::PingoraProxyService>>,
|
||||
/// gRPC 连接池(用于与 agent_runner 通信)
|
||||
pub grpc_pool: Arc<crate::grpc::GrpcChannelPool>,
|
||||
/// 🆕 可热更新的 API Key 配置(使用 ArcSwap 实现无锁读取)
|
||||
pub api_key_config: Arc<ArcSwap<ApiKeyAuthConfig>>,
|
||||
/// 🆕 容器创建中标记: user_id -> 创建开始时间
|
||||
/// 用于防止并发 pod_ensure 请求互相干扰(无锁方案)
|
||||
pub pod_creating: Arc<dashmap::DashMap<String, std::time::Instant>>,
|
||||
/// 🆕 容器前缀(从配置读取,启动时初始化)
|
||||
pub container_prefix_rcoder: String,
|
||||
pub container_prefix_computer: String,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(
|
||||
config: AppConfig,
|
||||
pingora: Option<Arc<rcoder_proxy::PingoraProxyService>>,
|
||||
api_key_config: Arc<ArcSwap<ApiKeyAuthConfig>>,
|
||||
container_prefix_rcoder: String,
|
||||
container_prefix_computer: String,
|
||||
) -> anyhow::Result<Self> {
|
||||
let projects = ProjectAdapter::new()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to initialize ProjectAdapter: {}", e))?;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
projects,
|
||||
pingora_service: pingora,
|
||||
grpc_pool: Arc::new(crate::grpc::GrpcChannelPool::new()),
|
||||
api_key_config,
|
||||
pod_creating: Arc::new(DashMap::new()),
|
||||
container_prefix_rcoder,
|
||||
container_prefix_computer,
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 向后兼容的便捷方法 ==========
|
||||
|
||||
/// 获取项目信息(替代 project_and_agent_map.get)
|
||||
#[inline]
|
||||
pub fn get_project(&self, project_id: &str) -> Option<Arc<ProjectAndContainerInfo>> {
|
||||
self.projects.get(project_id)
|
||||
}
|
||||
|
||||
/// 插入项目信息(替代 project_and_agent_map.insert)
|
||||
#[inline]
|
||||
pub fn insert_project(&self, project_id: String, info: Arc<ProjectAndContainerInfo>) {
|
||||
if let Err(e) = self.projects.insert(project_id.clone(), info) {
|
||||
tracing::error!("Failed to insert project {}: {}", project_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除项目(替代 project_and_agent_map.remove)
|
||||
#[inline]
|
||||
pub fn remove_project(&self, project_id: &str) -> Option<Arc<ProjectAndContainerInfo>> {
|
||||
self.projects.remove(project_id)
|
||||
}
|
||||
|
||||
/// 检查项目是否存在(替代 project_and_agent_map.contains_key)
|
||||
#[inline]
|
||||
pub fn contains_project(&self, project_id: &str) -> bool {
|
||||
self.projects.contains_key(project_id)
|
||||
}
|
||||
|
||||
/// 通过会话ID获取项目信息(替代 sessions.get)
|
||||
#[inline]
|
||||
pub fn get_by_session(&self, session_id: &str) -> Option<Arc<ProjectAndContainerInfo>> {
|
||||
self.projects.get_by_session_id(session_id)
|
||||
}
|
||||
|
||||
/// 通过会话ID获取容器名称(用于容器重启后的容器查询)
|
||||
///
|
||||
/// 与 `get_container_id_by_session` 不同,返回稳定的 `container_name`。
|
||||
/// 即使容器被重建,container_name 保持不变,可直接通过 Docker API 查询容器状态。
|
||||
#[inline]
|
||||
pub fn get_container_name_by_session(&self, session_id: &str) -> Option<String> {
|
||||
self.projects.get_container_name_by_session(session_id)
|
||||
}
|
||||
|
||||
/// 更新会话信息
|
||||
#[inline]
|
||||
pub fn update_session(&self, project_id: &str, session_id: &str) {
|
||||
if let Err(e) = self.projects.update_session(project_id, session_id) {
|
||||
tracing::error!(
|
||||
"Failed to update session: project_id={}, session_id={}, error={}",
|
||||
project_id,
|
||||
session_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除会话信息(将 session_id 设置为 NULL)
|
||||
///
|
||||
/// 用于 Agent 停止后清理会话状态
|
||||
#[inline]
|
||||
pub fn clear_session(&self, project_id: &str) {
|
||||
if let Err(e) = self.projects.clear_session(project_id) {
|
||||
tracing::error!(
|
||||
"Failed to clear session: project_id={}, error={}",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新项目活动时间,返回实际更新使用的时间戳
|
||||
#[inline]
|
||||
pub fn update_activity(&self, project_id: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
self.projects.update_activity(project_id)
|
||||
}
|
||||
|
||||
/// 更新会话活动时间
|
||||
#[inline]
|
||||
pub fn update_session_activity(&self, session_id: &str) {
|
||||
self.projects.update_session_activity(session_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Axum 路由
|
||||
pub fn create_router(state: Arc<AppState>, telemetry: Option<Arc<TelemetryGuard>>) -> Router {
|
||||
let api_routes = Router::new()
|
||||
.route("/chat", post(handler::handle_chat))
|
||||
// Axum SSE 代理处理器,直接返回 SSE 流
|
||||
.route(
|
||||
"/agent/progress/{session_id}",
|
||||
get(handler::agent_session_notification),
|
||||
)
|
||||
.route("/agent/session/cancel", post(handler::agent_session_cancel))
|
||||
.route("/agent/stop", post(handler::agent_stop))
|
||||
.route("/agent/status/{project_id}", get(handler::agent_status))
|
||||
.with_state(state.clone());
|
||||
|
||||
// Computer Agent Runner 路由
|
||||
let computer_routes = Router::new()
|
||||
.route("/computer/chat", post(handler::handle_computer_chat))
|
||||
.route("/computer/agent/stop", post(handler::computer_agent_stop))
|
||||
.route(
|
||||
"/computer/agent/status",
|
||||
post(handler::computer_agent_status),
|
||||
) // 🆕 新增
|
||||
.route(
|
||||
"/computer/agent/session/cancel",
|
||||
post(handler::computer_agent_session_cancel),
|
||||
)
|
||||
// 进度流复用现有的 agent_session_notification
|
||||
.route(
|
||||
"/computer/progress/{session_id}",
|
||||
get(handler::computer_agent_progress_notification),
|
||||
)
|
||||
// VNC 桌面访问说明接口
|
||||
.route(
|
||||
"/computer/desktop/{user_id}/{project_id}",
|
||||
get(handler::computer_desktop_vnc),
|
||||
)
|
||||
// Pod 容器管理接口
|
||||
.route("/computer/pod/count", get(handler::pod_count))
|
||||
.route("/computer/pod/list", get(handler::pod_list))
|
||||
.route("/computer/pod/ensure", post(handler::pod_ensure))
|
||||
.route("/computer/pod/keepalive", post(handler::pod_keepalive))
|
||||
.route("/computer/pod/restart", post(handler::pod_restart))
|
||||
.route("/computer/pod/status", get(handler::pod_status))
|
||||
.route("/computer/pod/vnc-status", get(handler::pod_vnc_status))
|
||||
// 🆕 音频代理路由(用于 OpenAPI 文档)
|
||||
.route(
|
||||
"/computer/audio/{user_id}/{project_id}/{*path}",
|
||||
get(handler::computer_audio_proxy),
|
||||
)
|
||||
// 🆕 IME 代理路由(用于 OpenAPI 文档)
|
||||
.route(
|
||||
"/computer/ime/{user_id}/{project_id}/{*path}",
|
||||
get(handler::computer_ime_proxy),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
|
||||
// Pingora 代理 API 路由(用于文档和状态查询)
|
||||
let proxy_api_routes = Router::new()
|
||||
.route("/proxy/status", get(handler::proxy_status))
|
||||
.route("/proxy/stats", get(handler::proxy_stats))
|
||||
.route("/proxy/config", get(handler::proxy_config))
|
||||
.with_state(state.clone());
|
||||
|
||||
// 调试路由(仅用于开发和问题排查,需要 feature flag "debug" 启用)
|
||||
#[cfg(feature = "debug")]
|
||||
let debug_routes = Router::new()
|
||||
.route("/debug/sql", post(handler::debug_sql_query))
|
||||
.route("/debug/projects", get(handler::debug_list_projects))
|
||||
.route("/debug/containers", get(handler::debug_list_containers))
|
||||
.route("/debug/storage/stats", get(handler::debug_storage_stats))
|
||||
.with_state(state.clone());
|
||||
|
||||
// 健康检查路由
|
||||
let health_routes = Router::new()
|
||||
.route("/health", get(handler::health_check))
|
||||
.with_state(state.clone());
|
||||
|
||||
let mut router = Router::new()
|
||||
.merge(health_routes)
|
||||
.merge(api_routes)
|
||||
.merge(computer_routes)
|
||||
.merge(proxy_api_routes);
|
||||
|
||||
// 仅在启用 debug feature 时添加调试路由
|
||||
#[cfg(feature = "debug")]
|
||||
{
|
||||
router = router.merge(debug_routes);
|
||||
}
|
||||
|
||||
// 添加 /metrics 端点(如果启用了 Prometheus)
|
||||
if let Some(ref guard) = telemetry {
|
||||
let guard_clone = Arc::clone(guard);
|
||||
router = router.route(
|
||||
"/metrics",
|
||||
get(move || {
|
||||
let guard = Arc::clone(&guard_clone);
|
||||
async move { metrics_handler(guard).await }
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 🆕 克隆共享的 API Key 配置用于中间件
|
||||
let api_key_config = Arc::clone(&state.api_key_config);
|
||||
|
||||
router
|
||||
.merge(create_swagger_ui())
|
||||
.layer(DefaultBodyLimit::max(50 * 1024 * 1024)) // 50MB body 大小限制
|
||||
.layer(HttpMetricsLayer::new()) // 🆕 HTTP 指标中间件
|
||||
// 🆕 添加 API Key 鉴权中间件(支持热更新)
|
||||
.layer(axum::middleware::from_fn(move |req, next| {
|
||||
crate::middleware::api_key_middleware::api_key_middleware_handler(
|
||||
Arc::clone(&api_key_config),
|
||||
req,
|
||||
next,
|
||||
)
|
||||
}))
|
||||
.layer(axum::middleware::from_fn(locale_context_middleware))
|
||||
}
|
||||
|
||||
/// Prometheus 指标处理器
|
||||
async fn metrics_handler(telemetry: Arc<TelemetryGuard>) -> impl IntoResponse {
|
||||
match telemetry.render_metrics() {
|
||||
Some(metrics) => (
|
||||
axum::http::StatusCode::OK,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
metrics,
|
||||
),
|
||||
None => (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
[(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
"text/plain; charset=utf-8",
|
||||
)],
|
||||
"Prometheus metrics not enabled".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAPI 文档结构
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
paths(
|
||||
handler::health_check,
|
||||
handler::handle_chat,
|
||||
handler::agent_session_notification,
|
||||
handler::agent_session_cancel,
|
||||
handler::agent_stop,
|
||||
handler::agent_status,
|
||||
handler::handle_computer_chat,
|
||||
handler::computer_agent_stop,
|
||||
handler::computer_agent_status, // 🆕 新增
|
||||
handler::computer_agent_session_cancel,
|
||||
handler::computer_agent_progress_notification,
|
||||
handler::computer_desktop_vnc,
|
||||
handler::computer_desktop_proxy,
|
||||
handler::computer_audio_proxy,
|
||||
handler::computer_ime_proxy,
|
||||
handler::pod_count,
|
||||
handler::pod_list,
|
||||
handler::pod_ensure,
|
||||
handler::pod_keepalive,
|
||||
handler::pod_restart,
|
||||
handler::pod_status,
|
||||
handler::pod_vnc_status,
|
||||
// Pingora 代理接口
|
||||
handler::proxy_status,
|
||||
handler::proxy_stats,
|
||||
handler::proxy_config,
|
||||
handler::proxy_to_port,
|
||||
handler::proxy_to_port_with_path,
|
||||
handler::proxy_with_query_params,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
// 响应结构体
|
||||
handler::HealthResponse,
|
||||
shared_types::AgentChatRequest,
|
||||
shared_types::ChatResponse,
|
||||
shared_types::AgentStopResponse,
|
||||
shared_types::AgentCancelResponse,
|
||||
// 移除 SessionUpdateEvent,因为现在使用 ProxyRedirectResponse
|
||||
handler::ProxyErrorResponse,
|
||||
// 模型配置相关结构体
|
||||
shared_types::ModelProviderConfig,
|
||||
shared_types::ModelApiProtocol,
|
||||
shared_types::ModelProviderSafeInfo,
|
||||
// Agent状态相关结构体
|
||||
shared_types::AgentStatusResponse,
|
||||
shared_types::AgentStatus,
|
||||
handler::SessionNotificationParams,
|
||||
// SSE 进度事件结构体(用于文档)
|
||||
handler::ProgressEventDoc,
|
||||
handler::SseErrorEvent,
|
||||
// 附件相关结构体
|
||||
shared_types::Attachment,
|
||||
shared_types::AttachmentSource,
|
||||
shared_types::TextAttachment,
|
||||
shared_types::ImageAttachment,
|
||||
shared_types::AudioAttachment,
|
||||
shared_types::DocumentAttachment,
|
||||
shared_types::ImageDimensions,
|
||||
// 会话消息相关结构体
|
||||
shared_types::UnifiedSessionMessage,
|
||||
shared_types::SessionMessageType,
|
||||
// Computer Agent 相关结构体
|
||||
shared_types::ComputerChatRequest,
|
||||
shared_types::ComputerAgentStopRequest,
|
||||
shared_types::ComputerAgentStopResponse,
|
||||
shared_types::ComputerAgentStatusRequest,
|
||||
shared_types::ComputerAgentStatusResponse,
|
||||
handler::DesktopPathParams,
|
||||
handler::VncProxyPathParams,
|
||||
handler::AudioProxyPathParams,
|
||||
handler::ImeProxyPathParams,
|
||||
handler::DesktopAccessResponse,
|
||||
handler::DesktopErrorResponse,
|
||||
// Pod 容器管理相关结构体
|
||||
handler::PodCountResponse,
|
||||
handler::PodCountByServiceType,
|
||||
handler::PodListQuery,
|
||||
handler::PodListResponse,
|
||||
handler::PodDetailInfo,
|
||||
handler::EnsurePodRequest,
|
||||
handler::PodResourceLimits,
|
||||
handler::EnsurePodResponse,
|
||||
handler::PodContainerInfo,
|
||||
handler::KeepalivePodRequest,
|
||||
handler::KeepalivePodResponse,
|
||||
handler::RestartPodRequest,
|
||||
handler::RestartPodResponse,
|
||||
handler::PodStatusQuery,
|
||||
handler::PodStatusResponse,
|
||||
handler::VncStatusQuery,
|
||||
handler::VncStatusResponse,
|
||||
// Pingora 代理相关结构体
|
||||
handler::ProxyResponse,
|
||||
handler::ProxyStatus,
|
||||
handler::ProxyStats,
|
||||
handler::ProxyConfig,
|
||||
handler::ProxyPathParams,
|
||||
handler::ProxyPathWithTailParams,
|
||||
handler::ProxyErrorResponse,
|
||||
handler::LoadBalancerInfo,
|
||||
handler::BackendInfo,
|
||||
handler::PortStats,
|
||||
handler::HealthCheckConfig,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "system", description = "系统健康检查和状态监控接口"),
|
||||
(name = "chat", description = "AI 聊天对话接口,支持多媒体内容"),
|
||||
(name = "agent", description = "AI 代理会话管理和实时通知接口"),
|
||||
(name = "computer", description = "Computer Agent 桌面与聊天接口"),
|
||||
(name = "pod", description = "Pod 容器管理接口,支持容器监控、启动和保活"),
|
||||
(name = "proxy", description = "Pingora 反向代理接口,支持端口路由和负载均衡"),
|
||||
),
|
||||
info(
|
||||
description = r#"
|
||||
RCoder AI 服务 API
|
||||
|
||||
基于 ACP (Agent Client Protocol) 的 AI 驱动开发平台,提供完整的 AI 代理集成解决方案。
|
||||
|
||||
## 主要功能
|
||||
|
||||
- **智能对话**: 支持文本、图像、音频、文档等多媒体内容的 AI 交互
|
||||
- **实时通知**: 通过 SSE 协议提供 AI 代理执行进度的实时推送
|
||||
- **会话管理**: 完整的会话生命周期管理,支持任务取消
|
||||
- **项目隔离**: 每个对话在独立的项目工作空间中进行,确保安全性
|
||||
- **Pingora 反向代理**: 基于 Cloudflare Pingora 的高性能反向代理服务
|
||||
|
||||
## 技术架构
|
||||
|
||||
- **协议**: ACP (Agent Client Protocol) v0.4
|
||||
- **代理类型**: 支持 Codex、Claude、Proxy 三种 AI 代理
|
||||
- **并发**: 基于 MPMC 架构的高并发处理
|
||||
- **实时通信**: Server-Sent Events (SSE) 协议
|
||||
- **反向代理**: Cloudflare Pingora 高性能代理服务器
|
||||
|
||||
## Pingora 代理功能
|
||||
|
||||
- **VNC 代理**: `/computer/vnc/{user_id}/{project_id}/{*path}` - 代理到容器的 noVNC 服务(端口 6080)
|
||||
- 路径示例:`/computer/vnc/user_123/proj_456/vnc.html` - VNC 桌面页面
|
||||
- WebSocket:`/computer/vnc/user_123/proj_456/websockify` - VNC 连接
|
||||
- **端口路由**: `/proxy/{port}/{*path}` - 动态路由到任意端口的后端服务
|
||||
- 支持两种方式:直接访问 Pingora 端口 或 通过 API 重定向
|
||||
- **负载均衡**: 支持 Round Robin 算法和健康检查
|
||||
- **动态发现**: 自动发现和添加后端服务,无需预配置
|
||||
- **高性能**: 基于 Rust 异步 I/O 的高性能代理
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 调用 `/chat` 接口发送对话请求
|
||||
2. 通过 `/agent/progress/{session_id}` 建立 SSE 连接接收实时更新
|
||||
3. 可随时通过 `/agent/session/cancel` 取消正在执行的任务
|
||||
4. 直接访问 Pingora 代理路径或使用管理接口
|
||||
|
||||
## 代理接口示例
|
||||
|
||||
- `GET /proxy/status` - 查看代理服务状态
|
||||
- `GET /proxy/stats` - 查看代理统计信息
|
||||
- `GET /proxy/config` - 查看代理配置信息
|
||||
- 直接访问 `http://{host}:{pingora_port}/proxy/{port}/{path}` - 使用 Pingora 代理服务
|
||||
"#,
|
||||
title = "RCoder AI API",
|
||||
version = "1.0.0",
|
||||
license(name = "MIT OR Apache-2.0", url = "https://opensource.org/licenses/MIT"),
|
||||
contact(
|
||||
name = "RCoder Team",
|
||||
email = "team@rcoder.com",
|
||||
url = "https://github.com/rcoder/rcoder"
|
||||
)
|
||||
),
|
||||
servers(
|
||||
(url = "http://localhost:8087", description = "本地开发环境"),
|
||||
(url = "https://api.rcoder.com", description = "生产环境"),
|
||||
(url = "https://staging-api.rcoder.com", description = "测试环境")
|
||||
)
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
/// 创建 Swagger UI 路由
|
||||
pub fn create_swagger_ui() -> SwaggerUi {
|
||||
SwaggerUi::new("/api/docs")
|
||||
.url("/api/docs/openapi.json", ApiDoc::openapi())
|
||||
.config(utoipa_swagger_ui::Config::new(["/api/docs/openapi.json"]))
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Computer Agent Runner 容器管理服务
|
||||
//!
|
||||
//! 提供用户级容器的创建和管理逻辑。
|
||||
//! 与 RCoder 的 project_id 容器模式不同,ComputerAgentRunner 使用 user_id 作为容器标识。
|
||||
//!
|
||||
//! ## 与 RCoder ContainerManager 的区别
|
||||
//!
|
||||
//! | 维度 | RCoder | ComputerAgentRunner |
|
||||
//! |------|--------|---------------------|
|
||||
//! | 容器标识 | `project_id` | `user_id` |
|
||||
//! | 容器命名 | `rcoder-agent-{project_id}` | `computer-agent-runner-{user_id}` |
|
||||
//! | 工作目录 | `/app/project_workspace/{project_id}` | `/home/user` (通过 mounts 配置挂载) |
|
||||
//! | 挂载配置 | 硬编码 | config.yml mounts (配置化) |
|
||||
//! | Agent 实例 | 1 个 | 多个(按 project_id 区分) |
|
||||
|
||||
use crate::AppError;
|
||||
use crate::handler::utils::{COMPUTER_WORKSPACE_ROOT, user_dir};
|
||||
use container_runtime_api::{ContainerCreateParams, ContainerRuntime};
|
||||
use docker_manager::ContainerBasicInfo;
|
||||
use shared_types::error_codes::{ERR_CONTAINER_ERROR, ERR_WORKSPACE_ERROR};
|
||||
use shared_types::{ServiceResourceLimits, ServiceType};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
/// Computer Agent Runner 容器管理服务
|
||||
///
|
||||
/// 负责根据 `user_id` 获取或创建容器。
|
||||
/// 一个用户对应一个容器,容器内可以运行多个 project_id 的 Agent 实例。
|
||||
pub struct ComputerContainerManager;
|
||||
|
||||
impl ComputerContainerManager {
|
||||
/// 根据 user_id 或 pod_id 获取或创建容器
|
||||
///
|
||||
/// 容器命名规则: `computer-agent-runner-{pod_id}` 或 `computer-agent-runner-{user_id}`
|
||||
/// 工作区路径: `/app/computer-project-workspace/{user_id}` 或基于 isolation 的路径
|
||||
///
|
||||
/// # 参数
|
||||
/// - `user_id`: 用户唯一标识符
|
||||
/// - `resource_limits`: 可选的资源限额配置
|
||||
/// - `pod_id`: 可选的容器唯一标识,若提供则使用此 ID 作为容器标识(实现容器复用)
|
||||
/// - `isolation_type`: 隔离类型
|
||||
/// - `tenant_id`: 租户 ID
|
||||
/// - `space_id`: 空间 ID
|
||||
///
|
||||
/// # 返回
|
||||
/// 容器基本信息,包含容器 ID、IP 地址等
|
||||
///
|
||||
/// # 示例
|
||||
/// ```ignore
|
||||
/// let container_info = ComputerContainerManager::get_or_create_container_for_user(
|
||||
/// "user_123",
|
||||
/// None,
|
||||
/// None,
|
||||
/// None,
|
||||
/// None,
|
||||
/// None,
|
||||
/// ).await?;
|
||||
/// println!("Container IP: {}", container_info.container_ip);
|
||||
/// ```
|
||||
pub async fn get_or_create_container_for_user(
|
||||
user_id: &str,
|
||||
resource_limits: Option<ServiceResourceLimits>,
|
||||
pod_id: Option<&str>,
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
) -> Result<ContainerBasicInfo, AppError> {
|
||||
// 确定容器标识符:pod_id 有值时使用 pod_id,否则使用 user_id
|
||||
let container_identifier = pod_id.unwrap_or(user_id);
|
||||
|
||||
info!(
|
||||
"🔍 [COMPUTER_CONTAINER] Getting/creating user container: user_id={}, pod_id={:?}, container_identifier={}",
|
||||
user_id, pod_id, container_identifier
|
||||
);
|
||||
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_CONTAINER] Failed to get runtime: {}", e);
|
||||
AppError::with_message(
|
||||
ERR_CONTAINER_ERROR,
|
||||
format!("Failed to get runtime: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 1. 尝试获取现有容器
|
||||
// 使用 container_identifier 作为容器标识进行查询
|
||||
if let Ok(Some(info)) = runtime
|
||||
.get_container_info_by_identifier(container_identifier, &ServiceType::ComputerAgentRunner)
|
||||
.await
|
||||
{
|
||||
// ✅ 关键修复: 先验证 IP 是否有效,再检查容器运行状态
|
||||
// 顺序很重要:IP 为空说明容器已异常(被 kill 后网络已销毁),
|
||||
// 此时不应再调用 is_container_running_by_identifier(可能因缓存返回错误结果)
|
||||
if info.container_ip.trim().is_empty() {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CONTAINER] Container has empty IP (likely killed externally), will recreate: container_identifier={}, container_id={}",
|
||||
container_identifier, info.container_id
|
||||
);
|
||||
// 尝试清理已失效的容器
|
||||
if let Err(e) = runtime
|
||||
.stop_container_by_identifier(container_identifier, &ServiceType::ComputerAgentRunner)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CONTAINER] Failed to cleanup broken container (will create new anyway): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
// 继续创建新容器
|
||||
} else {
|
||||
// IP 非空,进一步验证容器是否真的在运行
|
||||
match runtime
|
||||
.is_container_running_by_identifier(container_identifier, &ServiceType::ComputerAgentRunner)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
info!(
|
||||
"✅ [COMPUTER_CONTAINER] User container already exists and running: container_identifier={}, container_id={}, ip={}",
|
||||
container_identifier, info.container_id, info.container_ip
|
||||
);
|
||||
return Ok(info);
|
||||
}
|
||||
Ok(false) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CONTAINER] User container exists but stopped: container_identifier={}, container_id={}, will delete and recreate",
|
||||
container_identifier, info.container_id
|
||||
);
|
||||
if let Err(e) = runtime
|
||||
.stop_container_by_identifier(container_identifier, &ServiceType::ComputerAgentRunner)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CONTAINER] Failed to delete old container (will create new container anyway): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
// 继续创建新容器
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"⚠️ [COMPUTER_CONTAINER] Failed to check container status: container_identifier={}, error={}, will try creating new container",
|
||||
container_identifier, e
|
||||
);
|
||||
// 继续创建新容器
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 容器不存在或已停止,创建新容器
|
||||
info!(
|
||||
"🏗️ [COMPUTER_CONTAINER] Creating new user container: container_identifier={}",
|
||||
container_identifier
|
||||
);
|
||||
Self::create_container_for_user(
|
||||
user_id,
|
||||
&runtime,
|
||||
resource_limits,
|
||||
pod_id,
|
||||
isolation_type,
|
||||
tenant_id,
|
||||
space_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 强制为用户创建新容器(跳过检查)
|
||||
///
|
||||
/// 直接调用内部创建逻辑,用于重启等需要强制重建的场景。
|
||||
/// 调用前应确保旧容器已被移除。
|
||||
pub async fn force_create_container_for_user(
|
||||
user_id: &str,
|
||||
resource_limits: Option<ServiceResourceLimits>,
|
||||
pod_id: Option<&str>,
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
) -> Result<ContainerBasicInfo, AppError> {
|
||||
let container_identifier = pod_id.unwrap_or(user_id);
|
||||
info!(
|
||||
"🏗️ [COMPUTER_CONTAINER] Force creating new user container: container_identifier={}",
|
||||
container_identifier
|
||||
);
|
||||
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_CONTAINER] Failed to get runtime: {}", e);
|
||||
AppError::with_message(
|
||||
ERR_CONTAINER_ERROR,
|
||||
format!("Failed to get runtime: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Self::create_container_for_user(
|
||||
user_id,
|
||||
&runtime,
|
||||
resource_limits,
|
||||
pod_id,
|
||||
isolation_type,
|
||||
tenant_id,
|
||||
space_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 为用户创建容器
|
||||
///
|
||||
/// 内部方法,负责实际的容器创建逻辑。
|
||||
async fn create_container_for_user(
|
||||
user_id: &str,
|
||||
runtime: &Arc<dyn ContainerRuntime>,
|
||||
resource_limits: Option<ServiceResourceLimits>,
|
||||
pod_id: Option<&str>,
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
) -> Result<ContainerBasicInfo, AppError> {
|
||||
// 确定容器标识符:pod_id 有值时使用 pod_id,否则使用 user_id
|
||||
let container_identifier = pod_id.unwrap_or(user_id);
|
||||
|
||||
// 1. 准备用户级工作目录(仍需在 rcoder 容器内创建)
|
||||
// 在容器内创建目录,绑定挂载会自动同步到宿主机
|
||||
Self::create_user_workspace(user_id).await?;
|
||||
|
||||
info!(
|
||||
"📁 [COMPUTER_CONTAINER] User workspace prepared: /app/computer-project-workspace/{}",
|
||||
user_id
|
||||
);
|
||||
|
||||
// 2. 调用 DockerManager 启动容器
|
||||
// 注意:不再传递 host_path,挂载由 config.yml 的 mounts 配置管理
|
||||
// 使用 container_identifier 作为 project_id(用于容器名称生成)
|
||||
let mut params_builder = ContainerCreateParams::builder()
|
||||
.project_id(container_identifier) // 用于容器名称生成和查找
|
||||
.user_id(user_id) // user_id 用于容器内配置
|
||||
.host_workspace_path("")
|
||||
.service_type(ServiceType::ComputerAgentRunner);
|
||||
|
||||
// 只有在有资源限制时才设置
|
||||
if let Some(limits) = resource_limits {
|
||||
params_builder = params_builder.resource_limits(limits);
|
||||
}
|
||||
|
||||
// 设置可选的隔离参数
|
||||
if let Some(pid) = pod_id {
|
||||
params_builder = params_builder.pod_id(pid);
|
||||
}
|
||||
if let Some(it) = isolation_type {
|
||||
params_builder = params_builder.isolation_type(it);
|
||||
}
|
||||
if let Some(tid) = tenant_id {
|
||||
params_builder = params_builder.tenant_id(tid);
|
||||
}
|
||||
if let Some(sid) = space_id {
|
||||
params_builder = params_builder.space_id(sid);
|
||||
}
|
||||
|
||||
let params = params_builder.build();
|
||||
|
||||
let container_info = runtime
|
||||
.create_container(params)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_CONTAINER] Failed to start container: {}", e);
|
||||
AppError::with_message(
|
||||
ERR_CONTAINER_ERROR,
|
||||
format!("Failed to start container: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"🚀 [COMPUTER_CONTAINER] User container created successfully: user_id={}, container_id={}, ip={}",
|
||||
user_id, container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
Ok(container_info)
|
||||
}
|
||||
|
||||
/// 获取用户工作区路径
|
||||
///
|
||||
/// 路径格式: `/app/computer-project-workspace/{user_id}`
|
||||
///
|
||||
/// 注意:project_id 作为子目录由容器内的 agent 自己管理
|
||||
pub async fn get_user_workspace(user_id: &str) -> Result<PathBuf, AppError> {
|
||||
Ok(PathBuf::from(user_dir(user_id)))
|
||||
}
|
||||
|
||||
/// 创建用户工作区目录
|
||||
///
|
||||
/// 创建 `/app/computer-project-workspace/{user_id}` 目录
|
||||
pub async fn create_user_workspace(user_id: &str) -> Result<PathBuf, AppError> {
|
||||
let workspace_root = PathBuf::from(COMPUTER_WORKSPACE_ROOT);
|
||||
|
||||
// 确保根目录存在
|
||||
tokio::fs::create_dir_all(&workspace_root)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"[COMPUTER_CONTAINER] Failed to create workspace directory: {:?}",
|
||||
e
|
||||
);
|
||||
AppError::with_message(
|
||||
ERR_WORKSPACE_ERROR,
|
||||
format!("Failed to create workspace directory: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 创建用户目录
|
||||
let user_workspace = PathBuf::from(user_dir(user_id));
|
||||
tokio::fs::create_dir_all(&user_workspace)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"[COMPUTER_CONTAINER] Failed to create user directory: {:?}",
|
||||
e
|
||||
);
|
||||
AppError::with_message(
|
||||
ERR_WORKSPACE_ERROR,
|
||||
format!("Failed to create user directory: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
debug!(
|
||||
"📁 [COMPUTER_CONTAINER] User workspace created successfully: {:?}",
|
||||
user_workspace
|
||||
);
|
||||
|
||||
Ok(user_workspace)
|
||||
}
|
||||
|
||||
/// 获取容器信息
|
||||
///
|
||||
/// 通过 user_id 查询容器是否存在
|
||||
pub async fn get_container_info(user_id: &str) -> Result<Option<ContainerBasicInfo>, AppError> {
|
||||
debug!(
|
||||
"[COMPUTER_CONTAINER] get container: user_id={}",
|
||||
user_id
|
||||
);
|
||||
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_CONTAINER] Failed to get runtime: {}", e);
|
||||
AppError::with_message(
|
||||
ERR_CONTAINER_ERROR,
|
||||
format!("Failed to get runtime: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
runtime
|
||||
.get_container_info_by_identifier(user_id, &ServiceType::ComputerAgentRunner)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[COMPUTER_CONTAINER] Failed to query container info: {}", e);
|
||||
AppError::with_message(
|
||||
ERR_CONTAINER_ERROR,
|
||||
format!("Failed to query container info: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 单元测试
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_user_workspace_path() {
|
||||
// 测试路径格式
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let path = ComputerContainerManager::get_user_workspace("user_123")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("/app/computer-project-workspace/user_123")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_workspace_path_with_special_chars() {
|
||||
// 测试带特殊字符的 user_id
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let path = ComputerContainerManager::get_user_workspace("user-with-dash_123")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from("/app/computer-project-workspace/user-with-dash_123")
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
303
qiming-rcoder/crates/rcoder/src/service/container_manager.rs
Normal file
303
qiming-rcoder/crates/rcoder/src/service/container_manager.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! 容器管理服务
|
||||
//!
|
||||
//! 提供通用的容器创建、管理和复用逻辑
|
||||
//! 供各个 handler 模块使用
|
||||
|
||||
use crate::AppError;
|
||||
use anyhow::Result;
|
||||
use container_runtime_api::{ContainerCreateParams, ContainerRuntime};
|
||||
use docker_manager::ContainerBasicInfo;
|
||||
use shared_types::error_codes::{ERR_CONTAINER_ERROR, ERR_WORKSPACE_ERROR};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// 通用容器管理服务
|
||||
pub struct ContainerManager;
|
||||
|
||||
impl ContainerManager {
|
||||
/// 根据请求获取或创建容器
|
||||
///
|
||||
/// # 参数
|
||||
/// - `project_id`: 项目 ID
|
||||
/// - `service_type`: 服务类型
|
||||
/// - `request_resource_limits`: 资源限制
|
||||
/// - `pod_id`: 容器唯一标识(可选,有值时优先使用)
|
||||
/// - `isolation_type`: 隔离类型(可选,tenant/space 时路径不同)
|
||||
/// - `tenant_id`: 租户 ID(可选)
|
||||
/// - `space_id`: 空间 ID(可选)
|
||||
/// - `container_work_path`: 容器内工作路径
|
||||
pub async fn get_or_create_container(
|
||||
project_id: &str,
|
||||
service_type: &shared_types::ServiceType,
|
||||
request_resource_limits: Option<shared_types::ServiceResourceLimits>,
|
||||
pod_id: Option<&str>,
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
container_work_path: &str,
|
||||
) -> Result<ContainerBasicInfo, AppError> {
|
||||
info!(
|
||||
"🔍 [CONTAINER_MGR] Starting container processing: project_id={}, service_type={:?}, pod_id={:?}, isolation_type={:?}",
|
||||
project_id, service_type, pod_id, isolation_type
|
||||
);
|
||||
|
||||
// 确定容器标识符:pod_id 有值时使用 pod_id,否则使用 project_id
|
||||
let container_identifier = pod_id.unwrap_or(project_id);
|
||||
|
||||
// 检查或创建容器
|
||||
let container_info = ensure_container_exists(
|
||||
project_id,
|
||||
container_identifier,
|
||||
service_type,
|
||||
request_resource_limits,
|
||||
pod_id,
|
||||
isolation_type,
|
||||
tenant_id,
|
||||
space_id,
|
||||
container_work_path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(
|
||||
"✅ [CONTAINER_MGR] Container ready: project_id={}, container_id={}, container_identifier={}",
|
||||
project_id, container_info.container_id, container_identifier
|
||||
);
|
||||
|
||||
Ok(container_info)
|
||||
}
|
||||
|
||||
/// 获取容器信息
|
||||
pub async fn get_container_info(
|
||||
project_id: &str,
|
||||
) -> Result<Option<ContainerBasicInfo>, AppError> {
|
||||
debug!(
|
||||
"[CONTAINER_MGR] get container: project_id={}",
|
||||
project_id
|
||||
);
|
||||
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[CONTAINER_MGR] Failed to get global runtime: {}", e);
|
||||
AppError::with_message(
|
||||
ERR_CONTAINER_ERROR,
|
||||
format!("Failed to get global runtime: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
runtime
|
||||
.get_container_info(project_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[CONTAINER_MGR] Failed to query container info: {}", e);
|
||||
AppError::with_message(
|
||||
ERR_CONTAINER_ERROR,
|
||||
format!("Failed to query container info: {}", e),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 project_id 检查对应容器是否存在,不存在就动态创建容器
|
||||
async fn ensure_container_exists(
|
||||
project_id: &str,
|
||||
container_identifier: &str,
|
||||
service_type: &shared_types::ServiceType,
|
||||
request_resource_limits: Option<shared_types::ServiceResourceLimits>,
|
||||
pod_id: Option<&str>,
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
container_work_path: &str,
|
||||
) -> Result<ContainerBasicInfo, AppError> {
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[CONTAINER_MGR] Failed to get global runtime: {}", e);
|
||||
AppError::with_message(
|
||||
ERR_CONTAINER_ERROR,
|
||||
format!("Failed to get global runtime: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 1. 尝试获取现有容器(使用 container_identifier 查找)
|
||||
if let Ok(Some(info)) = runtime.get_container_info(container_identifier).await {
|
||||
info!(
|
||||
"[CONTAINER_MGR] container already exists: container_identifier={}, container_id={}",
|
||||
container_identifier, info.container_id
|
||||
);
|
||||
return Ok(info);
|
||||
}
|
||||
|
||||
// 2. 创建新容器
|
||||
info!(
|
||||
"🏗️ [CONTAINER_MGR] Container does not exist, creating new container: container_identifier={}, service_type={:?}, pod_id={:?}",
|
||||
container_identifier, service_type, pod_id
|
||||
);
|
||||
|
||||
create_container_for_request(
|
||||
project_id,
|
||||
container_identifier,
|
||||
service_type,
|
||||
&runtime,
|
||||
request_resource_limits,
|
||||
pod_id,
|
||||
isolation_type,
|
||||
tenant_id,
|
||||
space_id,
|
||||
container_work_path,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 为请求创建容器
|
||||
async fn create_container_for_request(
|
||||
project_id: &str,
|
||||
container_identifier: &str,
|
||||
service_type: &shared_types::ServiceType,
|
||||
runtime: &Arc<dyn ContainerRuntime>,
|
||||
request_resource_limits: Option<shared_types::ServiceResourceLimits>,
|
||||
pod_id: Option<&str>,
|
||||
isolation_type: Option<&str>,
|
||||
tenant_id: Option<&str>,
|
||||
space_id: Option<&str>,
|
||||
container_work_path: &str,
|
||||
) -> Result<ContainerBasicInfo, AppError> {
|
||||
// 1. 准备工作目录(在 rcoder 容器内创建)
|
||||
// 注意:container_work_path 已经是完整的路径
|
||||
create_workspace_dir(container_work_path).await.map_err(|e| {
|
||||
AppError::with_message(
|
||||
ERR_WORKSPACE_ERROR,
|
||||
format!("Failed to create workspace directory: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"📁 [CONTAINER_MGR] Project workspace prepared: {}",
|
||||
container_work_path
|
||||
);
|
||||
|
||||
// 2. 调用容器运行时启动容器
|
||||
// 注意:project_id 始终使用实际的 project_id(不被 pod_id 覆盖)
|
||||
// 容器命名由 runtime 层通过 pod_id 处理
|
||||
let mut params_builder = ContainerCreateParams::builder()
|
||||
.project_id(project_id)
|
||||
.host_workspace_path("") // 空字符串,表示不使用硬编码挂载
|
||||
.service_type(service_type.clone());
|
||||
|
||||
// 只有在有资源限制时才设置
|
||||
if let Some(limits) = request_resource_limits {
|
||||
params_builder = params_builder.resource_limits(limits);
|
||||
}
|
||||
|
||||
// 设置可选的隔离参数(如果提供的话)
|
||||
if let Some(pid) = pod_id {
|
||||
params_builder = params_builder.pod_id(pid);
|
||||
}
|
||||
if let Some(it) = isolation_type {
|
||||
params_builder = params_builder.isolation_type(it);
|
||||
}
|
||||
if let Some(tid) = tenant_id {
|
||||
params_builder = params_builder.tenant_id(tid);
|
||||
}
|
||||
if let Some(sid) = space_id {
|
||||
params_builder = params_builder.space_id(sid);
|
||||
}
|
||||
|
||||
let params = params_builder.build();
|
||||
|
||||
let container_info = runtime
|
||||
.create_container(params)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("[CONTAINER_MGR] Failed to start container: {}", e);
|
||||
AppError::with_message(
|
||||
ERR_CONTAINER_ERROR,
|
||||
format!("Failed to start container: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"🚀 [CONTAINER_MGR] Container created successfully: container_identifier={}, container_id={}, ip={}",
|
||||
container_identifier, container_info.container_id, container_info.container_ip
|
||||
);
|
||||
|
||||
Ok(container_info)
|
||||
}
|
||||
|
||||
/// 创建工作目录(使用完整路径)
|
||||
async fn create_workspace_dir(full_path: &str) -> Result<std::path::PathBuf, AppError> {
|
||||
let path = std::path::PathBuf::from(full_path);
|
||||
|
||||
// 确保父目录存在
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||
error!(
|
||||
"[CONTAINER_MGR] Failed to create parent directory: {:?}",
|
||||
e
|
||||
);
|
||||
AppError::with_message(
|
||||
ERR_WORKSPACE_ERROR,
|
||||
format!("Failed to create parent directory: {}", e),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
// 创建完整路径目录
|
||||
tokio::fs::create_dir_all(&path).await.map_err(|e| {
|
||||
error!(
|
||||
"[CONTAINER_MGR] Failed to create workspace directory: {:?}",
|
||||
e
|
||||
);
|
||||
AppError::with_message(
|
||||
ERR_WORKSPACE_ERROR,
|
||||
format!("Failed to create workspace directory: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// 生成新的项目ID(UUID去除连字符)
|
||||
pub fn generate_project_id() -> String {
|
||||
uuid::Uuid::new_v4().to_string().replace('-', "")
|
||||
}
|
||||
|
||||
/// 获取 project_id 的 workspace_path
|
||||
pub async fn get_project_workspace(project_id: &str) -> Result<std::path::PathBuf, AppError> {
|
||||
let workspace_dir = std::path::PathBuf::from("/app/project_workspace");
|
||||
let project_dir = workspace_dir.join(project_id);
|
||||
Ok(project_dir)
|
||||
}
|
||||
|
||||
/// 创建项目工作目录
|
||||
pub async fn create_project_workspace(project_id: &str) -> Result<std::path::PathBuf, AppError> {
|
||||
let workspace_dir = std::path::PathBuf::from("/app/project_workspace");
|
||||
|
||||
tokio::fs::create_dir_all(&workspace_dir)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!(
|
||||
"[CONTAINER_MGR] Failed to create workspace directory: {:?}",
|
||||
e
|
||||
);
|
||||
AppError::with_message(
|
||||
ERR_WORKSPACE_ERROR,
|
||||
format!("Failed to create workspace directory: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let project_dir = workspace_dir.join(project_id);
|
||||
tokio::fs::create_dir_all(&project_dir).await.map_err(|e| {
|
||||
error!(
|
||||
"[CONTAINER_MGR] Failed to create project directory: {:?}",
|
||||
e
|
||||
);
|
||||
AppError::with_message(
|
||||
ERR_WORKSPACE_ERROR,
|
||||
format!("Failed to create project directory: {}", e),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(project_dir)
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
//! 容器状态检查器
|
||||
//!
|
||||
//! 定期查询 Agent Runner 的容器状态,如果容器有活跃任务则更新活动时间。
|
||||
//! 这样可以防止正在执行长时间任务的容器被清理任务误判为闲置而销毁。
|
||||
//!
|
||||
//! ## 优化特性
|
||||
//!
|
||||
//! 1. **Docker 主动查询**:gRPC 失败时主动查询 Docker 容器是否存在
|
||||
//! 2. **失败计数器**:为每个容器维护健康状态,记录连续失败次数
|
||||
//! 3. **智能跳过**:连续失败超过阈值后暂时跳过检查
|
||||
//! 4. **自动清理**:容器不存在时立即清理 gRPC 连接池和健康状态
|
||||
//! 5. **分级日志**:根据失败次数输出不同级别的日志
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// 格式化日期时间为标准格式(如:2026-01-12 15:04:30)
|
||||
fn format_datetime(dt: DateTime<Utc>) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// 格式化相对时间(如:5分钟前)
|
||||
fn format_relative_time(dt: DateTime<Utc>) -> String {
|
||||
let now = Utc::now();
|
||||
let duration = now.signed_duration_since(dt);
|
||||
|
||||
if duration.num_seconds() < 60 {
|
||||
format!("{}s ago", duration.num_seconds())
|
||||
} else if duration.num_minutes() < 60 {
|
||||
format!("{}m ago", duration.num_minutes())
|
||||
} else if duration.num_hours() < 24 {
|
||||
format!("{}h ago", duration.num_hours())
|
||||
} else {
|
||||
format!("{}d ago", duration.num_days())
|
||||
}
|
||||
}
|
||||
|
||||
use crate::grpc::GrpcChannelPool;
|
||||
use crate::router::AppState;
|
||||
use shared_types::grpc::GetContainerStatusRequest;
|
||||
|
||||
/// 容器健康状态
|
||||
#[derive(Debug, Clone)]
|
||||
struct ContainerHealthState {
|
||||
/// 连续失败次数
|
||||
consecutive_failures: u32,
|
||||
/// 首次失败时间
|
||||
first_failure_time: Option<DateTime<Utc>>,
|
||||
/// 最后检查时间
|
||||
last_check_time: DateTime<Utc>,
|
||||
/// 最后成功时间
|
||||
last_success_time: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl ContainerHealthState {
|
||||
/// 创建新的健康状态
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
consecutive_failures: 0,
|
||||
first_failure_time: None,
|
||||
last_check_time: Utc::now(),
|
||||
last_success_time: Some(Utc::now()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建失败状态
|
||||
fn new_failed() -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
consecutive_failures: 1,
|
||||
first_failure_time: Some(now),
|
||||
last_check_time: now,
|
||||
last_success_time: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 容器状态检查配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContainerStatusCheckerConfig {
|
||||
/// 检查间隔(默认 30 秒)
|
||||
pub check_interval: Duration,
|
||||
/// 查询超时(默认 5 秒)
|
||||
pub query_timeout: Duration,
|
||||
/// 连续失败阈值(默认 3 次)
|
||||
pub failure_threshold: u32,
|
||||
/// 失败容器跳过时间(默认 5 分钟)
|
||||
pub skip_duration: Duration,
|
||||
/// 健康状态重置周期(默认 30 分钟)
|
||||
pub health_reset_interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for ContainerStatusCheckerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
check_interval: Duration::from_secs(30),
|
||||
query_timeout: Duration::from_secs(5),
|
||||
failure_threshold: 3,
|
||||
skip_duration: Duration::from_secs(5 * 60),
|
||||
health_reset_interval: Duration::from_secs(30 * 60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 容器状态检查器
|
||||
struct ContainerStatusChecker {
|
||||
config: ContainerStatusCheckerConfig,
|
||||
state: Arc<AppState>,
|
||||
/// 容器健康状态映射 (lookup_key -> health_state)
|
||||
health_states: Arc<DashMap<String, ContainerHealthState>>,
|
||||
}
|
||||
|
||||
impl ContainerStatusChecker {
|
||||
/// 创建新的状态检查器
|
||||
fn new(config: ContainerStatusCheckerConfig, state: Arc<AppState>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
state,
|
||||
health_states: Arc::new(DashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查所有容器的状态
|
||||
async fn check_all_containers(&self) -> anyhow::Result<()> {
|
||||
// 收集所有需要检查的容器(创建快照,使用 DuckDB 存储)
|
||||
let containers: Vec<(String, Arc<shared_types::ProjectAndContainerInfo>)> =
|
||||
self.state.projects.iter().collect();
|
||||
|
||||
if containers.is_empty() {
|
||||
debug!("📭 [STATUS_CHECKER] No containers to check");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!(
|
||||
"🔍 [STATUS_CHECKER] Starting to check {} containers",
|
||||
containers.len()
|
||||
);
|
||||
|
||||
let total_count = containers.len();
|
||||
let mut checked = 0;
|
||||
let mut skipped = 0;
|
||||
let mut updated = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
for (_project_id, container_info) in containers {
|
||||
// 使用 container_key() 获取正确的容器标识符
|
||||
// - RCoder 模式:返回 project_id
|
||||
// - ComputerAgentRunner 模式:返回 user_id(用于匹配容器名称)
|
||||
let lookup_key = container_info.container_key().to_string();
|
||||
|
||||
// 🆕 检查所有类型的容器(RCoder 和 ComputerAgentRunner)
|
||||
// 两种模式都可能执行长时间任务,需要定期检查状态防止被误杀
|
||||
|
||||
// 检查是否应该跳过
|
||||
if self.should_skip_check(&lookup_key) {
|
||||
skipped += 1;
|
||||
debug!(
|
||||
"⏭️ [STATUS_CHECKER] Skipping check (recently failed): {}",
|
||||
lookup_key
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
checked += 1;
|
||||
|
||||
// 执行单个容器检查
|
||||
match self
|
||||
.check_single_container(&lookup_key, &container_info)
|
||||
.await
|
||||
{
|
||||
Ok(true) => updated += 1,
|
||||
Ok(false) => {} // 容器空闲或未更新
|
||||
Err(_) => failed += 1,
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"📊 [STATUS_CHECKER] Check completed: total={}, checked={}, skipped={}, updated={}, failed={}",
|
||||
total_count, checked, skipped, updated, failed
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查单个容器
|
||||
///
|
||||
/// 返回是否更新了活动时间
|
||||
async fn check_single_container(
|
||||
&self,
|
||||
lookup_key: &str,
|
||||
container_info: &Arc<shared_types::ProjectAndContainerInfo>,
|
||||
) -> anyhow::Result<bool> {
|
||||
// 获取容器信息
|
||||
let container = match container_info.container() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
debug!("⚠️ [STATUS_CHECKER] Container info not found: {}", lookup_key);
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取最后激活时间用于日志显示
|
||||
let last_activity = container_info.last_activity();
|
||||
let last_activity_str = format_datetime(last_activity);
|
||||
let relative_time_str = format_relative_time(last_activity);
|
||||
|
||||
// 构建 gRPC 地址
|
||||
let grpc_addr = format!(
|
||||
"{}:{}",
|
||||
container.container_ip,
|
||||
shared_types::GRPC_DEFAULT_PORT
|
||||
);
|
||||
|
||||
// 提取 user_id(lookup_key 可能是 user_id 或 project_id)
|
||||
let user_id = container_info
|
||||
.user_id()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| lookup_key.to_string());
|
||||
|
||||
let project_id = container_info.project_id().to_string();
|
||||
|
||||
// 查询容器状态
|
||||
match query_container_status(
|
||||
&grpc_addr,
|
||||
&user_id,
|
||||
&project_id,
|
||||
&self.state.grpc_pool,
|
||||
&self.config,
|
||||
last_activity_str,
|
||||
relative_time_str,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(is_active) => {
|
||||
// ✅ 成功:重置失败计数器
|
||||
self.record_success(lookup_key);
|
||||
|
||||
if is_active {
|
||||
// 容器有活跃任务,更新活动时间和状态
|
||||
// 注意:使用 project_id 更新 DuckDB,而不是 lookup_key
|
||||
if let Err(e) = update_project_activity(&project_id, &self.state).await {
|
||||
warn!(
|
||||
"⚠️ [STATUS_CHECKER] Failed to update activity time: project_id={}, {}",
|
||||
project_id, e
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
// 🆕 同步更新 agent 状态为 Active
|
||||
if let Err(e) = self.state.projects.update_agent_status(
|
||||
&project_id,
|
||||
1, // Active
|
||||
"active",
|
||||
) {
|
||||
warn!(
|
||||
"⚠️ [STATUS_CHECKER] Failed to update agent status to Active: project_id={}, error={}",
|
||||
project_id, e
|
||||
);
|
||||
}
|
||||
debug!(
|
||||
"✅ [STATUS_CHECKER] Container is active, updated activity time and status: container_key={}, project_id={}",
|
||||
lookup_key, project_id
|
||||
);
|
||||
Ok(true)
|
||||
} else {
|
||||
// 🆕 同步更新 agent 状态为 Idle
|
||||
if let Err(e) = self.state.projects.update_agent_status(
|
||||
&project_id,
|
||||
0, // Idle
|
||||
"idle",
|
||||
) {
|
||||
warn!(
|
||||
"⚠️ [STATUS_CHECKER] Failed to update agent status to Idle: project_id={}, error={}",
|
||||
project_id, e
|
||||
);
|
||||
}
|
||||
debug!(
|
||||
"📭 [STATUS_CHECKER] Container is idle, updated status to Idle: container_key={}, project_id={}",
|
||||
lookup_key, project_id
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// ❌ 失败:主动查询 Docker 容器是否存在(关键优化)
|
||||
let container_exists = self
|
||||
.check_container_exists(container_info, &grpc_addr)
|
||||
.await;
|
||||
|
||||
if !container_exists {
|
||||
// 容器不存在,直接清理所有状态
|
||||
info!(
|
||||
"🗑️ [STATUS_CHECKER] Container has been destroyed, cleaning up health state: {}",
|
||||
lookup_key
|
||||
);
|
||||
self.health_states.remove(lookup_key);
|
||||
self.state.grpc_pool.remove(&grpc_addr);
|
||||
// 注意:不移除 DuckDB 存储中的项目记录,由清理任务统一处理
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// 容器存在但连接失败,记录失败(可能是网络问题)
|
||||
self.record_failure(lookup_key, &grpc_addr, &e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 runtime 容器是否存在
|
||||
async fn check_container_exists(
|
||||
&self,
|
||||
container_info: &Arc<shared_types::ProjectAndContainerInfo>,
|
||||
grpc_addr: &str,
|
||||
) -> bool {
|
||||
match docker_manager::runtime::RuntimeManager::get().await {
|
||||
Ok(runtime) => {
|
||||
let service_type = container_info
|
||||
.service_type()
|
||||
.unwrap_or(shared_types::ServiceType::ComputerAgentRunner);
|
||||
|
||||
// 根据 service_type 使用不同的查找方法
|
||||
// - RCoder 模式:使用 project_id 查找
|
||||
// - ComputerAgentRunner 模式:使用 user_id 查找
|
||||
let exists = match service_type {
|
||||
shared_types::ServiceType::ComputerAgentRunner => {
|
||||
// ComputerAgentRunner 模式:使用 user_id 查找容器
|
||||
if let Some(user_id) = container_info.user_id() {
|
||||
match runtime.find_container(user_id, &service_type).await {
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
Err(e) => {
|
||||
debug!("⚠️ [STATUS_CHECKER] Failed to query container: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("⚠️ [STATUS_CHECKER] ComputerAgentRunner missing user_id");
|
||||
false
|
||||
}
|
||||
}
|
||||
shared_types::ServiceType::RCoder => {
|
||||
// RCoder 模式:使用 project_id 查找容器
|
||||
match runtime
|
||||
.find_container(container_info.project_id(), &service_type)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => true,
|
||||
Ok(None) => false,
|
||||
Err(e) => {
|
||||
debug!("⚠️ [STATUS_CHECKER] Failed to query container: {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if exists {
|
||||
debug!(
|
||||
"🔍 [STATUS_CHECKER] Runtime container exists, likely network issue: {} (service_type={:?})",
|
||||
grpc_addr, service_type
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"🔍 [STATUS_CHECKER] Runtime container does not exist (already destroyed): {} (service_type={:?})",
|
||||
grpc_addr, service_type
|
||||
);
|
||||
}
|
||||
|
||||
exists
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[STATUS_CHECKER] get runtime failed: {}", e);
|
||||
// 无法确定容器状态,保守地认为容器存在
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否应该跳过检查
|
||||
fn should_skip_check(&self, lookup_key: &str) -> bool {
|
||||
if let Some(health) = self.health_states.get(lookup_key) {
|
||||
let now = Utc::now();
|
||||
|
||||
// 如果连续失败次数超过阈值
|
||||
if health.consecutive_failures >= self.config.failure_threshold {
|
||||
// 检查是否还在跳过期内
|
||||
if let Some(first_failure) = health.first_failure_time {
|
||||
let elapsed = now.signed_duration_since(first_failure);
|
||||
if let Ok(skip_duration) = chrono::Duration::from_std(self.config.skip_duration)
|
||||
{
|
||||
if elapsed < skip_duration {
|
||||
return true; // 仍在跳过期内
|
||||
}
|
||||
}
|
||||
}
|
||||
// 跳过期已过,允许重新检查(但不重置失败计数器)
|
||||
}
|
||||
}
|
||||
|
||||
false // 默认不跳过
|
||||
}
|
||||
|
||||
/// 记录成功并重置失败计数器
|
||||
fn record_success(&self, lookup_key: &str) {
|
||||
let now = Utc::now();
|
||||
|
||||
use dashmap::mapref::entry::Entry;
|
||||
|
||||
match self.health_states.entry(lookup_key.to_string()) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
// 使用 get_mut 直接修改,避免克隆
|
||||
let was_failing = entry.get().consecutive_failures > 0;
|
||||
let health = entry.get_mut();
|
||||
health.consecutive_failures = 0;
|
||||
health.first_failure_time = None;
|
||||
health.last_check_time = now;
|
||||
health.last_success_time = Some(now);
|
||||
// 无需 insert,修改已生效
|
||||
|
||||
if was_failing {
|
||||
info!("[STATUS_CHECKER] Container recovered: {}", lookup_key);
|
||||
}
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(ContainerHealthState::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录失败并清理连接
|
||||
fn record_failure(&self, lookup_key: &str, grpc_addr: &str, error: &anyhow::Error) {
|
||||
let now = Utc::now();
|
||||
|
||||
use dashmap::mapref::entry::Entry;
|
||||
|
||||
let consecutive_failures = match self.health_states.entry(lookup_key.to_string()) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
// 使用 get_mut 直接修改,避免克隆
|
||||
let health = entry.get_mut();
|
||||
health.consecutive_failures += 1;
|
||||
health.last_check_time = now;
|
||||
if health.first_failure_time.is_none() {
|
||||
health.first_failure_time = Some(now);
|
||||
}
|
||||
let failures = health.consecutive_failures;
|
||||
// 无需 insert,修改已生效
|
||||
failures
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(ContainerHealthState::new_failed());
|
||||
1
|
||||
}
|
||||
};
|
||||
|
||||
// 🔌 第1次失败或达到阈值时,清理 gRPC 连接池
|
||||
if consecutive_failures == 1 || consecutive_failures == self.config.failure_threshold {
|
||||
self.state.grpc_pool.remove(grpc_addr);
|
||||
info!(
|
||||
"🔌 [STATUS_CHECKER] Already cleanup connection: {}",
|
||||
grpc_addr
|
||||
);
|
||||
}
|
||||
|
||||
// 📊 分级日志输出
|
||||
match consecutive_failures {
|
||||
1 => {
|
||||
// 首次失败:INFO 级别
|
||||
info!(
|
||||
"❌ [STATUS_CHECKER] Container first query failed: {} - {}",
|
||||
lookup_key, error
|
||||
);
|
||||
}
|
||||
n if n < self.config.failure_threshold => {
|
||||
// 持续失败但未达到阈值:DEBUG 级别
|
||||
debug!(
|
||||
"❌ [STATUS_CHECKER] Container continuous failure ({}/{}): {}",
|
||||
n, self.config.failure_threshold, lookup_key
|
||||
);
|
||||
}
|
||||
n if n == self.config.failure_threshold => {
|
||||
// 达到阈值:WARN 级别
|
||||
warn!(
|
||||
"⚠️ [STATUS_CHECKER] Container continuous failures reached threshold, will skip check temporarily: {} (failures: {})",
|
||||
lookup_key, n
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
// 超过阈值后的偶发检查:DEBUG 级别
|
||||
debug!("⏭️ [STATUS_CHECKER] Skipping check for: {}", lookup_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理过期的健康状态
|
||||
fn cleanup_stale_health_states(&self) {
|
||||
let now = Utc::now();
|
||||
let retention_duration = match chrono::Duration::from_std(self.config.health_reset_interval)
|
||||
{
|
||||
Ok(d) => d,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut removed_count = 0;
|
||||
|
||||
// 收集需要移除的 key
|
||||
let keys_to_remove: Vec<String> = self
|
||||
.health_states
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let lookup_key = entry.key();
|
||||
let health = entry.value();
|
||||
|
||||
// 移除条件:
|
||||
// 1. 容器已不在 DuckDB 存储中
|
||||
// 2. 最后检查时间超过健康重置周期
|
||||
let not_in_storage = !self.state.contains_project(lookup_key);
|
||||
let elapsed = now.signed_duration_since(health.last_check_time);
|
||||
let is_stale = elapsed > retention_duration;
|
||||
|
||||
if not_in_storage || is_stale {
|
||||
Some(lookup_key.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 批量移除
|
||||
for key in keys_to_remove {
|
||||
if self.health_states.remove(&key).is_some() {
|
||||
removed_count += 1;
|
||||
debug!("🧹 [STATUS_CHECKER] Cleaned up stale health state: {}", key);
|
||||
}
|
||||
}
|
||||
|
||||
if removed_count > 0 {
|
||||
info!(
|
||||
"🧹 [STATUS_CHECKER] Cleaned up stale health states: removed={}",
|
||||
removed_count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动容器状态检查任务
|
||||
///
|
||||
/// 定期查询所有容器的 Agent Runner 状态,如果容器有活跃任务则更新活动时间
|
||||
pub fn start_container_status_checker(
|
||||
config: ContainerStatusCheckerConfig,
|
||||
state: Arc<AppState>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
info!(
|
||||
"🔍 [STATUS_CHECKER] Starting container status checker: interval={}s, failure_threshold={}, skip_duration={}s",
|
||||
config.check_interval.as_secs(),
|
||||
config.failure_threshold,
|
||||
config.skip_duration.as_secs()
|
||||
);
|
||||
|
||||
let checker = Arc::new(ContainerStatusChecker::new(config.clone(), state));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = time::interval(config.check_interval);
|
||||
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
|
||||
|
||||
let mut cleanup_counter = 0;
|
||||
let cleanup_interval = 10; // 每 10 次检查清理一次健康状态
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// 执行容器状态检查
|
||||
if let Err(e) = checker.check_all_containers().await {
|
||||
warn!("[STATUS_CHECKER] containerstatuscheckfailed: {}", e);
|
||||
}
|
||||
|
||||
// 定期清理过期的健康状态
|
||||
cleanup_counter += 1;
|
||||
if cleanup_counter >= cleanup_interval {
|
||||
checker.cleanup_stale_health_states();
|
||||
cleanup_counter = 0;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 查询容器状态
|
||||
///
|
||||
/// 返回容器是否活跃(有活跃任务)
|
||||
async fn query_container_status(
|
||||
grpc_addr: &str,
|
||||
user_id: &str,
|
||||
project_id: &str,
|
||||
grpc_pool: &Arc<GrpcChannelPool>,
|
||||
config: &ContainerStatusCheckerConfig,
|
||||
last_activity_str: String,
|
||||
relative_time_str: String,
|
||||
) -> anyhow::Result<bool> {
|
||||
// 获取 gRPC 客户端
|
||||
let mut client = grpc_pool.get_client(grpc_addr).await?;
|
||||
|
||||
// 构建请求
|
||||
let request = tonic::Request::new(GetContainerStatusRequest {
|
||||
user_id: user_id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
});
|
||||
|
||||
// 发送请求(带超时)
|
||||
let response =
|
||||
tokio::time::timeout(config.query_timeout, client.get_container_status(request)).await??;
|
||||
|
||||
let status_response = response.into_inner();
|
||||
|
||||
debug!(
|
||||
"📊 [STATUS_CHECKER] Container status: user_id={}, is_active={}, active_tasks={}, status={}, last_activity={} ({})",
|
||||
user_id,
|
||||
status_response.is_active,
|
||||
status_response.active_tasks,
|
||||
status_response.status,
|
||||
last_activity_str,
|
||||
relative_time_str
|
||||
);
|
||||
|
||||
// 如果容器有活跃任务,则认为容器活跃
|
||||
Ok(status_response.is_active || status_response.active_tasks > 0)
|
||||
}
|
||||
|
||||
/// 更新项目活动时间(并同步更新关联容器的活动时间)
|
||||
///
|
||||
/// 使用 DuckDB 存储更新 projects 表的 last_activity 字段
|
||||
async fn update_project_activity(project_id: &str, state: &Arc<AppState>) -> anyhow::Result<()> {
|
||||
// 使用 ProjectAdapter 的 update_activity 方法
|
||||
// 该方法会同时更新 project 和关联 container 的 last_activity
|
||||
state.update_activity(project_id);
|
||||
Ok(())
|
||||
}
|
||||
91
qiming-rcoder/crates/rcoder/src/service/container_sync.rs
Normal file
91
qiming-rcoder/crates/rcoder/src/service/container_sync.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! 容器状态同步任务
|
||||
//!
|
||||
//! 定期从运行时同步容器状态。
|
||||
//! 在 K8s 模式下通过 Runtime API 获取 Pod 状态;在 Docker 模式下同样通过 Runtime 抽象层访问。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::grpc::GrpcChannelPool;
|
||||
|
||||
/// 容器状态同步配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContainerSyncConfig {
|
||||
/// 同步间隔
|
||||
pub sync_interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for ContainerSyncConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sync_interval: Duration::from_secs(60), // 默认 60 秒
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动容器状态同步任务
|
||||
///
|
||||
/// 定期调用 `ContainerRuntime::sync_states()` 方法,
|
||||
/// 检查缓存中的容器是否仍然存在于运行时(Docker/K8s)中,
|
||||
/// 如果不存在则从缓存中移除。
|
||||
///
|
||||
/// 同时清理已移除容器的关联资源:
|
||||
/// - gRPC 连接池中的旧连接
|
||||
pub fn start_container_sync_task(
|
||||
config: ContainerSyncConfig,
|
||||
grpc_pool: Arc<GrpcChannelPool>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
info!(
|
||||
"🔄 [CONTAINER_SYNC] Starting container state sync task: interval={}s",
|
||||
config.sync_interval.as_secs()
|
||||
);
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let mut interval = tokio::time::interval(config.sync_interval);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// 获取全局 Runtime
|
||||
let runtime = match docker_manager::runtime::RuntimeManager::get().await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
warn!("[CONTAINER_SYNC] Failed to get runtime: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 同步缓存状态 - 清理失效的容器记录
|
||||
debug!("[CONTAINER_SYNC] Syncing container states...");
|
||||
match runtime.sync_states().await {
|
||||
Ok((checked, removed)) => {
|
||||
if !removed.is_empty() {
|
||||
info!(
|
||||
"[CONTAINER_SYNC] Sync completed: checked={}, removed_stale={}",
|
||||
checked,
|
||||
removed.len()
|
||||
);
|
||||
|
||||
// 清理关联资源
|
||||
for container in removed {
|
||||
// 清理 gRPC 连接池
|
||||
if !container.container_ip.is_empty() {
|
||||
let grpc_addr = format!(
|
||||
"{}:{}",
|
||||
container.container_ip,
|
||||
shared_types::GRPC_DEFAULT_PORT
|
||||
);
|
||||
grpc_pool.remove(&grpc_addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[CONTAINER_SYNC] sync failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
21
qiming-rcoder/crates/rcoder/src/service/mod.rs
Normal file
21
qiming-rcoder/crates/rcoder/src/service/mod.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! 服务模块
|
||||
//!
|
||||
//! 提供容器管理功能
|
||||
//!
|
||||
//! ## 模块说明
|
||||
//! - `container_manager`: RCoder 模式的容器管理(project_id -> 容器)
|
||||
//! - `computer_container_manager`: ComputerAgentRunner 模式的容器管理(user_id -> 容器)
|
||||
//! - `container_status_checker`: 容器状态检查器(定期查询容器状态,防止误杀)
|
||||
//! - `container_sync`: 容器状态同步任务(定期同步 Docker 容器状态到内存缓存)
|
||||
//! - `vnc_sync`: VNC 后端同步任务(定期同步容器 IP 到 Pingora 的 vnc_backends)
|
||||
|
||||
pub mod computer_container_manager;
|
||||
pub mod container_manager;
|
||||
pub mod container_status_checker;
|
||||
pub mod container_sync;
|
||||
pub mod vnc_sync;
|
||||
|
||||
pub use computer_container_manager::ComputerContainerManager;
|
||||
pub use container_status_checker::{ContainerStatusCheckerConfig, start_container_status_checker};
|
||||
pub use container_sync::{ContainerSyncConfig, start_container_sync_task};
|
||||
pub use vnc_sync::{VncSyncConfig, start_vnc_sync_task};
|
||||
237
qiming-rcoder/crates/rcoder/src/service/vnc_sync.rs
Normal file
237
qiming-rcoder/crates/rcoder/src/service/vnc_sync.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
//! VNC 后端同步任务
|
||||
//!
|
||||
//! 定期从 Runtime 同步容器的 IP 到 Pingora 的 vnc_backends 映射。
|
||||
//! 使用容器命名规则解析业务标识符:
|
||||
//! - RCoder: `rcoder-agent-{project_id}`
|
||||
//! - ComputerAgentRunner: `computer-agent-runner-{user_id}`
|
||||
//! 解决服务重启后 VNC 映射丢失的问题,并支持容器重启后自动更新 IP。
|
||||
|
||||
use rcoder_proxy::PingoraProxyService;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// VNC 后端同步配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VncSyncConfig {
|
||||
/// 同步间隔
|
||||
pub sync_interval: Duration,
|
||||
}
|
||||
|
||||
impl Default for VncSyncConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sync_interval: Duration::from_secs(5), // 默认 5 秒
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 VNC 后端同步任务
|
||||
///
|
||||
/// 定期扫描所有容器,使用 `container_key()` 获取正确的业务标识符,
|
||||
/// 将 user_id/project_id -> container_ip 映射同步到 Pingora 的 vnc_backends。
|
||||
///
|
||||
/// 这解决了两个问题:
|
||||
/// 1. 服务重启后 vnc_backends 丢失
|
||||
/// 2. 容器重启后 IP 变化
|
||||
pub fn start_vnc_sync_task(
|
||||
pingora_service: Arc<PingoraProxyService>,
|
||||
config: VncSyncConfig,
|
||||
rcoder_prefix: String,
|
||||
computer_prefix: String,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
info!(
|
||||
"🔄 [VNC_SYNC] Starting VNC backend sync task: interval={}s",
|
||||
config.sync_interval.as_secs()
|
||||
);
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let mut interval = tokio::time::interval(config.sync_interval);
|
||||
|
||||
// 首次立即执行同步(用于服务启动时恢复映射)
|
||||
sync_vnc_backends(&pingora_service, &rcoder_prefix, &computer_prefix).await;
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
sync_vnc_backends(&pingora_service, &rcoder_prefix, &computer_prefix).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 同步 VNC 后端映射
|
||||
async fn sync_vnc_backends(
|
||||
pingora_service: &Arc<PingoraProxyService>,
|
||||
rcoder_prefix: &str,
|
||||
computer_prefix: &str,
|
||||
) {
|
||||
let runtime = match docker_manager::runtime::RuntimeManager::get().await {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
warn!("[VNC_SYNC] Failed to get runtime: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let containers = match runtime.list_containers().await {
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
warn!("[VNC_SYNC] Failed to list containers from runtime: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if containers.is_empty() {
|
||||
debug!("[VNC_SYNC] Syncing containers");
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用配置化的前缀,而不是硬编码的 ServiceType::container_prefix()
|
||||
|
||||
// 预先收集运行中容器的 key 集合(用于后续清理旧映射)
|
||||
let active_user_ids: std::collections::HashSet<String> = containers
|
||||
.iter()
|
||||
.filter(|c| c.status == container_runtime_api::ContainerRuntimeStatus::Running)
|
||||
.filter_map(|c| {
|
||||
if c.container_name.starts_with(computer_prefix) {
|
||||
return c
|
||||
.container_name
|
||||
.strip_prefix(&format!("{}-", computer_prefix))
|
||||
.map(|v| v.to_string());
|
||||
}
|
||||
if c.container_name.starts_with(rcoder_prefix) {
|
||||
return c
|
||||
.container_name
|
||||
.strip_prefix(&format!("{}-", rcoder_prefix))
|
||||
.map(|v| v.to_string());
|
||||
}
|
||||
None
|
||||
})
|
||||
.filter(|k| !k.is_empty())
|
||||
.collect();
|
||||
|
||||
let mut synced_count = 0;
|
||||
let mut updated_count = 0;
|
||||
|
||||
for container_info in containers {
|
||||
let user_id = if container_info.container_name.starts_with(computer_prefix) {
|
||||
container_info
|
||||
.container_name
|
||||
.strip_prefix(&format!("{}-", computer_prefix))
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
} else if container_info.container_name.starts_with(rcoder_prefix) {
|
||||
container_info
|
||||
.container_name
|
||||
.strip_prefix(&format!("{}-", rcoder_prefix))
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if user_id.is_empty() {
|
||||
debug!(
|
||||
"⏭️ [VNC_SYNC] Skipping container without business identifier: {}",
|
||||
container_info.container_name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查容器是否在运行
|
||||
if container_info.status != container_runtime_api::ContainerRuntimeStatus::Running {
|
||||
debug!(
|
||||
"⏭️ [VNC_SYNC] Skipping non-running container: {} (status={})",
|
||||
container_info.container_name,
|
||||
String::from(container_info.status.clone())
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let container_ip = container_info.container_ip.clone();
|
||||
if container_ip.is_empty() {
|
||||
warn!(
|
||||
"⚠️ [VNC_SYNC] Container {} has no IP address",
|
||||
container_info.container_name
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否需要更新映射
|
||||
let needs_update = match pingora_service.get_vnc_backend(&user_id) {
|
||||
Some(existing_ip) if existing_ip == container_ip => {
|
||||
// IP 没有变化,无需更新
|
||||
false
|
||||
}
|
||||
Some(existing_ip) => {
|
||||
// IP 变化了,需要更新
|
||||
debug!(
|
||||
"🔄 [VNC_SYNC] Container IP changed: user_id={}, old={}, new={}",
|
||||
user_id, existing_ip, container_ip
|
||||
);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
// 新容器,需要添加
|
||||
debug!(
|
||||
"➕ [VNC_SYNC] New container mapping: user_id={} -> {}",
|
||||
user_id, container_ip
|
||||
);
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if needs_update {
|
||||
pingora_service.add_vnc_backend(&user_id, &container_ip);
|
||||
updated_count += 1;
|
||||
}
|
||||
synced_count += 1;
|
||||
}
|
||||
|
||||
if updated_count > 0 {
|
||||
info!(
|
||||
"🔄 [VNC_SYNC] Sync completed: checked={}, updated={}",
|
||||
synced_count, updated_count
|
||||
);
|
||||
} else if synced_count > 0 {
|
||||
debug!(
|
||||
"[VNC_SYNC] Sync completed: synced={}",
|
||||
synced_count
|
||||
);
|
||||
}
|
||||
|
||||
// === 清理已销毁容器的旧映射 ===
|
||||
// 获取当前所有 VNC 后端映射
|
||||
let current_backends = pingora_service.list_vnc_backends();
|
||||
|
||||
let mut removed_count = 0;
|
||||
for user_id in current_backends.keys() {
|
||||
if !active_user_ids.contains(user_id) {
|
||||
pingora_service.remove_vnc_backend(user_id);
|
||||
removed_count += 1;
|
||||
debug!(
|
||||
"🗑️ [VNC_SYNC] Cleaning up already destroyed container mapping: user_id={}",
|
||||
user_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if removed_count > 0 {
|
||||
info!(
|
||||
"🗑️ [VNC_SYNC] Cleanup completed: removed={} mappings",
|
||||
removed_count
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步单个容器的 VNC 后端映射
|
||||
///
|
||||
/// 用于在创建容器时立即更新映射,不需要等待定时任务
|
||||
pub async fn sync_single_vnc_backend(
|
||||
pingora_service: &Arc<PingoraProxyService>,
|
||||
user_id: &str,
|
||||
container_ip: &str,
|
||||
) {
|
||||
pingora_service.add_vnc_backend(user_id, container_ip);
|
||||
debug!(
|
||||
"➕ [VNC_SYNC] Single container mapping updated: user_id={} -> {}",
|
||||
user_id, container_ip
|
||||
);
|
||||
}
|
||||
569
qiming-rcoder/crates/rcoder/src/storage/adapter.rs
Normal file
569
qiming-rcoder/crates/rcoder/src/storage/adapter.rs
Normal file
@@ -0,0 +1,569 @@
|
||||
//! 项目适配器
|
||||
//!
|
||||
//! 提供统一的项目数据访问接口,替代原有的 3 个 DashMap:
|
||||
//! - project_and_agent_map: DashMap<String, Arc<ProjectAndContainerInfo>>
|
||||
//! - sessions: DashMap<String, Arc<ProjectAndContainerInfo>>
|
||||
//! - session_to_container_id: DashMap<String, String>
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use duckdb_manager::{ContainerRecord, DuckDbStorage, ProjectRecord, StorageStats, UnifiedStorage};
|
||||
use shared_types::{ContainerBasicInfo, ProjectAndContainerInfo, ServiceType};
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::bridge::DataBridge;
|
||||
|
||||
/// 项目适配器
|
||||
///
|
||||
/// 统一管理项目、会话和容器数据,替代原有的 3 个 DashMap
|
||||
#[derive(Clone)]
|
||||
pub struct ProjectAdapter {
|
||||
storage: Arc<DuckDbStorage>,
|
||||
}
|
||||
|
||||
impl ProjectAdapter {
|
||||
/// 创建新的项目适配器
|
||||
pub fn new() -> Result<Self, duckdb_manager::DuckDbError> {
|
||||
let storage = DuckDbStorage::new()?;
|
||||
Ok(Self {
|
||||
storage: Arc::new(storage),
|
||||
})
|
||||
}
|
||||
|
||||
/// 从现有存储创建适配器
|
||||
pub fn from_storage(storage: Arc<DuckDbStorage>) -> Self {
|
||||
Self { storage }
|
||||
}
|
||||
|
||||
// ========== project_and_agent_map 替代方法 ==========
|
||||
|
||||
/// 获取项目信息(替代 project_and_agent_map.get)
|
||||
pub fn get(&self, project_id: &str) -> Option<Arc<ProjectAndContainerInfo>> {
|
||||
match self.storage.get_project(project_id) {
|
||||
Ok(Some(record)) => {
|
||||
// 获取关联的容器信息
|
||||
let container = self.get_container_for_project(&record);
|
||||
Some(Arc::new(DataBridge::project_record_to_info(
|
||||
&record, container,
|
||||
)))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("getproject {} failed: {}", project_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 插入或更新项目信息(替代 project_and_agent_map.insert)
|
||||
pub fn insert(
|
||||
&self,
|
||||
project_id: String,
|
||||
info: Arc<ProjectAndContainerInfo>,
|
||||
) -> Result<(), duckdb_manager::DuckDbError> {
|
||||
// 如果有容器信息,先保存容器
|
||||
if let Some(container) = info.container() {
|
||||
debug!(
|
||||
"Inserting project container info: project_id={}, container_id={}, container_ip={}",
|
||||
project_id, container.container_id, container.container_ip
|
||||
);
|
||||
let container_record =
|
||||
DataBridge::container_info_to_record(container, info.service_type());
|
||||
self.storage.save_container(&container_record)?;
|
||||
} else {
|
||||
debug!(
|
||||
"No project container: project_id={}",
|
||||
project_id
|
||||
);
|
||||
}
|
||||
|
||||
// 保存项目记录
|
||||
let record = DataBridge::info_to_project_record(&info, &project_id);
|
||||
debug!(
|
||||
"Saving project record: project_id={}, session_id={:?}, container_id={}",
|
||||
record.project_id, record.session_id, record.container_id
|
||||
);
|
||||
self.storage.save_project(&record)?;
|
||||
|
||||
debug!("Removed project: {}", project_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除项目(替代 project_and_agent_map.remove)
|
||||
pub fn remove(&self, project_id: &str) -> Option<Arc<ProjectAndContainerInfo>> {
|
||||
// 先获取现有数据
|
||||
let info = self.get(project_id)?;
|
||||
|
||||
// 删除项目记录
|
||||
if let Err(e) = self.storage.delete_project(project_id) {
|
||||
warn!("Failed to remove project {}: {}", project_id, e);
|
||||
return None;
|
||||
}
|
||||
|
||||
debug!("Removed project: {}", project_id);
|
||||
Some(info)
|
||||
}
|
||||
|
||||
/// 检查项目是否存在(替代 project_and_agent_map.contains_key)
|
||||
pub fn contains_key(&self, project_id: &str) -> bool {
|
||||
match self.storage.project_exists(project_id) {
|
||||
Ok(exists) => exists,
|
||||
Err(e) => {
|
||||
warn!("Failed to check project {} exists: {}", project_id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有项目(替代 project_and_agent_map.iter)
|
||||
pub fn iter(&self) -> impl Iterator<Item = (String, Arc<ProjectAndContainerInfo>)> + '_ {
|
||||
let projects = self.storage.get_all_projects().unwrap_or_default();
|
||||
projects.into_iter().filter_map(move |record| {
|
||||
let container = self.get_container_for_project(&record);
|
||||
let info = DataBridge::project_record_to_info(&record, container);
|
||||
Some((record.project_id.clone(), Arc::new(info)))
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取项目数量(替代 project_and_agent_map.len)
|
||||
pub fn len(&self) -> usize {
|
||||
match self.storage.get_stats() {
|
||||
Ok(stats) => stats.total_projects,
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否为空(替代 project_and_agent_map.is_empty)
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
|
||||
// ========== sessions 替代方法 ==========
|
||||
|
||||
/// 通过会话ID获取项目信息(替代 sessions.get)
|
||||
pub fn get_by_session_id(&self, session_id: &str) -> Option<Arc<ProjectAndContainerInfo>> {
|
||||
match self.storage.get_project_by_session(session_id) {
|
||||
Ok(Some(record)) => {
|
||||
let container = self.get_container_for_project(&record);
|
||||
Some(Arc::new(DataBridge::project_record_to_info(
|
||||
&record, container,
|
||||
)))
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("Failed to get project for session {}: {}", session_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新会话信息(替代 sessions.insert 和会话更新逻辑)
|
||||
pub fn update_session(
|
||||
&self,
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
) -> Result<(), duckdb_manager::DuckDbError> {
|
||||
self.storage.update_session(project_id, session_id)?;
|
||||
debug!(
|
||||
"Updating session: project_id={}, session_id={}",
|
||||
project_id, session_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清除会话信息(将 session_id 设置为 NULL)
|
||||
///
|
||||
/// 用于 Agent 停止后清理会话状态
|
||||
pub fn clear_session(&self, project_id: &str) -> Result<(), duckdb_manager::DuckDbError> {
|
||||
self.storage.clear_session(project_id)?;
|
||||
debug!(
|
||||
"Clearing session: project_id={}",
|
||||
project_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== session_to_container_id 替代方法 ==========
|
||||
|
||||
/// 通过会话ID获取容器名称(用于容器重启后的容器查询)
|
||||
///
|
||||
/// 与 `get_container_id_by_session` 不同,返回稳定的 `container_name`(如 `computer-agent-runner-user_123`)。
|
||||
/// 即使容器被重建,container_name 保持不变,可直接通过 Docker API 查询容器状态。
|
||||
pub fn get_container_name_by_session(&self, session_id: &str) -> Option<String> {
|
||||
match self.storage.get_container_name_by_session(session_id) {
|
||||
Ok(container_name) => {
|
||||
debug!(
|
||||
"Getting container name by session_id: session_id={}, container_name={:?}",
|
||||
session_id, container_name
|
||||
);
|
||||
container_name
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
" sessionID {} getcontainer failed: {}",
|
||||
session_id, e
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 活动时间更新方法 ==========
|
||||
|
||||
/// 更新项目活动时间,返回实际更新使用的时间戳
|
||||
pub fn update_activity(&self, project_id: &str) -> Option<DateTime<Utc>> {
|
||||
match self.storage.update_project_activity(project_id) {
|
||||
Ok(Some(updated_time)) => {
|
||||
// 使用相同的时间更新关联容器的活动时间
|
||||
if let Ok(Some(record)) = self.storage.get_project(project_id) {
|
||||
let _ = self
|
||||
.storage
|
||||
.update_container_activity_with_time(&record.container_id, updated_time);
|
||||
}
|
||||
Some(updated_time)
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("update project {} failed: {}", project_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新会话活动时间
|
||||
pub fn update_session_activity(&self, session_id: &str) -> bool {
|
||||
match self.storage.update_session_activity(session_id) {
|
||||
Ok(updated) => {
|
||||
if updated {
|
||||
// 底层 storage.update_session_activity 现在会自动更新关联容器的活动时间
|
||||
// 无需在此处手动调用,避免了使用不可靠的 container_id
|
||||
}
|
||||
updated
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("update session {} failed: {}", session_id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Agent 状态更新方法 ==========
|
||||
|
||||
/// 原子更新 Agent 状态
|
||||
pub fn update_agent_status(
|
||||
&self,
|
||||
project_id: &str,
|
||||
status_code: i32,
|
||||
status_name: &str,
|
||||
) -> Result<bool, duckdb_manager::DuckDbError> {
|
||||
self.storage
|
||||
.update_agent_status(project_id, status_code, status_name)
|
||||
}
|
||||
|
||||
// ========== 容器相关方法 ==========
|
||||
|
||||
/// 保存容器信息
|
||||
pub fn save_container(
|
||||
&self,
|
||||
container: &ContainerBasicInfo,
|
||||
service_type: Option<ServiceType>,
|
||||
) -> Result<(), duckdb_manager::DuckDbError> {
|
||||
let record = DataBridge::container_info_to_record(container, service_type);
|
||||
self.storage.save_container(&record)
|
||||
}
|
||||
|
||||
/// 获取容器信息
|
||||
pub fn get_container(&self, container_id: &str) -> Option<ContainerBasicInfo> {
|
||||
match self.storage.get_container(container_id) {
|
||||
Ok(Some(record)) => Some(DataBridge::container_record_to_info(&record)),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!("getcontainer {} failed: {}", container_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除容器及其关联的项目
|
||||
pub fn delete_container_with_projects(
|
||||
&self,
|
||||
container_id: &str,
|
||||
) -> Result<(bool, usize), duckdb_manager::DuckDbError> {
|
||||
self.storage.delete_container_with_projects(container_id)
|
||||
}
|
||||
|
||||
/// 按服务类型获取所有容器
|
||||
pub fn get_containers_by_service_type(
|
||||
&self,
|
||||
service_type: ServiceType,
|
||||
) -> Vec<ContainerBasicInfo> {
|
||||
match self.storage.get_containers_by_service_type(service_type) {
|
||||
Ok(records) => records
|
||||
.iter()
|
||||
.map(DataBridge::container_record_to_info)
|
||||
.collect(),
|
||||
Err(e) => {
|
||||
warn!("Failed to get container: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有容器记录
|
||||
pub fn get_all_container_records(
|
||||
&self,
|
||||
) -> Result<Vec<ContainerRecord>, duckdb_manager::DuckDbError> {
|
||||
self.storage.get_all_containers()
|
||||
}
|
||||
|
||||
/// 根据容器ID获取关联的项目列表
|
||||
pub fn get_projects_by_container_id(
|
||||
&self,
|
||||
container_id: &str,
|
||||
) -> Result<Vec<ProjectRecord>, duckdb_manager::DuckDbError> {
|
||||
self.storage.get_projects_by_container(container_id)
|
||||
}
|
||||
|
||||
// ========== ComputerAgentRunner 模式专用方法 ==========
|
||||
|
||||
/// 通过用户ID获取容器信息(ComputerAgentRunner模式)
|
||||
///
|
||||
/// 在 ComputerAgentRunner 模式中,一个用户对应一个容器
|
||||
/// 此方法返回该用户最近活跃项目关联的容器信息
|
||||
pub fn get_container_by_user_id(&self, user_id: &str) -> Option<ContainerBasicInfo> {
|
||||
match self.storage.get_latest_container_id_by_user_id(user_id) {
|
||||
Ok(Some(container_id)) => {
|
||||
// 通过容器ID获取容器信息
|
||||
self.get_container(&container_id)
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!("No project for user_id {} ", user_id);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to get container for user_id {}: {}", user_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过 Pod ID 获取容器信息(共享容器模式)
|
||||
///
|
||||
/// 在共享容器模式中,多个项目可能共享同一个容器
|
||||
/// 此方法返回该 Pod 下最近活跃项目关联的容器信息
|
||||
pub fn get_container_by_pod_id(&self, pod_id: &str) -> Option<ContainerBasicInfo> {
|
||||
match self.storage.get_latest_container_id_by_pod_id(pod_id) {
|
||||
Ok(Some(container_id)) => {
|
||||
debug!(
|
||||
"Found container_id for pod_id={}: container_id={}",
|
||||
pod_id, container_id
|
||||
);
|
||||
self.get_container(&container_id)
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!("No container found for pod_id={}", pod_id);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to get container for pod_id {}: {}", pod_id, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 通过用户ID查找所有项目(ComputerAgentRunner模式)
|
||||
///
|
||||
/// 返回该用户的所有项目记录,按最后活动时间倒序排列
|
||||
pub fn find_projects_by_user_id(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Vec<duckdb_manager::models::ProjectRecord> {
|
||||
match self.storage.find_projects_by_user_id(user_id) {
|
||||
Ok(projects) => projects,
|
||||
Err(e) => {
|
||||
warn!("Failed to get project for user_id {}: {}", user_id, e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据 pod_id 查找所有项目(RCoder 共享容器模式)
|
||||
///
|
||||
/// 返回该 Pod 下所有项目记录,按最后活动时间倒序排列
|
||||
pub fn find_projects_by_pod_id(
|
||||
&self,
|
||||
pod_id: &str,
|
||||
) -> Vec<duckdb_manager::models::ProjectRecord> {
|
||||
match self.storage.find_projects_by_pod_id(pod_id) {
|
||||
Ok(projects) => projects,
|
||||
Err(e) => {
|
||||
warn!("Failed to get project for pod_id {}: {}", pod_id, e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 清理相关方法 ==========
|
||||
|
||||
/// 查找闲置容器
|
||||
pub fn find_idle_containers(
|
||||
&self,
|
||||
idle_minutes: i64,
|
||||
protection_minutes: i64,
|
||||
) -> Vec<duckdb_manager::IdleContainerInfo> {
|
||||
match self
|
||||
.storage
|
||||
.find_idle_containers(idle_minutes, protection_minutes)
|
||||
{
|
||||
Ok(containers) => containers,
|
||||
Err(e) => {
|
||||
warn!("Failed to get container: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取存储统计信息
|
||||
pub fn get_stats(&self) -> StorageStats {
|
||||
match self.storage.get_stats() {
|
||||
Ok(stats) => stats,
|
||||
Err(e) => {
|
||||
warn!("get value failed: {}", e);
|
||||
StorageStats::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 调试方法 ==========
|
||||
|
||||
/// 执行原始 SQL 查询(仅用于调试)
|
||||
///
|
||||
/// ⚠️ 此方法仅用于开发和调试目的
|
||||
pub fn execute_raw_query(
|
||||
&self,
|
||||
sql: &str,
|
||||
) -> Result<(Vec<String>, Vec<serde_json::Value>), String> {
|
||||
self.storage
|
||||
.execute_raw_query(sql)
|
||||
.map_err(|e| format!("Query execution failed: {}", e))
|
||||
}
|
||||
|
||||
/// 获取 DuckDB 内存使用统计(用于调试和监控)
|
||||
pub fn get_memory_stats(&self) -> Result<String, String> {
|
||||
self.storage
|
||||
.get_memory_stats()
|
||||
.map_err(|e| format!("Failed to get memory stats: {}", e))
|
||||
}
|
||||
|
||||
// ========== 内部辅助方法 ==========
|
||||
|
||||
/// 获取项目关联的容器信息
|
||||
fn get_container_for_project(&self, record: &ProjectRecord) -> Option<ContainerBasicInfo> {
|
||||
match self.storage.get_container(&record.container_id) {
|
||||
Ok(Some(container)) => Some(DataBridge::container_record_to_info(&container)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProjectAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("创建 ProjectAdapter 失败")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProjectAdapter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ProjectAdapter")
|
||||
.field("storage", &"<DuckDbStorage>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_info(project_id: &str) -> ProjectAndContainerInfo {
|
||||
let mut info = ProjectAndContainerInfo::new(project_id.to_string());
|
||||
info.set_service_type(Some(ServiceType::RCoder));
|
||||
info
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_crud() {
|
||||
let adapter = ProjectAdapter::new().unwrap();
|
||||
|
||||
let project_id = "test-project-1";
|
||||
let info = Arc::new(create_test_info(project_id));
|
||||
|
||||
// 插入
|
||||
adapter
|
||||
.insert(project_id.to_string(), info.clone())
|
||||
.unwrap();
|
||||
assert!(adapter.contains_key(project_id));
|
||||
|
||||
// 获取
|
||||
let retrieved = adapter.get(project_id);
|
||||
assert!(retrieved.is_some());
|
||||
assert_eq!(retrieved.unwrap().project_id(), project_id);
|
||||
|
||||
// 删除
|
||||
let removed = adapter.remove(project_id);
|
||||
assert!(removed.is_some());
|
||||
assert!(!adapter.contains_key(project_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_operations() {
|
||||
let adapter = ProjectAdapter::new().unwrap();
|
||||
|
||||
let project_id = "test-project-2";
|
||||
let session_id = "test-session-1";
|
||||
|
||||
// 创建项目
|
||||
let mut info = create_test_info(project_id);
|
||||
info.set_container(Some(ContainerBasicInfo {
|
||||
container_id: "c1".to_string(),
|
||||
container_name: "container-1".to_string(),
|
||||
container_ip: "127.0.0.1".to_string(),
|
||||
internal_port: 8080,
|
||||
external_port: 8080,
|
||||
project_id: project_id.to_string(),
|
||||
status: "running".to_string(),
|
||||
created_at: chrono::Utc::now(),
|
||||
service_url: "http://localhost:8080".to_string(),
|
||||
}));
|
||||
adapter
|
||||
.insert(project_id.to_string(), Arc::new(info))
|
||||
.unwrap();
|
||||
|
||||
// 更新会话
|
||||
adapter.update_session(project_id, session_id).unwrap();
|
||||
|
||||
// 通过会话ID获取
|
||||
let by_session = adapter.get_by_session_id(session_id);
|
||||
assert!(by_session.is_some());
|
||||
assert_eq!(by_session.unwrap().project_id(), project_id);
|
||||
|
||||
// 获取容器名称
|
||||
let container_name = adapter.get_container_name_by_session(session_id);
|
||||
assert_eq!(container_name, Some("container-1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_iter() {
|
||||
let adapter = ProjectAdapter::new().unwrap();
|
||||
|
||||
// 插入多个项目
|
||||
for i in 0..3 {
|
||||
let project_id = format!("iter-project-{}", i);
|
||||
let info = Arc::new(create_test_info(&project_id));
|
||||
adapter.insert(project_id, info).unwrap();
|
||||
}
|
||||
|
||||
// 验证迭代
|
||||
let count = adapter.iter().count();
|
||||
assert_eq!(count, 3);
|
||||
}
|
||||
}
|
||||
252
qiming-rcoder/crates/rcoder/src/storage/bridge.rs
Normal file
252
qiming-rcoder/crates/rcoder/src/storage/bridge.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
//! 数据转换桥接器
|
||||
//!
|
||||
//! 提供 DuckDB 记录和 shared_types 结构之间的转换
|
||||
|
||||
use chrono::Utc;
|
||||
use duckdb_manager::{ContainerRecord, ProjectRecord};
|
||||
use shared_types::{
|
||||
AgentStatus, ContainerBasicInfo, ModelProviderConfig, ProjectAndContainerInfo, ServiceType,
|
||||
};
|
||||
|
||||
/// 数据桥接器
|
||||
///
|
||||
/// 提供 DuckDB 记录和应用层结构之间的转换方法
|
||||
pub struct DataBridge;
|
||||
|
||||
impl DataBridge {
|
||||
// ========== ProjectRecord <-> ProjectAndContainerInfo 转换 ==========
|
||||
|
||||
/// 将 ProjectRecord 转换为 ProjectAndContainerInfo
|
||||
pub fn project_record_to_info(
|
||||
record: &ProjectRecord,
|
||||
container: Option<ContainerBasicInfo>,
|
||||
) -> ProjectAndContainerInfo {
|
||||
let mut info = ProjectAndContainerInfo::new(record.project_id.clone());
|
||||
|
||||
// 设置基本信息
|
||||
info.set_session_id(record.session_id.clone());
|
||||
info.set_user_id(record.user_id.clone());
|
||||
info.set_pod_id(record.pod_id.clone());
|
||||
info.set_service_type(Some(record.service_type.clone()));
|
||||
info.set_container(container);
|
||||
|
||||
// 设置 Agent 状态
|
||||
if let (Some(code), Some(name)) = (&record.agent_status_code, &record.agent_status_name) {
|
||||
let status = Self::code_to_agent_status(*code, name);
|
||||
info.set_status(Some(status));
|
||||
}
|
||||
|
||||
// 设置请求ID
|
||||
info.set_request_id(record.request_id.clone());
|
||||
|
||||
// 设置模型提供商配置
|
||||
if let Some(json) = &record.model_provider_json {
|
||||
if let Ok(config) = serde_json::from_str::<ModelProviderConfig>(json) {
|
||||
info.set_model_provider(Some(config));
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 关键修复:从 record 恢复原始的时间戳
|
||||
// 否则 created_at 会被 new() 设置为当前时间,导致清理任务一直认为容器"刚创建"
|
||||
info.set_timestamps(record.created_at, record.last_activity);
|
||||
|
||||
info
|
||||
}
|
||||
|
||||
/// 将 ProjectAndContainerInfo 转换为 ProjectRecord
|
||||
pub fn info_to_project_record(
|
||||
info: &ProjectAndContainerInfo,
|
||||
project_id: &str,
|
||||
) -> ProjectRecord {
|
||||
let service_type = info.service_type().unwrap_or(ServiceType::RCoder);
|
||||
let container_id = info
|
||||
.container()
|
||||
.map(|c| c.container_id.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut record = ProjectRecord::new(project_id.to_string(), service_type, container_id);
|
||||
|
||||
// 设置会话信息
|
||||
record.session_id = info.session_id().map(|s| s.to_string());
|
||||
|
||||
// 设置用户ID
|
||||
record.user_id = info.user_id().map(|s| s.to_string());
|
||||
|
||||
// 设置 Pod ID(共享容器模式)
|
||||
record.pod_id = info.pod_id().map(|s| s.to_string());
|
||||
|
||||
// 设置 Agent 状态
|
||||
if let Some(status) = info.status() {
|
||||
let (code, name) = Self::agent_status_to_code(status);
|
||||
record.agent_status_code = Some(code);
|
||||
record.agent_status_name = Some(name);
|
||||
}
|
||||
|
||||
// 设置请求ID
|
||||
record.request_id = info.request_id().map(|s| s.to_string());
|
||||
|
||||
// 设置模型提供商配置
|
||||
if let Some(config) = info.model_provider() {
|
||||
if let Ok(json) = serde_json::to_string(config) {
|
||||
record.model_provider_json = Some(json);
|
||||
}
|
||||
}
|
||||
|
||||
// 设置时间戳
|
||||
record.created_at = info.created_at();
|
||||
record.last_activity = info.last_activity();
|
||||
|
||||
// 如果有会话,设置会话时间
|
||||
if info.session_id().is_some() {
|
||||
record.session_created_at = Some(info.created_at());
|
||||
record.session_last_activity = Some(info.last_activity());
|
||||
}
|
||||
|
||||
record
|
||||
}
|
||||
|
||||
// ========== ContainerRecord <-> ContainerBasicInfo 转换 ==========
|
||||
|
||||
/// 将 ContainerRecord 转换为 ContainerBasicInfo
|
||||
pub fn container_record_to_info(record: &ContainerRecord) -> ContainerBasicInfo {
|
||||
ContainerBasicInfo {
|
||||
container_id: record.container_id.clone(),
|
||||
container_name: record.container_name.clone(),
|
||||
container_ip: record.container_ip.clone(),
|
||||
internal_port: record.internal_port,
|
||||
external_port: record.external_port,
|
||||
project_id: String::new(), // 从关联的项目获取
|
||||
status: record.status.clone(),
|
||||
created_at: record.created_at,
|
||||
service_url: record.service_url.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 ContainerBasicInfo 转换为 ContainerRecord
|
||||
pub fn container_info_to_record(
|
||||
info: &ContainerBasicInfo,
|
||||
service_type: Option<ServiceType>,
|
||||
) -> ContainerRecord {
|
||||
ContainerRecord::new(
|
||||
info.container_id.clone(),
|
||||
info.container_name.clone(),
|
||||
info.container_ip.clone(),
|
||||
info.internal_port,
|
||||
info.external_port,
|
||||
service_type.unwrap_or(ServiceType::RCoder),
|
||||
info.status.clone(),
|
||||
info.service_url.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
// ========== AgentStatus <-> 状态码/名称 转换 ==========
|
||||
|
||||
/// 将 AgentStatus 转换为状态码和名称
|
||||
pub fn agent_status_to_code(status: &AgentStatus) -> (i32, String) {
|
||||
match status {
|
||||
AgentStatus::Pending => (3, "pending".to_string()),
|
||||
AgentStatus::Idle => (0, "idle".to_string()),
|
||||
AgentStatus::Active => (1, "active".to_string()),
|
||||
AgentStatus::Terminating => (2, "terminating".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 将状态码和名称转换为 AgentStatus
|
||||
pub fn code_to_agent_status(code: i32, _name: &str) -> AgentStatus {
|
||||
match code {
|
||||
0 => AgentStatus::Idle,
|
||||
1 => AgentStatus::Active,
|
||||
2 => AgentStatus::Terminating,
|
||||
3 => AgentStatus::Pending,
|
||||
_ => AgentStatus::Idle, // 默认
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
/// 创建项目记录并关联会话
|
||||
pub fn create_project_with_session(
|
||||
project_id: &str,
|
||||
session_id: &str,
|
||||
service_type: ServiceType,
|
||||
container_id: &str,
|
||||
) -> ProjectRecord {
|
||||
let now = Utc::now();
|
||||
let mut record = ProjectRecord::new(
|
||||
project_id.to_string(),
|
||||
service_type,
|
||||
container_id.to_string(),
|
||||
);
|
||||
record.session_id = Some(session_id.to_string());
|
||||
record.session_created_at = Some(now);
|
||||
record.session_last_activity = Some(now);
|
||||
record
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_agent_status_conversion() {
|
||||
// 测试各种状态的转换
|
||||
let statuses = vec![
|
||||
AgentStatus::Idle,
|
||||
AgentStatus::Active,
|
||||
AgentStatus::Terminating,
|
||||
];
|
||||
|
||||
for status in statuses {
|
||||
let (code, name) = DataBridge::agent_status_to_code(&status);
|
||||
let converted = DataBridge::code_to_agent_status(code, &name);
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&status),
|
||||
std::mem::discriminant(&converted)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_container_info_conversion() {
|
||||
let info = ContainerBasicInfo {
|
||||
container_id: "c1".to_string(),
|
||||
container_name: "test-container".to_string(),
|
||||
container_ip: "192.168.1.100".to_string(),
|
||||
internal_port: 8080,
|
||||
external_port: 9090,
|
||||
project_id: "p1".to_string(),
|
||||
status: "running".to_string(),
|
||||
created_at: Utc::now(),
|
||||
service_url: "http://localhost:9090".to_string(),
|
||||
};
|
||||
|
||||
let record = DataBridge::container_info_to_record(&info, Some(ServiceType::RCoder));
|
||||
|
||||
assert_eq!(record.container_id, info.container_id);
|
||||
assert_eq!(record.container_name, info.container_name);
|
||||
assert_eq!(record.container_ip, info.container_ip);
|
||||
assert_eq!(record.internal_port, info.internal_port);
|
||||
assert_eq!(record.external_port, info.external_port);
|
||||
|
||||
let converted = DataBridge::container_record_to_info(&record);
|
||||
|
||||
assert_eq!(converted.container_id, info.container_id);
|
||||
assert_eq!(converted.container_name, info.container_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_project_record_conversion() {
|
||||
let mut info = ProjectAndContainerInfo::new("test-project".to_string());
|
||||
info.set_service_type(Some(ServiceType::RCoder));
|
||||
info.set_session_id(Some("session-1".to_string()));
|
||||
info.set_status(Some(AgentStatus::Active));
|
||||
|
||||
let record = DataBridge::info_to_project_record(&info, "test-project");
|
||||
|
||||
assert_eq!(record.project_id, "test-project");
|
||||
assert_eq!(record.session_id, Some("session-1".to_string()));
|
||||
assert_eq!(record.agent_status_code, Some(1));
|
||||
assert_eq!(record.agent_status_name, Some("active".to_string()));
|
||||
}
|
||||
}
|
||||
9
qiming-rcoder/crates/rcoder/src/storage/mod.rs
Normal file
9
qiming-rcoder/crates/rcoder/src/storage/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! 存储适配层
|
||||
//!
|
||||
//! 提供从 DashMap 到 DuckDB 存储的适配器
|
||||
|
||||
mod adapter;
|
||||
mod bridge;
|
||||
|
||||
pub use adapter::ProjectAdapter;
|
||||
pub use bridge::DataBridge;
|
||||
7
qiming-rcoder/crates/rcoder/src/utils/mod.rs
Normal file
7
qiming-rcoder/crates/rcoder/src/utils/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! 工具函数模块
|
||||
//!
|
||||
//! 此模块重新导出 docker_manager 中的路径解析功能
|
||||
|
||||
// 重新导出 docker_manager 的路径解析实现
|
||||
#[allow(unused_imports)] // 导出供外部使用
|
||||
pub use docker_manager::path::{HostPathResolver, resolve_container_path_to_host};
|
||||
173
qiming-rcoder/crates/rcoder/src/vnc/docker_resolver.rs
Normal file
173
qiming-rcoder/crates/rcoder/src/vnc/docker_resolver.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! 基于 Runtime 的 VNC 后端解析器
|
||||
//!
|
||||
//! 通过 runtime 的全局实例动态查询容器 IP,
|
||||
//! 支持带 TTL 缓存以减少运行时 API 调用。
|
||||
//!
|
||||
//! ## 设计说明
|
||||
//!
|
||||
//! 本模块实现了 `VncBackendResolver` trait,提供异步解析 VNC 后端的能力。
|
||||
//!
|
||||
//! **当前状态**: 作为备选方案保留,未被主动使用。
|
||||
//!
|
||||
//! **原因**: Pingora 的 `upstream_peer` 方法是同步的,无法直接调用异步 Docker API。
|
||||
//! 当前系统使用 `vnc_sync` 定时同步任务来维护 VNC 后端映射。
|
||||
//!
|
||||
//! **未来用途**: 如果 Pingora 支持异步上游解析,或使用其他支持异步的代理服务,
|
||||
//! 可以直接使用此解析器。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use moka::future::Cache;
|
||||
use rcoder_proxy::{VncBackendInfo, VncBackendResolver, VncResolveError};
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// noVNC 默认端口
|
||||
const NOVNC_DEFAULT_PORT: u16 = 6080;
|
||||
|
||||
/// 默认缓存 TTL(5 秒)
|
||||
const DEFAULT_CACHE_TTL_SECS: u64 = 5;
|
||||
|
||||
/// 基于 DockerManager 的 VNC 后端解析器(带缓存)
|
||||
///
|
||||
/// 使用 moka 缓存减少 Docker API 查询频率。
|
||||
/// 缓存 TTL 默认 5 秒,确保容器 IP 变化能及时更新。
|
||||
///
|
||||
/// ## 注意
|
||||
///
|
||||
/// 此结构体目前作为备选方案保留,未被主动使用。
|
||||
/// 原因是 Pingora 的同步接口限制,详见模块文档。
|
||||
#[allow(dead_code)]
|
||||
pub struct CachedDockerResolver {
|
||||
/// 缓存:user_id -> VncBackendInfo
|
||||
cache: Cache<String, VncBackendInfo>,
|
||||
}
|
||||
|
||||
impl Default for CachedDockerResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CachedDockerResolver {
|
||||
/// 创建带默认 TTL(5 秒)的解析器
|
||||
pub fn new() -> Self {
|
||||
Self::with_ttl(Duration::from_secs(DEFAULT_CACHE_TTL_SECS))
|
||||
}
|
||||
|
||||
/// 创建带自定义 TTL 的解析器
|
||||
pub fn with_ttl(ttl: Duration) -> Self {
|
||||
let cache = Cache::builder()
|
||||
.time_to_live(ttl)
|
||||
.max_capacity(10_000) // 最多缓存 10000 个用户
|
||||
.build();
|
||||
|
||||
info!(
|
||||
"🔧 [VNC_RESOLVER] Creating CachedDockerResolver: TTL={}s",
|
||||
ttl.as_secs()
|
||||
);
|
||||
|
||||
Self { cache }
|
||||
}
|
||||
|
||||
/// 直接从 runtime 查询容器信息(不走缓存)
|
||||
async fn query_runtime(&self, user_id: &str) -> Result<VncBackendInfo, VncResolveError> {
|
||||
let runtime = docker_manager::runtime::RuntimeManager::get()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("[VNC_RESOLVER] Failed to get runtime: {}", e);
|
||||
VncResolveError::QueryFailed(format!("Failed to get runtime: {}", e))
|
||||
})?;
|
||||
|
||||
// ComputerAgentRunner 模式:使用 user_id 作为容器标识
|
||||
let container_info = runtime
|
||||
.get_container_info_by_identifier(user_id, &shared_types::ServiceType::ComputerAgentRunner)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
"⚠️ [VNC_RESOLVER] Failed to query container info: user_id={}, error={}",
|
||||
user_id, e
|
||||
);
|
||||
VncResolveError::QueryFailed(format!("failed to query container info: {}", e))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
debug!("[VNC_RESOLVER] containernot found: user_id={}", user_id);
|
||||
VncResolveError::ContainerNotFound(user_id.to_string())
|
||||
})?;
|
||||
|
||||
// 检查容器状态
|
||||
let is_running = container_info.status.to_lowercase() == "running";
|
||||
if !is_running {
|
||||
warn!(
|
||||
"⚠️ [VNC_RESOLVER] Container not running: user_id={}, status={}",
|
||||
user_id, container_info.status
|
||||
);
|
||||
}
|
||||
|
||||
let info = VncBackendInfo::new(
|
||||
container_info.container_ip.clone(),
|
||||
NOVNC_DEFAULT_PORT,
|
||||
is_running,
|
||||
);
|
||||
|
||||
debug!(
|
||||
"✅ [VNC_RESOLVER] Resolution successful: user_id={} -> {}:{} (running={})",
|
||||
user_id, info.container_ip, info.vnc_port, info.is_running
|
||||
);
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VncBackendResolver for CachedDockerResolver {
|
||||
async fn resolve(&self, user_id: &str) -> Result<VncBackendInfo, VncResolveError> {
|
||||
// 先尝试从缓存获取
|
||||
if let Some(cached) = self.cache.get(user_id).await {
|
||||
debug!(
|
||||
"🎯 [VNC_RESOLVER] Cache hit: user_id={} -> {}",
|
||||
user_id, cached.container_ip
|
||||
);
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
// 缓存未命中,查询 runtime
|
||||
debug!(
|
||||
"🔍 [VNC_RESOLVER] Cache miss, querying runtime: user_id={}",
|
||||
user_id
|
||||
);
|
||||
let info = self.query_runtime(user_id).await?;
|
||||
|
||||
// 写入缓存
|
||||
self.cache.insert(user_id.to_string(), info.clone()).await;
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
async fn exists(&self, user_id: &str) -> bool {
|
||||
// 先检查缓存
|
||||
if self.cache.get(user_id).await.is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 缓存未命中,尝试解析
|
||||
self.resolve(user_id).await.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_resolver() {
|
||||
let resolver = CachedDockerResolver::default();
|
||||
// 仅验证创建成功
|
||||
assert!(std::mem::size_of_val(&resolver) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_ttl() {
|
||||
let resolver = CachedDockerResolver::with_ttl(Duration::from_secs(10));
|
||||
assert!(std::mem::size_of_val(&resolver) > 0);
|
||||
}
|
||||
}
|
||||
7
qiming-rcoder/crates/rcoder/src/vnc/mod.rs
Normal file
7
qiming-rcoder/crates/rcoder/src/vnc/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! VNC 后端解析模块
|
||||
//!
|
||||
//! 提供 VNC 后端解析器的具体实现
|
||||
|
||||
mod docker_resolver;
|
||||
|
||||
pub use docker_resolver::CachedDockerResolver;
|
||||
Reference in New Issue
Block a user