Fix tasks UI, WebSocket progress, memory monitoring, and app generator

Tasks UI fixes:
- Fix task list to query auto_tasks table instead of tasks table
- Fix task detail endpoint to use UUID binding for auto_tasks query
- Add proper filter handling: complete, active, awaiting, paused, blocked
- Add TaskStats fields: awaiting, paused, blocked, time_saved
- Add /api/tasks/time-saved endpoint
- Add count-all to stats HTML response

App generator improvements:
- Add AgentActivity struct for detailed terminal-style progress
- Add emit_activity method for rich progress events
- Add detailed logging for LLM calls with timing
- Track files_written, tables_synced, bytes_generated

Memory and performance:
- Add memory_monitor module for tracking RSS and thread activity
- Skip 0-byte files in drive monitor and document processor
- Change DRIVE_MONITOR checking logs from info to trace
- Remove unused profile_section macro

WebSocket progress:
- Ensure TaskProgressEvent includes activity field
- Add with_activity builder method
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-30 22:42:32 -03:00
parent b0baf36b11
commit 061c14b4a2
27 changed files with 2717 additions and 450 deletions

View file

@ -81,6 +81,7 @@ monitoring = ["dep:sysinfo"]
automation = ["dep:rhai"] automation = ["dep:rhai"]
grpc = ["dep:tonic"] grpc = ["dep:tonic"]
progress-bars = ["dep:indicatif"] progress-bars = ["dep:indicatif"]
jemalloc = ["dep:tikv-jemallocator", "dep:tikv-jemalloc-ctl"]
# ===== META FEATURES (BUNDLES) ===== # ===== META FEATURES (BUNDLES) =====
full = [ full = [
@ -216,6 +217,10 @@ tonic = { version = "0.14.2", features = ["transport"], optional = true }
# UI Enhancement (progress-bars feature) # UI Enhancement (progress-bars feature)
indicatif = { version = "0.18.0", optional = true } indicatif = { version = "0.18.0", optional = true }
smartstring = "1.0.1" smartstring = "1.0.1"
# Memory allocator (jemalloc feature)
tikv-jemallocator = { version = "0.6", optional = true }
tikv-jemalloc-ctl = { version = "0.6", features = ["stats"], optional = true }
scopeguard = "1.2.0" scopeguard = "1.2.0"
# Vault secrets management # Vault secrets management

View file

@ -1,7 +1,7 @@
{ {
"base_url": "http://localhost:8300", "base_url": "http://localhost:8300",
"default_org": { "default_org": {
"id": "353229789043097614", "id": "353379211173429262",
"name": "default", "name": "default",
"domain": "default.localhost" "domain": "default.localhost"
}, },
@ -13,8 +13,8 @@
"first_name": "Admin", "first_name": "Admin",
"last_name": "User" "last_name": "User"
}, },
"admin_token": "7QNrwws4y1X5iIuTUCtXpQj9RoQf4fYi144yEY87tNAbLMZOOD57t3YDAqCtIyIkBS1EZ5k", "admin_token": "pY9ruIghlJlAVn-a-vpbtM0L9yQ3WtweXkXJKEk2aVBL4HEeIppxCA8MPx60ZjQJRghq9zU",
"project_id": "", "project_id": "",
"client_id": "353229789848469518", "client_id": "353379211962023950",
"client_secret": "COd3gKdMO43jkUztTckCNtHrjxa5RtcIlBn7Cbp4GFoXs6mw6iZalB3m4Vv3FK5Y" "client_secret": "vD8uaGZubV4pOMqxfFkGc0YOfmzYII8W7L25V7cGWieQlw0UHuvDQkSuJbQ3Rhgp"
} }

View file

@ -5,7 +5,7 @@ use crate::basic::keywords::table_definition::{
use crate::core::config::ConfigManager; use crate::core::config::ConfigManager;
use crate::core::shared::get_content_type; use crate::core::shared::get_content_type;
use crate::core::shared::models::UserSession; use crate::core::shared::models::UserSession;
use crate::core::shared::state::AppState; use crate::core::shared::state::{AgentActivity, AppState};
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use diesel::prelude::*; use diesel::prelude::*;
@ -143,18 +143,90 @@ struct LlmFile {
pub struct AppGenerator { pub struct AppGenerator {
state: Arc<AppState>, state: Arc<AppState>,
task_id: Option<String>,
generation_start: Option<std::time::Instant>,
files_written: Vec<String>,
tables_synced: Vec<String>,
bytes_generated: u64,
} }
impl AppGenerator { impl AppGenerator {
pub fn new(state: Arc<AppState>) -> Self { pub fn new(state: Arc<AppState>) -> Self {
Self { state } Self {
state,
task_id: None,
generation_start: None,
files_written: Vec::new(),
tables_synced: Vec::new(),
bytes_generated: 0,
}
}
pub fn with_task_id(state: Arc<AppState>, task_id: impl Into<String>) -> Self {
Self {
state,
task_id: Some(task_id.into()),
generation_start: None,
files_written: Vec::new(),
tables_synced: Vec::new(),
bytes_generated: 0,
}
}
fn emit_activity(&self, step: &str, message: &str, current: u8, total: u8, activity: AgentActivity) {
if let Some(ref task_id) = self.task_id {
self.state.emit_activity(task_id, step, message, current, total, activity);
}
}
fn calculate_speed(&self, items_done: u32) -> (f32, Option<u32>) {
if let Some(start) = self.generation_start {
let elapsed = start.elapsed().as_secs_f32();
if elapsed > 0.0 {
let speed = (items_done as f32 / elapsed) * 60.0;
return (speed, None);
}
}
(0.0, None)
}
fn build_activity(&self, phase: &str, items_done: u32, items_total: Option<u32>, current_item: Option<&str>) -> AgentActivity {
let (speed, eta) = self.calculate_speed(items_done);
let mut activity = AgentActivity::new(phase)
.with_progress(items_done, items_total)
.with_bytes(self.bytes_generated);
if speed > 0.0 {
activity = activity.with_speed(speed, eta);
}
if !self.files_written.is_empty() {
activity = activity.with_files(self.files_written.clone());
}
if !self.tables_synced.is_empty() {
activity = activity.with_tables(self.tables_synced.clone());
}
if let Some(item) = current_item {
activity = activity.with_current_item(item);
}
activity
} }
pub async fn generate_app( pub async fn generate_app(
&self, &mut self,
intent: &str, intent: &str,
session: &UserSession, session: &UserSession,
) -> Result<GeneratedApp, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<GeneratedApp, Box<dyn std::error::Error + Send + Sync>> {
const TOTAL_STEPS: u8 = 8;
self.generation_start = Some(std::time::Instant::now());
self.files_written.clear();
self.tables_synced.clear();
self.bytes_generated = 0;
info!( info!(
"Generating app from intent: {}", "Generating app from intent: {}",
&intent[..intent.len().min(100)] &intent[..intent.len().min(100)]
@ -168,23 +240,82 @@ impl AppGenerator {
), ),
); );
if let Some(ref task_id) = self.task_id {
self.state.emit_task_started(task_id, &format!("Generating app: {}", &intent[..intent.len().min(50)]), TOTAL_STEPS);
}
let activity = self.build_activity("analyzing", 0, Some(TOTAL_STEPS as u32), Some("Sending request to LLM"));
self.emit_activity(
"llm_request",
"Analyzing request with AI...",
1,
TOTAL_STEPS,
activity
);
info!("[APP_GENERATOR] Calling generate_complete_app_with_llm for intent: {}", &intent[..intent.len().min(50)]);
let llm_start = std::time::Instant::now();
let llm_app = match self.generate_complete_app_with_llm(intent, session.bot_id).await { let llm_app = match self.generate_complete_app_with_llm(intent, session.bot_id).await {
Ok(app) => { Ok(app) => {
let llm_elapsed = llm_start.elapsed();
info!("[APP_GENERATOR] LLM generation completed in {:?}: app={}, files={}, tables={}",
llm_elapsed, app.name, app.files.len(), app.tables.len());
log_generator_info( log_generator_info(
&app.name, &app.name,
"LLM successfully generated app structure and files", "LLM successfully generated app structure and files",
); );
let total_bytes: u64 = app.files.iter().map(|f| f.content.len() as u64).sum();
self.bytes_generated = total_bytes;
let activity = self.build_activity(
"parsing",
1,
Some(TOTAL_STEPS as u32),
Some(&format!("Generated {} with {} files", app.name, app.files.len()))
);
self.emit_activity(
"llm_response",
&format!("AI generated {} structure", app.name),
2,
TOTAL_STEPS,
activity
);
app app
} }
Err(e) => { Err(e) => {
let llm_elapsed = llm_start.elapsed();
error!("[APP_GENERATOR] LLM generation failed after {:?}: {}", llm_elapsed, e);
log_generator_error("unknown", "LLM app generation failed", &e.to_string()); log_generator_error("unknown", "LLM app generation failed", &e.to_string());
if let Some(ref task_id) = self.task_id {
self.state.emit_task_error(task_id, "llm_request", &e.to_string());
}
return Err(e); return Err(e);
} }
}; };
let activity = self.build_activity("parsing", 2, Some(TOTAL_STEPS as u32), Some(&format!("Processing {} structure", llm_app.name)));
self.emit_activity("parse_structure", &format!("Parsing {} structure...", llm_app.name), 3, TOTAL_STEPS, activity);
let tables = Self::convert_llm_tables(&llm_app.tables); let tables = Self::convert_llm_tables(&llm_app.tables);
if !tables.is_empty() { if !tables.is_empty() {
let table_names: Vec<String> = tables.iter().map(|t| t.name.clone()).collect();
let activity = self.build_activity(
"database",
3,
Some(TOTAL_STEPS as u32),
Some(&format!("Creating tables: {}", table_names.join(", ")))
);
self.emit_activity(
"create_tables",
&format!("Creating {} database tables...", tables.len()),
4,
TOTAL_STEPS,
activity
);
let tables_bas_content = Self::generate_table_definitions(&tables)?; let tables_bas_content = Self::generate_table_definitions(&tables)?;
if let Err(e) = self.append_to_tables_bas(session.bot_id, &tables_bas_content) { if let Err(e) = self.append_to_tables_bas(session.bot_id, &tables_bas_content) {
log_generator_error( log_generator_error(
@ -203,6 +334,20 @@ impl AppGenerator {
result.tables_created, result.fields_added result.tables_created, result.fields_added
), ),
); );
self.tables_synced = table_names;
let activity = self.build_activity(
"database",
4,
Some(TOTAL_STEPS as u32),
Some(&format!("{} tables, {} fields created", result.tables_created, result.fields_added))
);
self.emit_activity(
"tables_synced",
"Database tables created",
4,
TOTAL_STEPS,
activity
);
} }
Err(e) => { Err(e) => {
log_generator_error(&llm_app.name, "Failed to sync tables", &e.to_string()); log_generator_error(&llm_app.name, "Failed to sync tables", &e.to_string());
@ -214,9 +359,37 @@ impl AppGenerator {
let bucket_name = format!("{}.gbai", bot_name.to_lowercase()); let bucket_name = format!("{}.gbai", bot_name.to_lowercase());
let drive_app_path = format!(".gbdrive/apps/{}", llm_app.name); let drive_app_path = format!(".gbdrive/apps/{}", llm_app.name);
let total_files = llm_app.files.len();
let activity = self.build_activity("writing", 0, Some(total_files as u32), Some("Preparing files"));
self.emit_activity(
"write_files",
&format!("Writing {} app files...", total_files),
5,
TOTAL_STEPS,
activity
);
let mut pages = Vec::new(); let mut pages = Vec::new();
for file in &llm_app.files { for (idx, file) in llm_app.files.iter().enumerate() {
let drive_path = format!("{}/{}", drive_app_path, file.filename); let drive_path = format!("{}/{}", drive_app_path, file.filename);
self.files_written.push(file.filename.clone());
self.bytes_generated += file.content.len() as u64;
let activity = self.build_activity(
"writing",
(idx + 1) as u32,
Some(total_files as u32),
Some(&file.filename)
);
self.emit_activity(
"write_file",
&format!("Writing {}", file.filename),
5,
TOTAL_STEPS,
activity
);
if let Err(e) = self if let Err(e) = self
.write_to_drive(&bucket_name, &drive_path, &file.content) .write_to_drive(&bucket_name, &drive_path, &file.content)
.await .await
@ -236,7 +409,12 @@ impl AppGenerator {
}); });
} }
self.files_written.push("designer.js".to_string());
let activity = self.build_activity("configuring", total_files as u32, Some(total_files as u32), Some("designer.js"));
self.emit_activity("write_designer", "Creating designer configuration...", 6, TOTAL_STEPS, activity);
let designer_js = Self::generate_designer_js(&llm_app.name); let designer_js = Self::generate_designer_js(&llm_app.name);
self.bytes_generated += designer_js.len() as u64;
self.write_to_drive( self.write_to_drive(
&bucket_name, &bucket_name,
&format!("{}/designer.js", drive_app_path), &format!("{}/designer.js", drive_app_path),
@ -246,8 +424,24 @@ impl AppGenerator {
let mut tools = Vec::new(); let mut tools = Vec::new();
if let Some(llm_tools) = &llm_app.tools { if let Some(llm_tools) = &llm_app.tools {
for tool in llm_tools { let tools_count = llm_tools.len();
let activity = self.build_activity("tools", 0, Some(tools_count as u32), Some("Creating BASIC tools"));
self.emit_activity(
"write_tools",
&format!("Creating {} tools...", tools_count),
7,
TOTAL_STEPS,
activity
);
for (idx, tool) in llm_tools.iter().enumerate() {
let tool_path = format!(".gbdialog/tools/{}", tool.filename); let tool_path = format!(".gbdialog/tools/{}", tool.filename);
self.files_written.push(format!("tools/{}", tool.filename));
self.bytes_generated += tool.content.len() as u64;
let activity = self.build_activity("tools", (idx + 1) as u32, Some(tools_count as u32), Some(&tool.filename));
self.emit_activity("write_tool", &format!("Writing tool {}", tool.filename), 7, TOTAL_STEPS, activity);
if let Err(e) = self if let Err(e) = self
.write_to_drive(&bucket_name, &tool_path, &tool.content) .write_to_drive(&bucket_name, &tool_path, &tool.content)
.await .await
@ -268,8 +462,24 @@ impl AppGenerator {
let mut schedulers = Vec::new(); let mut schedulers = Vec::new();
if let Some(llm_schedulers) = &llm_app.schedulers { if let Some(llm_schedulers) = &llm_app.schedulers {
for scheduler in llm_schedulers { let sched_count = llm_schedulers.len();
let activity = self.build_activity("schedulers", 0, Some(sched_count as u32), Some("Creating schedulers"));
self.emit_activity(
"write_schedulers",
&format!("Creating {} schedulers...", sched_count),
7,
TOTAL_STEPS,
activity
);
for (idx, scheduler) in llm_schedulers.iter().enumerate() {
let scheduler_path = format!(".gbdialog/schedulers/{}", scheduler.filename); let scheduler_path = format!(".gbdialog/schedulers/{}", scheduler.filename);
self.files_written.push(format!("schedulers/{}", scheduler.filename));
self.bytes_generated += scheduler.content.len() as u64;
let activity = self.build_activity("schedulers", (idx + 1) as u32, Some(sched_count as u32), Some(&scheduler.filename));
self.emit_activity("write_scheduler", &format!("Writing scheduler {}", scheduler.filename), 7, TOTAL_STEPS, activity);
if let Err(e) = self if let Err(e) = self
.write_to_drive(&bucket_name, &scheduler_path, &scheduler.content) .write_to_drive(&bucket_name, &scheduler_path, &scheduler.content)
.await .await
@ -288,16 +498,22 @@ impl AppGenerator {
} }
} }
let activity = self.build_activity("syncing", TOTAL_STEPS as u32 - 1, Some(TOTAL_STEPS as u32), Some("Deploying to site"));
self.emit_activity("sync_site", "Syncing app to site...", 8, TOTAL_STEPS, activity);
self.sync_app_to_site_root(&bucket_name, &llm_app.name, session.bot_id) self.sync_app_to_site_root(&bucket_name, &llm_app.name, session.bot_id)
.await?; .await?;
let elapsed = self.generation_start.map(|s| s.elapsed().as_secs()).unwrap_or(0);
log_generator_info( log_generator_info(
&llm_app.name, &llm_app.name,
&format!( &format!(
"App generated: {} files, {} tables, {} tools", "App generated: {} files, {} tables, {} tools in {}s",
pages.len(), pages.len(),
tables.len(), tables.len(),
tools.len() tools.len(),
elapsed
), ),
); );
@ -306,6 +522,24 @@ impl AppGenerator {
llm_app.name, bucket_name, drive_app_path llm_app.name, bucket_name, drive_app_path
); );
if let Some(ref task_id) = self.task_id {
let final_activity = AgentActivity::new("completed")
.with_progress(TOTAL_STEPS as u32, Some(TOTAL_STEPS as u32))
.with_bytes(self.bytes_generated)
.with_files(self.files_written.clone())
.with_tables(self.tables_synced.clone());
let event = crate::core::shared::state::TaskProgressEvent::new(task_id, "complete", &format!(
"App '{}' created: {} files, {} tables, {} bytes in {}s",
llm_app.name, pages.len(), tables.len(), self.bytes_generated, elapsed
))
.with_progress(TOTAL_STEPS, TOTAL_STEPS)
.with_activity(final_activity)
.completed();
self.state.broadcast_task_progress(event);
}
Ok(GeneratedApp { Ok(GeneratedApp {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
name: llm_app.name, name: llm_app.name,
@ -436,8 +670,14 @@ guid, string, text, integer, decimal, boolean, date, datetime, json
=== USER REQUEST === === USER REQUEST ===
"{intent}" "{intent}"
=== YOUR TASK === === YOLO MODE - JUST BUILD IT ===
Generate a complete application based on the user's request. DO NOT ask questions. DO NOT request clarification. Just CREATE the app NOW.
If user says "calculator" build a full-featured calculator with basic ops, scientific functions, history
If user says "CRM" build customer management with contacts, companies, deals, notes
If user says "inventory" build stock tracking with products, categories, movements
If user says "booking" build appointment scheduler with calendar, slots, confirmations
If user says ANYTHING interpret creatively and BUILD SOMETHING AWESOME
Respond with a single JSON object: Respond with a single JSON object:
{{ {{
@ -469,15 +709,16 @@ Respond with a single JSON object:
] ]
}} }}
IMPORTANT: CRITICAL RULES:
- For simple utilities (calculator, timer, converter): tables can be empty [], focus on files - For utilities (calculator, timer, converter, BMI, mortgage): tables = [], focus on interactive HTML/JS
- For data apps (CRM, inventory): design proper tables and CRUD pages - For data apps (CRM, inventory): design proper tables and CRUD pages
- Generate ALL files completely - no shortcuts - Generate ALL files completely - no placeholders, no "...", no shortcuts
- CSS must be comprehensive with variables, responsive design, dark mode - CSS must be comprehensive with variables, responsive design, dark mode
- Every HTML page needs proper structure with all required scripts - Every HTML page needs proper structure with all required scripts
- Replace APP_NAME_HERE with actual app name in data-app-name attribute - Replace APP_NAME_HERE with actual app name in data-app-name attribute
- BE CREATIVE - add extra features the user didn't ask for but would love
Respond with valid JSON only."# Respond with valid JSON only. NO QUESTIONS. JUST BUILD."#
); );
let response = self.call_llm(&prompt, bot_id).await?; let response = self.call_llm(&prompt, bot_id).await?;
@ -557,7 +798,6 @@ Respond with valid JSON only."#
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
#[cfg(feature = "llm")] #[cfg(feature = "llm")]
{ {
// Get model and key from bot configuration
let config_manager = ConfigManager::new(self.state.conn.clone()); let config_manager = ConfigManager::new(self.state.conn.clone());
let model = config_manager let model = config_manager
.get_config(&bot_id, "llm-model", None) .get_config(&bot_id, "llm-model", None)
@ -579,15 +819,24 @@ Respond with valid JSON only."#
"max_tokens": 16000 "max_tokens": 16000
}); });
let prompt_len = prompt.len();
info!("[APP_GENERATOR] Starting LLM call: model={}, prompt_len={} chars", model, prompt_len);
let start = std::time::Instant::now();
match self match self
.state .state
.llm_provider .llm_provider
.generate(prompt, &llm_config, &model, &key) .generate(prompt, &llm_config, &model, &key)
.await .await
{ {
Ok(response) => return Ok(response), Ok(response) => {
let elapsed = start.elapsed();
info!("[APP_GENERATOR] LLM call succeeded: response_len={} chars, elapsed={:?}", response.len(), elapsed);
return Ok(response);
}
Err(e) => { Err(e) => {
warn!("LLM call failed: {}", e); let elapsed = start.elapsed();
error!("[APP_GENERATOR] LLM call failed after {:?}: {}", elapsed, e);
return Err(e); return Err(e);
} }
} }

View file

@ -324,11 +324,11 @@ pub async fn create_and_execute_handler(
// Update status to running // Update status to running
let _ = update_task_status_db(&state, task_id, "running", None); let _ = update_task_status_db(&state, task_id, "running", None);
// Use IntentClassifier to classify and process // Use IntentClassifier to classify and process with task tracking
let classifier = IntentClassifier::new(Arc::clone(&state)); let classifier = IntentClassifier::new(Arc::clone(&state));
match classifier match classifier
.classify_and_process(&request.intent, &session) .classify_and_process_with_task_id(&request.intent, &session, Some(task_id.to_string()))
.await .await
{ {
Ok(result) => { Ok(result) => {

View file

@ -169,29 +169,20 @@ impl IntentClassifier {
&self, &self,
intent: &str, intent: &str,
session: &UserSession, session: &UserSession,
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
self.classify_and_process_with_task_id(intent, session, None).await
}
/// Classify and then process the intent through the appropriate handler with task tracking
pub async fn classify_and_process_with_task_id(
&self,
intent: &str,
session: &UserSession,
task_id: Option<String>,
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
let classification = self.classify(intent, session).await?; let classification = self.classify(intent, session).await?;
// If clarification is needed, return early self.process_classified_intent_with_task_id(&classification, session, task_id)
if classification.requires_clarification {
return Ok(IntentResult {
success: false,
intent_type: classification.intent_type,
message: classification
.clarification_question
.unwrap_or_else(|| "Could you please provide more details?".to_string()),
created_resources: Vec::new(),
app_url: None,
task_id: None,
schedule_id: None,
tool_triggers: Vec::new(),
next_steps: vec!["Provide more information".to_string()],
error: None,
});
}
// Route to appropriate handler
self.process_classified_intent(&classification, session)
.await .await
} }
@ -200,6 +191,16 @@ impl IntentClassifier {
&self, &self,
classification: &ClassifiedIntent, classification: &ClassifiedIntent,
session: &UserSession, session: &UserSession,
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
self.process_classified_intent_with_task_id(classification, session, None).await
}
/// Process a classified intent through the appropriate handler with task tracking
pub async fn process_classified_intent_with_task_id(
&self,
classification: &ClassifiedIntent,
session: &UserSession,
task_id: Option<String>,
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
info!( info!(
"Processing {} intent: {}", "Processing {} intent: {}",
@ -208,7 +209,7 @@ impl IntentClassifier {
); );
match classification.intent_type { match classification.intent_type {
IntentType::AppCreate => self.handle_app_create(classification, session).await, IntentType::AppCreate => self.handle_app_create(classification, session, task_id).await,
IntentType::Todo => self.handle_todo(classification, session), IntentType::Todo => self.handle_todo(classification, session),
IntentType::Monitor => self.handle_monitor(classification, session), IntentType::Monitor => self.handle_monitor(classification, session),
IntentType::Action => self.handle_action(classification, session).await, IntentType::Action => self.handle_action(classification, session).await,
@ -231,8 +232,9 @@ impl IntentClassifier {
USER REQUEST: "{intent}" USER REQUEST: "{intent}"
INTENT TYPES: INTENT TYPES:
- APP_CREATE: Create a full application (CRM, inventory, booking system, etc.) - APP_CREATE: Create a full application, utility, calculator, tool, system, etc.
Keywords: "create app", "build system", "make application", "CRM", "management system" Keywords: "create", "build", "make", "calculator", "app", "system", "CRM", "tool"
IMPORTANT: If user wants to CREATE anything (app, calculator, converter, timer, etc), classify as APP_CREATE
- TODO: Simple task or reminder - TODO: Simple task or reminder
Keywords: "call", "remind me", "don't forget", "tomorrow", "later" Keywords: "call", "remind me", "don't forget", "tomorrow", "later"
@ -252,6 +254,9 @@ INTENT TYPES:
- TOOL: Create a voice/chat command - TOOL: Create a voice/chat command
Keywords: "when I say", "create command", "shortcut for", "trigger" Keywords: "when I say", "create command", "shortcut for", "trigger"
YOLO MODE: NEVER ask for clarification. Always make a decision and proceed.
For APP_CREATE: Just build whatever makes sense. A "calculator" = basic calculator app. A "CRM" = customer management app. Be creative and decisive.
Respond with JSON only: Respond with JSON only:
{{ {{
"intent_type": "APP_CREATE|TODO|MONITOR|ACTION|SCHEDULE|GOAL|TOOL|UNKNOWN", "intent_type": "APP_CREATE|TODO|MONITOR|ACTION|SCHEDULE|GOAL|TOOL|UNKNOWN",
@ -269,13 +274,17 @@ Respond with JSON only:
"suggested_name": "short name for the resource", "suggested_name": "short name for the resource",
"requires_clarification": false, "requires_clarification": false,
"clarification_question": null, "clarification_question": null,
"alternatives": [ "alternatives": []
{{"type": "OTHER_TYPE", "confidence": 0.3, "reason": "could also be..."}}
]
}}"# }}"#
); );
info!("[INTENT_CLASSIFIER] Starting LLM call for classification, prompt_len={} chars", prompt.len());
let start = std::time::Instant::now();
let response = self.call_llm(&prompt, bot_id).await?; let response = self.call_llm(&prompt, bot_id).await?;
let elapsed = start.elapsed();
info!("[INTENT_CLASSIFIER] LLM classification completed in {:?}, response_len={} chars", elapsed, response.len());
trace!("LLM classification response: {}", &response[..response.len().min(500)]);
Self::parse_classification_response(&response, intent) Self::parse_classification_response(&response, intent)
} }
@ -321,8 +330,19 @@ Respond with JSON only:
reason: String, reason: String,
} }
// Clean response - remove markdown code blocks if present
let cleaned = response
.trim()
.trim_start_matches("```json")
.trim_start_matches("```JSON")
.trim_start_matches("```")
.trim_end_matches("```")
.trim();
trace!("Cleaned classification response: {}", &cleaned[..cleaned.len().min(300)]);
// Try to parse, fall back to heuristic classification // Try to parse, fall back to heuristic classification
let parsed: Result<LlmResponse, _> = serde_json::from_str(response); let parsed: Result<LlmResponse, _> = serde_json::from_str(cleaned);
match parsed { match parsed {
Ok(resp) => { Ok(resp) => {
@ -380,6 +400,7 @@ Respond with JSON only:
} }
Err(e) => { Err(e) => {
warn!("Failed to parse LLM response, using heuristic: {e}"); warn!("Failed to parse LLM response, using heuristic: {e}");
trace!("Raw response that failed to parse: {}", &response[..response.len().min(200)]);
Self::classify_heuristic(original_intent) Self::classify_heuristic(original_intent)
} }
} }
@ -397,6 +418,31 @@ Respond with JSON only:
|| lower.contains("management system") || lower.contains("management system")
|| lower.contains("inventory") || lower.contains("inventory")
|| lower.contains("booking") || lower.contains("booking")
|| lower.contains("calculator")
|| lower.contains("website")
|| lower.contains("webpage")
|| lower.contains("web page")
|| lower.contains("landing page")
|| lower.contains("dashboard")
|| lower.contains("form")
|| lower.contains("todo list")
|| lower.contains("todo app")
|| lower.contains("chat")
|| lower.contains("blog")
|| lower.contains("portfolio")
|| lower.contains("store")
|| lower.contains("shop")
|| lower.contains("e-commerce")
|| lower.contains("ecommerce")
|| (lower.contains("create") && lower.contains("html"))
|| (lower.contains("make") && lower.contains("html"))
|| (lower.contains("build") && lower.contains("html"))
|| (lower.contains("create a") && (lower.contains("simple") || lower.contains("basic")))
|| (lower.contains("make a") && (lower.contains("simple") || lower.contains("basic")))
|| (lower.contains("build a") && (lower.contains("simple") || lower.contains("basic")))
|| lower.contains("criar")
|| lower.contains("fazer")
|| lower.contains("construir")
{ {
(IntentType::AppCreate, 0.75) (IntentType::AppCreate, 0.75)
} else if lower.contains("remind") } else if lower.contains("remind")
@ -461,10 +507,15 @@ Respond with JSON only:
&self, &self,
classification: &ClassifiedIntent, classification: &ClassifiedIntent,
session: &UserSession, session: &UserSession,
task_id: Option<String>,
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
info!("Handling APP_CREATE intent"); info!("Handling APP_CREATE intent");
let app_generator = AppGenerator::new(self.state.clone()); let mut app_generator = if let Some(tid) = task_id {
AppGenerator::with_task_id(self.state.clone(), tid)
} else {
AppGenerator::new(self.state.clone())
};
match app_generator match app_generator
.generate_app(&classification.original_text, session) .generate_app(&classification.original_text, session)

View file

@ -33,6 +33,18 @@ pub use intent_compiler::{CompiledIntent, IntentCompiler};
pub use safety_layer::{AuditEntry, ConstraintCheckResult, SafetyLayer, SimulationResult}; pub use safety_layer::{AuditEntry, ConstraintCheckResult, SafetyLayer, SimulationResult};
use crate::core::urls::ApiUrls; use crate::core::urls::ApiUrls;
use crate::shared::state::AppState;
use axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
Path, Query, State,
},
response::IntoResponse,
};
use futures::{SinkExt, StreamExt};
use log::{debug, error, info, warn};
use std::collections::HashMap;
use std::sync::Arc;
pub fn configure_autotask_routes() -> axum::Router<std::sync::Arc<crate::shared::state::AppState>> { pub fn configure_autotask_routes() -> axum::Router<std::sync::Arc<crate::shared::state::AppState>> {
use axum::routing::{get, post}; use axum::routing::{get, post};
@ -102,6 +114,141 @@ pub fn configure_autotask_routes() -> axum::Router<std::sync::Arc<crate::shared:
.route("/api/app-logs/stats", get(handle_log_stats)) .route("/api/app-logs/stats", get(handle_log_stats))
.route("/api/app-logs/clear/{app_name}", post(handle_clear_logs)) .route("/api/app-logs/clear/{app_name}", post(handle_clear_logs))
.route("/api/app-logs/logger.js", get(handle_logger_js)) .route("/api/app-logs/logger.js", get(handle_logger_js))
.route("/ws/task-progress", get(task_progress_websocket_handler))
.route("/ws/task-progress/{task_id}", get(task_progress_by_id_websocket_handler))
}
pub async fn task_progress_websocket_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let task_filter = params.get("task_id").cloned();
info!(
"Task progress WebSocket connection request, filter: {:?}",
task_filter
);
ws.on_upgrade(move |socket| handle_task_progress_websocket(socket, state, task_filter))
}
pub async fn task_progress_by_id_websocket_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Path(task_id): Path<String>,
) -> impl IntoResponse {
info!(
"Task progress WebSocket connection for task: {}",
task_id
);
ws.on_upgrade(move |socket| handle_task_progress_websocket(socket, state, Some(task_id)))
}
async fn handle_task_progress_websocket(
socket: WebSocket,
state: Arc<AppState>,
task_filter: Option<String>,
) {
let (mut sender, mut receiver) = socket.split();
info!("Task progress WebSocket connected, filter: {:?}", task_filter);
let welcome = serde_json::json!({
"type": "connected",
"message": "Connected to task progress stream",
"filter": task_filter,
"timestamp": chrono::Utc::now().to_rfc3339()
});
if let Ok(welcome_str) = serde_json::to_string(&welcome) {
if sender.send(Message::Text(welcome_str)).await.is_err() {
error!("Failed to send welcome message to task progress WebSocket");
return;
}
}
let mut broadcast_rx = if let Some(broadcast_tx) = state.task_progress_broadcast.as_ref() {
broadcast_tx.subscribe()
} else {
warn!("No task progress broadcast channel available");
return;
};
let task_filter_clone = task_filter.clone();
let send_task = tokio::spawn(async move {
loop {
match broadcast_rx.recv().await {
Ok(event) => {
let should_send = task_filter_clone.is_none()
|| task_filter_clone.as_ref() == Some(&event.task_id);
if should_send {
if let Ok(json_str) = serde_json::to_string(&event) {
debug!(
"Sending task progress to WebSocket: {} - {}",
event.task_id, event.step
);
if sender.send(Message::Text(json_str)).await.is_err() {
error!("Failed to send task progress to WebSocket");
break;
}
}
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!("Task progress WebSocket lagged by {} messages", n);
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
info!("Task progress broadcast channel closed");
break;
}
}
}
});
let recv_task = tokio::spawn(async move {
while let Some(msg) = receiver.next().await {
match msg {
Ok(Message::Text(text)) => {
debug!("Received text from task progress WebSocket: {}", text);
if text == "ping" {
debug!("Received ping from task progress client");
}
}
Ok(Message::Ping(data)) => {
debug!("Received ping from task progress WebSocket");
drop(data);
}
Ok(Message::Pong(_)) => {
debug!("Received pong from task progress WebSocket");
}
Ok(Message::Close(_)) => {
info!("Task progress WebSocket client disconnected");
break;
}
Ok(Message::Binary(_)) => {
debug!("Received binary from task progress WebSocket (ignored)");
}
Err(e) => {
error!("Task progress WebSocket error: {}", e);
break;
}
}
}
});
tokio::select! {
_ = send_task => {
info!("Task progress send task completed");
}
_ = recv_task => {
info!("Task progress receive task completed");
}
}
info!("Task progress WebSocket connection closed, filter: {:?}", task_filter);
} }
async fn handle_client_logs( async fn handle_client_logs(

View file

@ -9,11 +9,101 @@ use diesel::prelude::*;
use std::sync::Arc; use std::sync::Arc;
use sysinfo::System; use sysinfo::System;
/// Cache for component status to avoid spawning pgrep processes too frequently
struct ComponentStatusCache {
statuses: Vec<(String, bool, String)>, // (name, is_running, port)
last_check: std::time::Instant,
}
impl ComponentStatusCache {
fn new() -> Self {
Self {
statuses: Vec::new(),
last_check: std::time::Instant::now() - std::time::Duration::from_secs(60),
}
}
fn needs_refresh(&self) -> bool {
// Only check component status every 10 seconds
self.last_check.elapsed() > std::time::Duration::from_secs(10)
}
fn refresh(&mut self) {
let components = vec![
("Tables", "postgres", "5432"),
("Cache", "valkey-server", "6379"),
("Drive", "minio", "9000"),
("LLM", "llama-server", "8081"),
];
self.statuses.clear();
for (comp_name, process, port) in components {
let is_running = Self::check_component_running(process);
self.statuses
.push((comp_name.to_string(), is_running, port.to_string()));
}
self.last_check = std::time::Instant::now();
}
fn check_component_running(process_name: &str) -> bool {
std::process::Command::new("pgrep")
.arg("-f")
.arg(process_name)
.output()
.map(|output| !output.stdout.is_empty())
.unwrap_or(false)
}
fn get_statuses(&self) -> &[(String, bool, String)] {
&self.statuses
}
}
/// Cache for bot list to avoid database queries too frequently
struct BotListCache {
bot_list: Vec<(String, uuid::Uuid)>,
last_check: std::time::Instant,
}
impl BotListCache {
fn new() -> Self {
Self {
bot_list: Vec::new(),
last_check: std::time::Instant::now() - std::time::Duration::from_secs(60),
}
}
fn needs_refresh(&self) -> bool {
// Only query database every 5 seconds
self.last_check.elapsed() > std::time::Duration::from_secs(5)
}
fn refresh(&mut self, app_state: &Arc<AppState>) {
if let Ok(mut conn) = app_state.conn.get() {
if let Ok(list) = bots
.filter(is_active.eq(true))
.select((name, id))
.load::<(String, uuid::Uuid)>(&mut *conn)
{
self.bot_list = list;
}
}
self.last_check = std::time::Instant::now();
}
fn get_bots(&self) -> &[(String, uuid::Uuid)] {
&self.bot_list
}
}
pub struct StatusPanel { pub struct StatusPanel {
app_state: Arc<AppState>, app_state: Arc<AppState>,
last_update: std::time::Instant, last_update: std::time::Instant,
last_system_refresh: std::time::Instant,
cached_content: String, cached_content: String,
system: System, system: System,
component_cache: ComponentStatusCache,
bot_cache: BotListCache,
} }
impl std::fmt::Debug for StatusPanel { impl std::fmt::Debug for StatusPanel {
@ -29,24 +119,42 @@ impl std::fmt::Debug for StatusPanel {
impl StatusPanel { impl StatusPanel {
pub fn new(app_state: Arc<AppState>) -> Self { pub fn new(app_state: Arc<AppState>) -> Self {
// Only initialize with CPU and memory info, not all system info
let mut system = System::new();
system.refresh_cpu_all();
system.refresh_memory();
Self { Self {
app_state, app_state,
last_update: std::time::Instant::now(), last_update: std::time::Instant::now(),
last_system_refresh: std::time::Instant::now(),
cached_content: String::new(), cached_content: String::new(),
system: System::new_all(), system,
component_cache: ComponentStatusCache::new(),
bot_cache: BotListCache::new(),
} }
} }
pub fn update(&mut self) -> Result<(), std::io::Error> { pub fn update(&mut self) -> Result<(), std::io::Error> {
self.system.refresh_all(); // Only refresh system metrics every 2 seconds instead of every call
// This is the main CPU hog - refresh_all() is very expensive
if self.last_system_refresh.elapsed() > std::time::Duration::from_secs(2) {
// Only refresh CPU and memory, not ALL system info
self.system.refresh_cpu_all();
self.system.refresh_memory();
self.last_system_refresh = std::time::Instant::now();
}
// Refresh component status cache if needed (every 10 seconds)
if self.component_cache.needs_refresh() {
self.component_cache.refresh();
}
// Refresh bot list cache if needed (every 5 seconds)
if self.bot_cache.needs_refresh() {
self.bot_cache.refresh(&self.app_state);
}
let _tokens = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time after UNIX epoch")
.as_secs()
% 1000) as usize;
#[cfg(feature = "nvidia")]
let _system_metrics = nvidia::get_system_metrics().unwrap_or_default();
self.cached_content = self.render(None); self.cached_content = self.render(None);
self.last_update = std::time::Instant::now(); self.last_update = std::time::Instant::now();
Ok(()) Ok(())
@ -60,10 +168,11 @@ impl StatusPanel {
String::new(), String::new(),
]; ];
self.system.refresh_cpu_all(); // Use cached CPU usage - don't refresh here
let cpu_usage = self.system.global_cpu_usage(); let cpu_usage = self.system.global_cpu_usage();
let cpu_bar = Self::create_progress_bar(cpu_usage, 20); let cpu_bar = Self::create_progress_bar(cpu_usage, 20);
lines.push(format!(" CPU: {:5.1}% {}", cpu_usage, cpu_bar)); lines.push(format!(" CPU: {:5.1}% {}", cpu_usage, cpu_bar));
#[cfg(feature = "nvidia")] #[cfg(feature = "nvidia")]
{ {
let system_metrics = get_system_metrics().unwrap_or_default(); let system_metrics = get_system_metrics().unwrap_or_default();
@ -81,7 +190,11 @@ impl StatusPanel {
let total_mem = self.system.total_memory() as f32 / 1024.0 / 1024.0 / 1024.0; let total_mem = self.system.total_memory() as f32 / 1024.0 / 1024.0 / 1024.0;
let used_mem = self.system.used_memory() as f32 / 1024.0 / 1024.0 / 1024.0; let used_mem = self.system.used_memory() as f32 / 1024.0 / 1024.0 / 1024.0;
let mem_percentage = (used_mem / total_mem) * 100.0; let mem_percentage = if total_mem > 0.0 {
(used_mem / total_mem) * 100.0
} else {
0.0
};
let mem_bar = Self::create_progress_bar(mem_percentage, 20); let mem_bar = Self::create_progress_bar(mem_percentage, 20);
lines.push(format!( lines.push(format!(
" MEM: {:5.1}% {} ({:.1}/{:.1} GB)", " MEM: {:5.1}% {} ({:.1}/{:.1} GB)",
@ -94,15 +207,9 @@ impl StatusPanel {
lines.push("╚═══════════════════════════════════════╝".to_string()); lines.push("╚═══════════════════════════════════════╝".to_string());
lines.push("".to_string()); lines.push("".to_string());
let components = vec![ // Use cached component statuses instead of spawning pgrep every time
("Tables", "postgres", "5432"), for (comp_name, is_running, port) in self.component_cache.get_statuses() {
("Cache", "valkey-server", "6379"), let status = if *is_running {
("Drive", "minio", "9000"),
("LLM", "llama-server", "8081"),
];
for (comp_name, process, port) in components {
let status = if Self::check_component_running(process) {
format!(" ONLINE [Port: {}]", port) format!(" ONLINE [Port: {}]", port)
} else { } else {
" OFFLINE".to_string() " OFFLINE".to_string()
@ -116,58 +223,44 @@ impl StatusPanel {
lines.push("╚═══════════════════════════════════════╝".to_string()); lines.push("╚═══════════════════════════════════════╝".to_string());
lines.push("".to_string()); lines.push("".to_string());
if let Ok(mut conn) = self.app_state.conn.get() { // Use cached bot list instead of querying database every time
match bots let bot_list = self.bot_cache.get_bots();
.filter(is_active.eq(true)) if bot_list.is_empty() {
.select((name, id)) lines.push(" No active bots".to_string());
.load::<(String, uuid::Uuid)>(&mut *conn) } else {
{ for (bot_name, bot_id) in bot_list {
Ok(bot_list) => { let marker = if let Some(ref selected) = selected_bot {
if bot_list.is_empty() { if selected == bot_name {
lines.push(" No active bots".to_string()); ""
} else { } else {
for (bot_name, bot_id) in bot_list { " "
let marker = if let Some(ref selected) = selected_bot { }
if selected == &bot_name { } else {
"" " "
} else { };
" " lines.push(format!(" {} {}", marker, bot_name));
}
} else {
" "
};
lines.push(format!(" {} {}", marker, bot_name));
if let Some(ref selected) = selected_bot { if let Some(ref selected) = selected_bot {
if selected == &bot_name { if selected == bot_name {
lines.push("".to_string()); lines.push("".to_string());
lines.push(" ┌─ Bot Configuration ─────────┐".to_string()); lines.push(" ┌─ Bot Configuration ─────────┐".to_string());
let config_manager = let config_manager = ConfigManager::new(self.app_state.conn.clone());
ConfigManager::new(self.app_state.conn.clone()); let llm_model = config_manager
let llm_model = config_manager .get_config(bot_id, "llm-model", None)
.get_config(&bot_id, "llm-model", None) .unwrap_or_else(|_| "N/A".to_string());
.unwrap_or_else(|_| "N/A".to_string()); lines.push(format!(" Model: {}", llm_model));
lines.push(format!(" Model: {}", llm_model)); let ctx_size = config_manager
let ctx_size = config_manager .get_config(bot_id, "llm-server-ctx-size", None)
.get_config(&bot_id, "llm-server-ctx-size", None) .unwrap_or_else(|_| "N/A".to_string());
.unwrap_or_else(|_| "N/A".to_string()); lines.push(format!(" Context: {}", ctx_size));
lines.push(format!(" Context: {}", ctx_size)); let temp = config_manager
let temp = config_manager .get_config(bot_id, "llm-temperature", None)
.get_config(&bot_id, "llm-temperature", None) .unwrap_or_else(|_| "N/A".to_string());
.unwrap_or_else(|_| "N/A".to_string()); lines.push(format!(" Temp: {}", temp));
lines.push(format!(" Temp: {}", temp)); lines.push(" └─────────────────────────────┘".to_string());
lines.push(" └─────────────────────────────┘".to_string());
}
}
}
} }
} }
Err(_) => {
lines.push(" Error loading bots".to_string());
}
} }
} else {
lines.push(" Database locked".to_string());
} }
lines.push("".to_string()); lines.push("".to_string());
@ -195,11 +288,6 @@ impl StatusPanel {
} }
pub fn check_component_running(process_name: &str) -> bool { pub fn check_component_running(process_name: &str) -> bool {
std::process::Command::new("pgrep") ComponentStatusCache::check_component_running(process_name)
.arg("-f")
.arg(process_name)
.output()
.map(|output| !output.stdout.is_empty())
.unwrap_or(false)
} }
} }

View file

@ -19,11 +19,12 @@ pub struct AutomationService {
impl AutomationService { impl AutomationService {
#[must_use] #[must_use]
pub fn new(state: Arc<AppState>) -> Self { pub fn new(state: Arc<AppState>) -> Self {
crate::llm::episodic_memory::start_episodic_memory_scheduler(Arc::clone(&state)); // Temporarily disabled to debug CPU spike
// crate::llm::episodic_memory::start_episodic_memory_scheduler(Arc::clone(&state));
Self { state } Self { state }
} }
pub async fn spawn(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { pub async fn spawn(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut ticker = interval(Duration::from_secs(5)); let mut ticker = interval(Duration::from_secs(60));
loop { loop {
ticker.tick().await; ticker.tick().await;
if let Err(e) = self.check_scheduled_tasks().await { if let Err(e) = self.check_scheduled_tasks().await {
@ -31,7 +32,7 @@ impl AutomationService {
} }
} }
} }
async fn check_scheduled_tasks(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { pub async fn check_scheduled_tasks(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use crate::shared::models::system_automations::dsl::{ use crate::shared::models::system_automations::dsl::{
id, is_active, kind, last_triggered as lt_column, system_automations, id, is_active, kind, last_triggered as lt_column, system_automations,
}; };

View file

@ -185,7 +185,7 @@ impl BootstrapManager {
if pm.is_installed("vault") { if pm.is_installed("vault") {
let vault_already_running = Command::new("sh") let vault_already_running = Command::new("sh")
.arg("-c") .arg("-c")
.arg("curl -f -s 'http://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200' >/dev/null 2>&1") .arg("curl -f -sk 'https://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200' >/dev/null 2>&1")
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.status() .status()
@ -208,7 +208,7 @@ impl BootstrapManager {
for i in 0..10 { for i in 0..10 {
let vault_ready = Command::new("sh") let vault_ready = Command::new("sh")
.arg("-c") .arg("-c")
.arg("curl -f -s 'http://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200' >/dev/null 2>&1") .arg("curl -f -sk 'https://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200' >/dev/null 2>&1")
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.status() .status()
@ -285,8 +285,28 @@ impl BootstrapManager {
info!("Starting PostgreSQL database..."); info!("Starting PostgreSQL database...");
match pm.start("tables") { match pm.start("tables") {
Ok(_child) => { Ok(_child) => {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; let pg_isready = self.stack_dir("bin/tables/bin/pg_isready");
info!("PostgreSQL started"); let mut ready = false;
for attempt in 1..=30 {
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
let status = std::process::Command::new(&pg_isready)
.args(["-h", "localhost", "-p", "5432", "-U", "gbuser"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
if status.map(|s| s.success()).unwrap_or(false) {
ready = true;
info!("PostgreSQL started and ready (attempt {})", attempt);
break;
}
if attempt % 5 == 0 {
info!("Waiting for PostgreSQL to be ready... (attempt {}/30)", attempt);
}
}
if !ready {
error!("PostgreSQL failed to become ready after 30 seconds");
return Err(anyhow::anyhow!("PostgreSQL failed to start properly"));
}
} }
Err(e) => { Err(e) => {
warn!("PostgreSQL might already be running: {}", e); warn!("PostgreSQL might already be running: {}", e);
@ -321,7 +341,7 @@ impl BootstrapManager {
for i in 0..15 { for i in 0..15 {
let drive_ready = Command::new("sh") let drive_ready = Command::new("sh")
.arg("-c") .arg("-c")
.arg("curl -sfk 'https://127.0.0.1:9000/minio/health/live' >/dev/null 2>&1") .arg("curl -sf --cacert ./botserver-stack/conf/drive/certs/CAs/ca.crt 'https://127.0.0.1:9000/minio/health/live' >/dev/null 2>&1")
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.status() .status()
@ -393,7 +413,7 @@ impl BootstrapManager {
if installer.is_installed("vault") { if installer.is_installed("vault") {
let vault_running = Command::new("sh") let vault_running = Command::new("sh")
.arg("-c") .arg("-c")
.arg("curl -f -s 'http://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200' >/dev/null 2>&1") .arg("curl -f -sk 'https://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200' >/dev/null 2>&1")
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.status() .status()
@ -539,7 +559,7 @@ impl BootstrapManager {
async fn ensure_vault_unsealed(&self) -> Result<()> { async fn ensure_vault_unsealed(&self) -> Result<()> {
let vault_init_path = self.stack_dir("conf/vault/init.json"); let vault_init_path = self.stack_dir("conf/vault/init.json");
let vault_addr = "http://localhost:8200"; let vault_addr = "https://localhost:8200";
if !vault_init_path.exists() { if !vault_init_path.exists() {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
@ -685,7 +705,7 @@ impl BootstrapManager {
std::env::set_var("VAULT_ADDR", vault_addr); std::env::set_var("VAULT_ADDR", vault_addr);
std::env::set_var("VAULT_TOKEN", &root_token); std::env::set_var("VAULT_TOKEN", &root_token);
std::env::set_var("VAULT_SKIP_VERIFY", "true"); std::env::set_var("VAULT_CACERT", "./botserver-stack/conf/system/certificates/ca/ca.crt");
std::env::set_var( std::env::set_var(
"VAULT_CACERT", "VAULT_CACERT",
@ -1398,7 +1418,7 @@ meet IN A 127.0.0.1
} }
let health_check = std::process::Command::new("curl") let health_check = std::process::Command::new("curl")
.args(["-f", "-s", "http://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200"]) .args(["-f", "-sk", "https://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200"])
.output(); .output();
if let Ok(output) = health_check { if let Ok(output) = health_check {
@ -1446,9 +1466,10 @@ meet IN A 127.0.0.1
)); ));
} }
let vault_addr = "http://localhost:8200"; let vault_addr = "https://localhost:8200";
let ca_cert_path = "./botserver-stack/conf/system/certificates/ca/ca.crt";
std::env::set_var("VAULT_ADDR", vault_addr); std::env::set_var("VAULT_ADDR", vault_addr);
std::env::set_var("VAULT_SKIP_VERIFY", "true"); std::env::set_var("VAULT_CACERT", ca_cert_path);
let (unseal_key, root_token) = if vault_init_path.exists() { let (unseal_key, root_token) = if vault_init_path.exists() {
info!("Reading Vault initialization from init.json..."); info!("Reading Vault initialization from init.json...");
@ -1485,8 +1506,8 @@ meet IN A 127.0.0.1
let init_output = std::process::Command::new("sh") let init_output = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} {} operator init -key-shares=1 -key-threshold=1 -format=json", "VAULT_ADDR={} VAULT_CACERT={} {} operator init -key-shares=1 -key-threshold=1 -format=json",
vault_addr, vault_bin vault_addr, ca_cert_path, vault_bin
)) ))
.output()?; .output()?;
@ -1501,8 +1522,8 @@ meet IN A 127.0.0.1
let status_check = std::process::Command::new("sh") let status_check = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} {} status -format=json 2>/dev/null", "VAULT_ADDR={} VAULT_CACERT={} {} status -format=json 2>/dev/null",
vault_addr, vault_bin vault_addr, ca_cert_path, vault_bin
)) ))
.output(); .output();
@ -1568,8 +1589,8 @@ meet IN A 127.0.0.1
let unseal_output = std::process::Command::new("sh") let unseal_output = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} {} operator unseal {}", "VAULT_ADDR={} VAULT_CACERT={} {} operator unseal {}",
vault_addr, vault_bin, unseal_key vault_addr, ca_cert_path, vault_bin, unseal_key
)) ))
.output()?; .output()?;
@ -1598,9 +1619,7 @@ meet IN A 127.0.0.1
# Vault Configuration - THESE ARE THE ONLY ALLOWED ENV VARS # Vault Configuration - THESE ARE THE ONLY ALLOWED ENV VARS
VAULT_ADDR={} VAULT_ADDR={}
VAULT_TOKEN={} VAULT_TOKEN={}
VAULT_CACERT=./botserver-stack/conf/system/certificates/ca/ca.crt
# Vault uses HTTP for local development (TLS disabled in config.hcl)
# In production, enable TLS and set VAULT_CACERT, VAULT_CLIENT_CERT, VAULT_CLIENT_KEY
# Cache TTL for secrets (seconds) # Cache TTL for secrets (seconds)
VAULT_CACHE_TTL=300 VAULT_CACHE_TTL=300
@ -1617,23 +1636,25 @@ VAULT_CACHE_TTL=300
} }
info!("Enabling KV secrets engine..."); info!("Enabling KV secrets engine...");
let ca_cert_path = "./botserver-stack/conf/system/certificates/ca/ca.crt";
let _ = std::process::Command::new("sh") let _ = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} secrets enable -path=secret kv-v2 2>&1 || true", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} secrets enable -path=secret kv-v2 2>&1 || true",
vault_addr, root_token, vault_bin vault_addr, root_token, ca_cert_path, vault_bin
)) ))
.output(); .output();
info!("Storing secrets in Vault (only if not existing)..."); info!("Storing secrets in Vault (only if not existing)...");
let vault_bin_clone = vault_bin.clone(); let vault_bin_clone = vault_bin.clone();
let ca_cert_clone = ca_cert_path.to_string();
let secret_exists = |path: &str| -> bool { let secret_exists = |path: &str| -> bool {
let output = std::process::Command::new("sh") let output = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv get {} 2>/dev/null", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv get {} 2>/dev/null",
vault_addr, root_token, vault_bin_clone, path vault_addr, root_token, ca_cert_clone, vault_bin_clone, path
)) ))
.output(); .output();
output.map(|o| o.status.success()).unwrap_or(false) output.map(|o| o.status.success()).unwrap_or(false)
@ -1645,8 +1666,8 @@ VAULT_CACHE_TTL=300
let _ = std::process::Command::new("sh") let _ = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv put secret/gbo/tables host=localhost port=5432 database=botserver username=gbuser password='{}'", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv put secret/gbo/tables host=localhost port=5432 database=botserver username=gbuser password='{}'",
vault_addr, root_token, vault_bin, db_password vault_addr, root_token, ca_cert_path, vault_bin, db_password
)) ))
.output()?; .output()?;
info!(" Stored database credentials"); info!(" Stored database credentials");
@ -1658,8 +1679,8 @@ VAULT_CACHE_TTL=300
let _ = std::process::Command::new("sh") let _ = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv put secret/gbo/drive accesskey='{}' secret='{}'", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv put secret/gbo/drive accesskey='{}' secret='{}'",
vault_addr, root_token, vault_bin, drive_accesskey, drive_secret vault_addr, root_token, ca_cert_path, vault_bin, drive_accesskey, drive_secret
)) ))
.output()?; .output()?;
info!(" Stored drive credentials"); info!(" Stored drive credentials");
@ -1671,8 +1692,8 @@ VAULT_CACHE_TTL=300
let _ = std::process::Command::new("sh") let _ = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv put secret/gbo/cache password='{}'", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv put secret/gbo/cache password='{}'",
vault_addr, root_token, vault_bin, cache_password vault_addr, root_token, ca_cert_path, vault_bin, cache_password
)) ))
.output()?; .output()?;
info!(" Stored cache credentials"); info!(" Stored cache credentials");
@ -1690,8 +1711,8 @@ VAULT_CACHE_TTL=300
let _ = std::process::Command::new("sh") let _ = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv put secret/gbo/directory url=https://localhost:8300 project_id= client_id= client_secret= masterkey={}", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv put secret/gbo/directory url=https://localhost:8300 project_id= client_id= client_secret= masterkey={}",
vault_addr, root_token, vault_bin, masterkey vault_addr, root_token, ca_cert_path, vault_bin, masterkey
)) ))
.output()?; .output()?;
info!(" Created directory placeholder with masterkey"); info!(" Created directory placeholder with masterkey");
@ -1703,8 +1724,8 @@ VAULT_CACHE_TTL=300
let _ = std::process::Command::new("sh") let _ = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv put secret/gbo/llm openai_key= anthropic_key= groq_key=", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv put secret/gbo/llm openai_key= anthropic_key= groq_key=",
vault_addr, root_token, vault_bin vault_addr, root_token, ca_cert_path, vault_bin
)) ))
.output()?; .output()?;
info!(" Created LLM placeholder"); info!(" Created LLM placeholder");
@ -1716,8 +1737,8 @@ VAULT_CACHE_TTL=300
let _ = std::process::Command::new("sh") let _ = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv put secret/gbo/email username= password=", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv put secret/gbo/email username= password=",
vault_addr, root_token, vault_bin vault_addr, root_token, ca_cert_path, vault_bin
)) ))
.output()?; .output()?;
info!(" Created email placeholder"); info!(" Created email placeholder");
@ -1730,8 +1751,8 @@ VAULT_CACHE_TTL=300
let _ = std::process::Command::new("sh") let _ = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv put secret/gbo/encryption master_key='{}'", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv put secret/gbo/encryption master_key='{}'",
vault_addr, root_token, vault_bin, encryption_key vault_addr, root_token, ca_cert_path, vault_bin, encryption_key
)) ))
.output()?; .output()?;
info!(" Generated and stored encryption key"); info!(" Generated and stored encryption key");
@ -1779,6 +1800,8 @@ VAULT_CACHE_TTL=300
format!("{}/", config.drive.server) format!("{}/", config.drive.server)
}; };
info!("[S3_CLIENT] Creating S3 client with endpoint: {}", endpoint);
let (access_key, secret_key) = let (access_key, secret_key) =
if config.drive.access_key.is_empty() || config.drive.secret_key.is_empty() { if config.drive.access_key.is_empty() || config.drive.secret_key.is_empty() {
match crate::shared::utils::get_secrets_manager().await { match crate::shared::utils::get_secrets_manager().await {
@ -1806,19 +1829,30 @@ VAULT_CACHE_TTL=300
) )
}; };
// Set CA cert for self-signed TLS (dev stack)
let ca_cert_path = "./botserver-stack/conf/system/certificates/ca/ca.crt"; let ca_cert_path = "./botserver-stack/conf/system/certificates/ca/ca.crt";
if std::path::Path::new(ca_cert_path).exists() { if std::path::Path::new(ca_cert_path).exists() {
std::env::set_var("AWS_CA_BUNDLE", ca_cert_path); std::env::set_var("AWS_CA_BUNDLE", ca_cert_path);
std::env::set_var("SSL_CERT_FILE", ca_cert_path); std::env::set_var("SSL_CERT_FILE", ca_cert_path);
} }
let timeout_config = aws_config::timeout::TimeoutConfig::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.read_timeout(std::time::Duration::from_secs(30))
.operation_timeout(std::time::Duration::from_secs(30))
.operation_attempt_timeout(std::time::Duration::from_secs(15))
.build();
let retry_config = aws_config::retry::RetryConfig::standard()
.with_max_attempts(2);
let base_config = aws_config::defaults(BehaviorVersion::latest()) let base_config = aws_config::defaults(BehaviorVersion::latest())
.endpoint_url(endpoint) .endpoint_url(endpoint)
.region("auto") .region("auto")
.credentials_provider(aws_sdk_s3::config::Credentials::new( .credentials_provider(aws_sdk_s3::config::Credentials::new(
access_key, secret_key, None, None, "static", access_key, secret_key, None, None, "static",
)) ))
.timeout_config(timeout_config)
.retry_config(retry_config)
.load() .load()
.await; .await;
let s3_config = aws_sdk_s3::config::Builder::from(&base_config) let s3_config = aws_sdk_s3::config::Builder::from(&base_config)
@ -1866,14 +1900,16 @@ VAULT_CACHE_TTL=300
{ {
let bot_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); let bot_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let bucket = bot_name.trim_start_matches('/').to_string(); let bucket = bot_name.trim_start_matches('/').to_string();
// Create bucket if it doesn't exist let bucket_exists = client.head_bucket().bucket(&bucket).send().await.is_ok();
if client.head_bucket().bucket(&bucket).send().await.is_err() { if bucket_exists {
if let Err(e) = client.create_bucket().bucket(&bucket).send().await { info!("Bucket {} already exists, skipping template upload (user content preserved)", bucket);
warn!("S3/MinIO not available, skipping bucket {}: {}", bucket, e); continue;
continue;
}
} }
// Always sync templates to bucket if let Err(e) = client.create_bucket().bucket(&bucket).send().await {
warn!("S3/MinIO not available, skipping bucket {}: {:?}", bucket, e);
continue;
}
info!("Created new bucket {}, uploading templates...", bucket);
if let Err(e) = Self::upload_directory_recursive(&client, &path, &bucket, "/").await { if let Err(e) = Self::upload_directory_recursive(&client, &path, &bucket, "/").await {
warn!("Failed to upload templates to bucket {}: {}", bucket, e); warn!("Failed to upload templates to bucket {}: {}", bucket, e);
} }
@ -2091,16 +2127,18 @@ storage "file" {
path = "../../data/vault" path = "../../data/vault"
} }
# Listener with TLS DISABLED for local development # Listener with TLS enabled
# In production, enable TLS with proper certificates
listener "tcp" { listener "tcp" {
address = "0.0.0.0:8200" address = "0.0.0.0:8200"
tls_disable = true tls_disable = false
tls_cert_file = "../../conf/system/certificates/vault/server.crt"
tls_key_file = "../../conf/system/certificates/vault/server.key"
tls_client_ca_file = "../../conf/system/certificates/ca/ca.crt"
} }
# API settings - use HTTP for local dev # API settings - use HTTPS
api_addr = "http://localhost:8200" api_addr = "https://localhost:8200"
cluster_addr = "http://localhost:8201" cluster_addr = "https://localhost:8201"
# UI enabled for administration # UI enabled for administration
ui = true ui = true
@ -2122,7 +2160,7 @@ log_level = "info"
fs::create_dir_all(self.stack_dir("data/vault"))?; fs::create_dir_all(self.stack_dir("data/vault"))?;
info!( info!(
"Created Vault config with mTLS at {}", "Created Vault config with TLS at {}",
config_path.display() config_path.display()
); );
Ok(()) Ok(())
@ -2293,9 +2331,15 @@ log_level = "info"
params.distinguished_name = dn; params.distinguished_name = dn;
for san in sans { for san in sans {
params if let Ok(ip) = san.parse::<std::net::IpAddr>() {
.subject_alt_names params
.push(rcgen::SanType::DnsName(san.to_string().try_into()?)); .subject_alt_names
.push(rcgen::SanType::IpAddress(ip));
} else {
params
.subject_alt_names
.push(rcgen::SanType::DnsName(san.to_string().try_into()?));
}
} }
let key_pair = KeyPair::generate()?; let key_pair = KeyPair::generate()?;
@ -2311,7 +2355,27 @@ log_level = "info"
fs::create_dir_all(&minio_certs_dir)?; fs::create_dir_all(&minio_certs_dir)?;
let drive_cert_dir = cert_dir.join("drive"); let drive_cert_dir = cert_dir.join("drive");
fs::copy(drive_cert_dir.join("server.crt"), minio_certs_dir.join("public.crt"))?; fs::copy(drive_cert_dir.join("server.crt"), minio_certs_dir.join("public.crt"))?;
fs::copy(drive_cert_dir.join("server.key"), minio_certs_dir.join("private.key"))?;
let drive_key_src = drive_cert_dir.join("server.key");
let drive_key_dst = minio_certs_dir.join("private.key");
let conversion_result = std::process::Command::new("openssl")
.args(["ec", "-in"])
.arg(&drive_key_src)
.args(["-out"])
.arg(&drive_key_dst)
.output();
match conversion_result {
Ok(output) if output.status.success() => {
debug!("Converted drive private key to SEC1 format for MinIO");
}
_ => {
warn!("Could not convert drive key to SEC1 format (openssl not available?), copying as-is");
fs::copy(&drive_key_src, &drive_key_dst)?;
}
}
let minio_ca_dir = minio_certs_dir.join("CAs"); let minio_ca_dir = minio_certs_dir.join("CAs");
fs::create_dir_all(&minio_ca_dir)?; fs::create_dir_all(&minio_ca_dir)?;
fs::copy(&ca_cert_path, minio_ca_dir.join("ca.crt"))?; fs::copy(&ca_cert_path, minio_ca_dir.join("ca.crt"))?;

View file

@ -1,5 +1,5 @@
use anyhow::Result; use anyhow::Result;
use log::{error, info, warn}; use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
@ -118,6 +118,14 @@ impl DocumentProcessor {
let metadata = tokio::fs::metadata(file_path).await?; let metadata = tokio::fs::metadata(file_path).await?;
let file_size = metadata.len() as usize; let file_size = metadata.len() as usize;
if file_size == 0 {
debug!(
"Skipping empty file (0 bytes): {}",
file_path.display()
);
return Ok(Vec::new());
}
let format = DocumentFormat::from_extension(file_path) let format = DocumentFormat::from_extension(file_path)
.ok_or_else(|| anyhow::anyhow!("Unsupported file format: {}", file_path.display()))?; .ok_or_else(|| anyhow::anyhow!("Unsupported file format: {}", file_path.display()))?;

View file

@ -1,24 +1,37 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use log::{debug, info, warn}; use log::{info, trace, warn};
use reqwest::Client; use reqwest::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use crate::core::shared::memory_monitor::{log_jemalloc_stats, MemoryStats};
use super::document_processor::TextChunk; use super::document_processor::TextChunk;
static EMBEDDING_SERVER_READY: AtomicBool = AtomicBool::new(false);
pub fn is_embedding_server_ready() -> bool {
EMBEDDING_SERVER_READY.load(Ordering::SeqCst)
}
pub fn set_embedding_server_ready(ready: bool) {
EMBEDDING_SERVER_READY.store(ready, Ordering::SeqCst);
if ready {
info!("[EMBEDDING] Embedding server marked as ready");
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct EmbeddingConfig { pub struct EmbeddingConfig {
pub embedding_url: String, pub embedding_url: String,
pub embedding_model: String, pub embedding_model: String,
pub dimensions: usize, pub dimensions: usize,
pub batch_size: usize, pub batch_size: usize,
pub timeout_seconds: u64, pub timeout_seconds: u64,
pub max_concurrent_requests: usize,
pub connect_timeout_seconds: u64,
} }
impl Default for EmbeddingConfig { impl Default for EmbeddingConfig {
@ -27,8 +40,10 @@ impl Default for EmbeddingConfig {
embedding_url: "http://localhost:8082".to_string(), embedding_url: "http://localhost:8082".to_string(),
embedding_model: "bge-small-en-v1.5".to_string(), embedding_model: "bge-small-en-v1.5".to_string(),
dimensions: 384, dimensions: 384,
batch_size: 32, batch_size: 16,
timeout_seconds: 30, timeout_seconds: 60,
max_concurrent_requests: 2,
connect_timeout_seconds: 10,
} }
} }
} }
@ -36,17 +51,17 @@ impl Default for EmbeddingConfig {
impl EmbeddingConfig { impl EmbeddingConfig {
pub fn from_env() -> Self { pub fn from_env() -> Self {
let embedding_url = "http://localhost:8082".to_string(); let embedding_url = "http://localhost:8082".to_string();
let embedding_model = "bge-small-en-v1.5".to_string(); let embedding_model = "bge-small-en-v1.5".to_string();
let dimensions = Self::detect_dimensions(&embedding_model); let dimensions = Self::detect_dimensions(&embedding_model);
Self { Self {
embedding_url, embedding_url,
embedding_model, embedding_model,
dimensions, dimensions,
batch_size: 32, batch_size: 16,
timeout_seconds: 30, timeout_seconds: 60,
max_concurrent_requests: 2,
connect_timeout_seconds: 10,
} }
} }
@ -79,12 +94,17 @@ struct EmbeddingResponse {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct EmbeddingData { struct EmbeddingData {
embedding: Vec<f32>, embedding: Vec<f32>,
_index: usize, #[serde(default)]
#[allow(dead_code)]
index: usize,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct EmbeddingUsage { struct EmbeddingUsage {
_prompt_tokens: usize, #[serde(default)]
#[allow(dead_code)]
prompt_tokens: usize,
#[serde(default)]
total_tokens: usize, total_tokens: usize,
} }
@ -115,14 +135,19 @@ impl std::fmt::Debug for KbEmbeddingGenerator {
impl KbEmbeddingGenerator { impl KbEmbeddingGenerator {
pub fn new(config: EmbeddingConfig) -> Self { pub fn new(config: EmbeddingConfig) -> Self {
let client = Client::builder() let client = Client::builder()
.timeout(std::time::Duration::from_secs(config.timeout_seconds)) .timeout(Duration::from_secs(config.timeout_seconds))
.connect_timeout(Duration::from_secs(config.connect_timeout_seconds))
.pool_max_idle_per_host(2)
.pool_idle_timeout(Duration::from_secs(30))
.tcp_keepalive(Duration::from_secs(60))
.tcp_nodelay(true)
.build() .build()
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
log::warn!("Failed to create HTTP client with timeout: {}, using default", e); warn!("Failed to create HTTP client with timeout: {}, using default", e);
Client::new() Client::new()
}); });
let semaphore = Arc::new(Semaphore::new(4)); let semaphore = Arc::new(Semaphore::new(config.max_concurrent_requests));
Self { Self {
config, config,
@ -131,6 +156,65 @@ impl KbEmbeddingGenerator {
} }
} }
pub async fn check_health(&self) -> bool {
let health_url = format!("{}/health", self.config.embedding_url);
match tokio::time::timeout(
Duration::from_secs(5),
self.client.get(&health_url).send()
).await {
Ok(Ok(response)) => {
let is_healthy = response.status().is_success();
if is_healthy {
set_embedding_server_ready(true);
}
is_healthy
}
Ok(Err(e)) => {
let alt_url = &self.config.embedding_url;
match tokio::time::timeout(
Duration::from_secs(5),
self.client.get(alt_url).send()
).await {
Ok(Ok(response)) => {
let is_healthy = response.status().is_success();
if is_healthy {
set_embedding_server_ready(true);
}
is_healthy
}
_ => {
warn!("[EMBEDDING] Health check failed: {}", e);
false
}
}
}
Err(_) => {
warn!("[EMBEDDING] Health check timed out");
false
}
}
}
pub async fn wait_for_server(&self, max_wait_secs: u64) -> bool {
let start = std::time::Instant::now();
let max_wait = Duration::from_secs(max_wait_secs);
info!("[EMBEDDING] Waiting for embedding server at {} (max {}s)...",
self.config.embedding_url, max_wait_secs);
while start.elapsed() < max_wait {
if self.check_health().await {
info!("[EMBEDDING] Embedding server is ready after {:?}", start.elapsed());
return true;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
warn!("[EMBEDDING] Embedding server not available after {}s", max_wait_secs);
false
}
pub async fn generate_embeddings( pub async fn generate_embeddings(
&self, &self,
chunks: &[TextChunk], chunks: &[TextChunk],
@ -139,35 +223,105 @@ impl KbEmbeddingGenerator {
return Ok(Vec::new()); return Ok(Vec::new());
} }
info!("Generating embeddings for {} chunks", chunks.len()); if !is_embedding_server_ready() {
info!("[EMBEDDING] Server not marked ready, checking health...");
if !self.wait_for_server(30).await {
return Err(anyhow::anyhow!(
"Embedding server not available at {}. Skipping embedding generation.",
self.config.embedding_url
));
}
}
let mut results = Vec::new(); let start_mem = MemoryStats::current();
trace!("[EMBEDDING] Generating embeddings for {} chunks, RSS={}",
chunks.len(), MemoryStats::format_bytes(start_mem.rss_bytes));
for batch in chunks.chunks(self.config.batch_size) { let mut results = Vec::with_capacity(chunks.len());
let batch_embeddings = self.generate_batch_embeddings(batch).await?; let total_batches = (chunks.len() + self.config.batch_size - 1) / self.config.batch_size;
for (batch_num, batch) in chunks.chunks(self.config.batch_size).enumerate() {
let batch_start = MemoryStats::current();
trace!("[EMBEDDING] Processing batch {}/{} ({} items), RSS={}",
batch_num + 1,
total_batches,
batch.len(),
MemoryStats::format_bytes(batch_start.rss_bytes));
let batch_embeddings = match tokio::time::timeout(
Duration::from_secs(self.config.timeout_seconds),
self.generate_batch_embeddings(batch)
).await {
Ok(Ok(embeddings)) => embeddings,
Ok(Err(e)) => {
warn!("[EMBEDDING] Batch {} failed: {}", batch_num + 1, e);
break;
}
Err(_) => {
warn!("[EMBEDDING] Batch {} timed out after {}s",
batch_num + 1, self.config.timeout_seconds);
break;
}
};
let batch_end = MemoryStats::current();
let delta = batch_end.rss_bytes.saturating_sub(batch_start.rss_bytes);
trace!("[EMBEDDING] Batch {} complete: {} embeddings, RSS={} (delta={})",
batch_num + 1,
batch_embeddings.len(),
MemoryStats::format_bytes(batch_end.rss_bytes),
MemoryStats::format_bytes(delta));
if delta > 100 * 1024 * 1024 {
warn!("[EMBEDDING] Excessive memory growth detected ({}), stopping early",
MemoryStats::format_bytes(delta));
for (chunk, embedding) in batch.iter().zip(batch_embeddings.iter()) {
results.push((chunk.clone(), embedding.clone()));
}
break;
}
for (chunk, embedding) in batch.iter().zip(batch_embeddings.iter()) { for (chunk, embedding) in batch.iter().zip(batch_embeddings.iter()) {
results.push((chunk.clone(), embedding.clone())); results.push((chunk.clone(), embedding.clone()));
} }
if batch_num + 1 < total_batches {
tokio::time::sleep(Duration::from_millis(100)).await;
}
} }
info!("Generated {} embeddings", results.len()); let end_mem = MemoryStats::current();
trace!("[EMBEDDING] Generated {} embeddings, RSS={} (total delta={})",
results.len(),
MemoryStats::format_bytes(end_mem.rss_bytes),
MemoryStats::format_bytes(end_mem.rss_bytes.saturating_sub(start_mem.rss_bytes)));
log_jemalloc_stats();
Ok(results) Ok(results)
} }
async fn generate_batch_embeddings(&self, chunks: &[TextChunk]) -> Result<Vec<Embedding>> { async fn generate_batch_embeddings(&self, chunks: &[TextChunk]) -> Result<Vec<Embedding>> {
let _permit = self.semaphore.acquire().await?; let _permit = self.semaphore.acquire().await
.map_err(|e| anyhow::anyhow!("Failed to acquire semaphore: {}", e))?;
let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect(); let texts: Vec<String> = chunks.iter().map(|c| c.content.clone()).collect();
let total_chars: usize = texts.iter().map(|t| t.len()).sum();
debug!("Generating embeddings for batch of {} texts", texts.len()); info!("[EMBEDDING] generate_batch_embeddings: {} texts, {} total chars",
texts.len(), total_chars);
match self.generate_local_embeddings(&texts).await { let truncated_texts: Vec<String> = texts.into_iter()
Ok(embeddings) => Ok(embeddings), .map(|t| if t.len() > 8192 { t[..8192].to_string() } else { t })
.collect();
match self.generate_local_embeddings(&truncated_texts).await {
Ok(embeddings) => {
info!("[EMBEDDING] Local embeddings succeeded: {} vectors", embeddings.len());
Ok(embeddings)
}
Err(e) => { Err(e) => {
warn!("Local embedding service failed: {}, trying OpenAI API", e); warn!("[EMBEDDING] Local embedding service failed: {}", e);
self.generate_openai_embeddings(&texts) Err(e)
} }
} }
} }
@ -178,6 +332,12 @@ impl KbEmbeddingGenerator {
model: self.config.embedding_model.clone(), model: self.config.embedding_model.clone(),
}; };
let request_size = serde_json::to_string(&request)
.map(|s| s.len())
.unwrap_or(0);
info!("[EMBEDDING] Sending request to {} (size: {} bytes)",
self.config.embedding_url, request_size);
let response = self let response = self
.client .client
.post(format!("{}/embeddings", self.config.embedding_url)) .post(format!("{}/embeddings", self.config.embedding_url))
@ -186,9 +346,10 @@ impl KbEmbeddingGenerator {
.await .await
.context("Failed to send request to embedding service")?; .context("Failed to send request to embedding service")?;
if !response.status().is_success() { let status = response.status();
let status = response.status(); if !status.is_success() {
let error_text = response.text().await.unwrap_or_default(); let error_bytes = response.bytes().await.unwrap_or_default();
let error_text = String::from_utf8_lossy(&error_bytes[..error_bytes.len().min(1024)]);
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"Embedding service error {}: {}", "Embedding service error {}: {}",
status, status,
@ -196,12 +357,24 @@ impl KbEmbeddingGenerator {
)); ));
} }
let embedding_response: EmbeddingResponse = response let response_bytes = response.bytes().await
.json() .context("Failed to read embedding response bytes")?;
.await
info!("[EMBEDDING] Received response: {} bytes", response_bytes.len());
if response_bytes.len() > 50 * 1024 * 1024 {
return Err(anyhow::anyhow!(
"Embedding response too large: {} bytes (max 50MB)",
response_bytes.len()
));
}
let embedding_response: EmbeddingResponse = serde_json::from_slice(&response_bytes)
.context("Failed to parse embedding response")?; .context("Failed to parse embedding response")?;
let mut embeddings = Vec::new(); drop(response_bytes);
let mut embeddings = Vec::with_capacity(embedding_response.data.len());
for data in embedding_response.data { for data in embedding_response.data {
embeddings.push(Embedding { embeddings.push(Embedding {
vector: data.embedding, vector: data.embedding,
@ -214,14 +387,14 @@ impl KbEmbeddingGenerator {
Ok(embeddings) Ok(embeddings)
} }
fn generate_openai_embeddings(&self, _texts: &[String]) -> Result<Vec<Embedding>> {
let _ = self; // Suppress unused self warning
Err(anyhow::anyhow!(
"OpenAI embeddings not configured - use local embedding service"
))
}
pub async fn generate_single_embedding(&self, text: &str) -> Result<Embedding> { pub async fn generate_single_embedding(&self, text: &str) -> Result<Embedding> {
if !is_embedding_server_ready() && !self.check_health().await {
return Err(anyhow::anyhow!(
"Embedding server not available at {}",
self.config.embedding_url
));
}
let embeddings = self let embeddings = self
.generate_batch_embeddings(&[TextChunk { .generate_batch_embeddings(&[TextChunk {
content: text.to_string(), content: text.to_string(),
@ -272,6 +445,11 @@ impl EmbeddingGenerator {
let embedding = self.kb_generator.generate_single_embedding(text).await?; let embedding = self.kb_generator.generate_single_embedding(text).await?;
Ok(embedding.vector) Ok(embedding.vector)
} }
/// Check if the embedding server is healthy
pub async fn check_health(&self) -> bool {
self.kb_generator.check_health().await
}
} }
pub struct EmailEmbeddingGenerator { pub struct EmailEmbeddingGenerator {

View file

@ -1,15 +1,16 @@
use anyhow::Result; use anyhow::Result;
use log::{debug, info, warn}; use log::{debug, info, trace, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use uuid::Uuid; use uuid::Uuid;
use crate::config::ConfigManager; use crate::config::ConfigManager;
use crate::core::shared::memory_monitor::{log_jemalloc_stats, MemoryStats};
use crate::shared::utils::{create_tls_client, DbPool}; use crate::shared::utils::{create_tls_client, DbPool};
use super::document_processor::{DocumentProcessor, TextChunk}; use super::document_processor::{DocumentProcessor, TextChunk};
use super::embedding_generator::{Embedding, EmbeddingConfig, KbEmbeddingGenerator}; use super::embedding_generator::{is_embedding_server_ready, Embedding, EmbeddingConfig, KbEmbeddingGenerator};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct QdrantConfig { pub struct QdrantConfig {
@ -94,7 +95,6 @@ impl KbIndexer {
let document_processor = DocumentProcessor::default(); let document_processor = DocumentProcessor::default();
let embedding_generator = KbEmbeddingGenerator::new(embedding_config); let embedding_generator = KbEmbeddingGenerator::new(embedding_config);
// Use shared TLS client with local CA certificate
let http_client = create_tls_client(Some(qdrant_config.timeout_secs)); let http_client = create_tls_client(Some(qdrant_config.timeout_secs));
Self { Self {
@ -105,7 +105,6 @@ impl KbIndexer {
} }
} }
/// Check if Qdrant vector database is available
pub async fn check_qdrant_health(&self) -> Result<bool> { pub async fn check_qdrant_health(&self) -> Result<bool> {
let health_url = format!("{}/healthz", self.qdrant_config.url); let health_url = format!("{}/healthz", self.qdrant_config.url);
@ -121,9 +120,24 @@ impl KbIndexer {
kb_name: &str, kb_name: &str,
kb_path: &Path, kb_path: &Path,
) -> Result<IndexingResult> { ) -> Result<IndexingResult> {
info!("Indexing KB folder: {} for bot {}", kb_name, bot_name); let start_mem = MemoryStats::current();
info!("Indexing KB folder: {} for bot {} [START RSS={}]",
kb_name, bot_name, MemoryStats::format_bytes(start_mem.rss_bytes));
log_jemalloc_stats();
if !is_embedding_server_ready() {
info!("[KB_INDEXER] Embedding server not ready yet, waiting up to 60s...");
if !self.embedding_generator.wait_for_server(60).await {
warn!(
"Embedding server is not available. KB indexing skipped. \
Wait for the embedding server to start before indexing."
);
return Err(anyhow::anyhow!(
"Embedding server not available. KB indexing deferred until embedding service is ready."
));
}
}
// Check if Qdrant is available before proceeding
if !self.check_qdrant_health().await.unwrap_or(false) { if !self.check_qdrant_health().await.unwrap_or(false) {
warn!( warn!(
"Qdrant vector database is not available at {}. KB indexing skipped. \ "Qdrant vector database is not available at {}. KB indexing skipped. \
@ -140,27 +154,48 @@ impl KbIndexer {
self.ensure_collection_exists(&collection_name).await?; self.ensure_collection_exists(&collection_name).await?;
let before_docs = MemoryStats::current();
trace!("[KB_INDEXER] Before process_kb_folder RSS={}",
MemoryStats::format_bytes(before_docs.rss_bytes));
let documents = self.document_processor.process_kb_folder(kb_path).await?; let documents = self.document_processor.process_kb_folder(kb_path).await?;
let after_docs = MemoryStats::current();
trace!("[KB_INDEXER] After process_kb_folder: {} documents, RSS={} (delta={})",
documents.len(),
MemoryStats::format_bytes(after_docs.rss_bytes),
MemoryStats::format_bytes(after_docs.rss_bytes.saturating_sub(before_docs.rss_bytes)));
let mut total_chunks = 0; let mut total_chunks = 0;
let mut indexed_documents = 0; let mut indexed_documents = 0;
for (doc_path, chunks) in documents { for (doc_path, chunks) in documents {
if chunks.is_empty() { if chunks.is_empty() {
debug!("[KB_INDEXER] Skipping document with no chunks: {}", doc_path);
continue; continue;
} }
info!( let before_embed = MemoryStats::current();
"Processing document: {} ({} chunks)", trace!(
"[KB_INDEXER] Processing document: {} ({} chunks) RSS={}",
doc_path, doc_path,
chunks.len() chunks.len(),
MemoryStats::format_bytes(before_embed.rss_bytes)
); );
trace!("[KB_INDEXER] Calling generate_embeddings for {} chunks...", chunks.len());
let embeddings = self let embeddings = self
.embedding_generator .embedding_generator
.generate_embeddings(&chunks) .generate_embeddings(&chunks)
.await?; .await?;
let after_embed = MemoryStats::current();
trace!("[KB_INDEXER] After generate_embeddings: {} embeddings, RSS={} (delta={})",
embeddings.len(),
MemoryStats::format_bytes(after_embed.rss_bytes),
MemoryStats::format_bytes(after_embed.rss_bytes.saturating_sub(before_embed.rss_bytes)));
log_jemalloc_stats();
let points = Self::create_qdrant_points(&doc_path, embeddings)?; let points = Self::create_qdrant_points(&doc_path, embeddings)?;
self.upsert_points(&collection_name, points).await?; self.upsert_points(&collection_name, points).await?;
@ -171,6 +206,13 @@ impl KbIndexer {
self.update_collection_metadata(&collection_name, bot_name, kb_name, total_chunks)?; self.update_collection_metadata(&collection_name, bot_name, kb_name, total_chunks)?;
let end_mem = MemoryStats::current();
trace!("[KB_INDEXER] Indexing complete: {} docs, {} chunks, RSS={} (total delta={})",
indexed_documents, total_chunks,
MemoryStats::format_bytes(end_mem.rss_bytes),
MemoryStats::format_bytes(end_mem.rss_bytes.saturating_sub(start_mem.rss_bytes)));
log_jemalloc_stats();
Ok(IndexingResult { Ok(IndexingResult {
collection_name, collection_name,
documents_processed: indexed_documents, documents_processed: indexed_documents,

View file

@ -460,8 +460,8 @@ Store credentials in Vault:
"drive" => { "drive" => {
format!( format!(
r"MinIO Object Storage: r"MinIO Object Storage:
API: http://{}:9000 API: https://{}:9000
Console: http://{}:9001 Console: https://{}:9001
Store credentials in Vault: Store credentials in Vault:
botserver vault put gbo/drive server={} port=9000 accesskey=minioadmin secret=<your-secret>", botserver vault put gbo/drive server={} port=9000 accesskey=minioadmin secret=<your-secret>",

View file

@ -252,7 +252,7 @@ impl PackageManager {
]), ]),
data_download_list: Vec::new(), data_download_list: Vec::new(),
exec_cmd: "nohup {{BIN_PATH}}/minio server {{DATA_PATH}} --address :9000 --console-address :9001 --certs-dir {{CONF_PATH}}/drive/certs > {{LOGS_PATH}}/minio.log 2>&1 &".to_string(), exec_cmd: "nohup {{BIN_PATH}}/minio server {{DATA_PATH}} --address :9000 --console-address :9001 --certs-dir {{CONF_PATH}}/drive/certs > {{LOGS_PATH}}/minio.log 2>&1 &".to_string(),
check_cmd: "curl -sfk https://127.0.0.1:9000/minio/health/live >/dev/null 2>&1".to_string(), check_cmd: "curl -sf --cacert {{CONF_PATH}}/drive/certs/CAs/ca.crt https://127.0.0.1:9000/minio/health/live >/dev/null 2>&1".to_string(),
}, },
); );
} }
@ -891,12 +891,15 @@ storage "file" {
} }
listener "tcp" { listener "tcp" {
address = "0.0.0.0:8200" address = "0.0.0.0:8200"
tls_disable = 1 tls_disable = false
tls_cert_file = "{{CONF_PATH}}/system/certificates/vault/server.crt"
tls_key_file = "{{CONF_PATH}}/system/certificates/vault/server.key"
tls_client_ca_file = "{{CONF_PATH}}/system/certificates/ca/ca.crt"
} }
api_addr = "http://0.0.0.0:8200" api_addr = "https://localhost:8200"
cluster_addr = "http://0.0.0.0:8201" cluster_addr = "https://localhost:8201"
ui = true ui = true
disable_mlock = true disable_mlock = true
EOF"#.to_string(), EOF"#.to_string(),
@ -914,12 +917,15 @@ storage "file" {
} }
listener "tcp" { listener "tcp" {
address = "0.0.0.0:8200" address = "0.0.0.0:8200"
tls_disable = 1 tls_disable = false
tls_cert_file = "{{CONF_PATH}}/system/certificates/vault/server.crt"
tls_key_file = "{{CONF_PATH}}/system/certificates/vault/server.key"
tls_client_ca_file = "{{CONF_PATH}}/system/certificates/ca/ca.crt"
} }
api_addr = "http://0.0.0.0:8200" api_addr = "https://localhost:8200"
cluster_addr = "http://0.0.0.0:8201" cluster_addr = "https://localhost:8201"
ui = true ui = true
disable_mlock = true disable_mlock = true
EOF"#.to_string(), EOF"#.to_string(),
@ -933,13 +939,16 @@ EOF"#.to_string(),
"VAULT_ADDR".to_string(), "VAULT_ADDR".to_string(),
"https://localhost:8200".to_string(), "https://localhost:8200".to_string(),
); );
env.insert("VAULT_SKIP_VERIFY".to_string(), "true".to_string()); env.insert(
"VAULT_CACERT".to_string(),
"./botserver-stack/conf/system/certificates/ca/ca.crt".to_string(),
);
env env
}, },
data_download_list: Vec::new(), data_download_list: Vec::new(),
exec_cmd: "nohup {{BIN_PATH}}/vault server -config={{CONF_PATH}}/vault/config.hcl > {{LOGS_PATH}}/vault.log 2>&1 &" exec_cmd: "nohup {{BIN_PATH}}/vault server -config={{CONF_PATH}}/vault/config.hcl > {{LOGS_PATH}}/vault.log 2>&1 &"
.to_string(), .to_string(),
check_cmd: "curl -f -s --connect-timeout 2 -m 5 'http://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200' >/dev/null 2>&1" check_cmd: "curl -f -sk --connect-timeout 2 -m 5 'https://localhost:8200/v1/sys/health?standbyok=true&uninitcode=200&sealedcode=200' >/dev/null 2>&1"
.to_string(), .to_string(),
}, },
); );
@ -1165,9 +1174,10 @@ EOF"#.to_string(),
} }
// Check if Vault is reachable before trying to fetch credentials // Check if Vault is reachable before trying to fetch credentials
// Use -k for self-signed certs in dev
let vault_check = std::process::Command::new("sh") let vault_check = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!("curl -sf {}/v1/sys/health >/dev/null 2>&1", vault_addr)) .arg(format!("curl -sfk {}/v1/sys/health >/dev/null 2>&1", vault_addr))
.status() .status()
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false); .unwrap_or(false);
@ -1187,10 +1197,14 @@ EOF"#.to_string(),
let vault_bin = base_path.join("bin/vault/vault"); let vault_bin = base_path.join("bin/vault/vault");
let vault_bin_str = vault_bin.to_string_lossy(); let vault_bin_str = vault_bin.to_string_lossy();
// Get CA cert path for Vault TLS
let ca_cert_path = std::env::var("VAULT_CACERT")
.unwrap_or_else(|_| base_path.join("conf/system/certificates/ca/ca.crt").to_string_lossy().to_string());
trace!("Fetching drive credentials from Vault at {} using {}", vault_addr, vault_bin_str); trace!("Fetching drive credentials from Vault at {} using {}", vault_addr, vault_bin_str);
let drive_cmd = format!( let drive_cmd = format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv get -format=json secret/gbo/drive", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv get -format=json secret/gbo/drive",
vault_addr, vault_token, vault_bin_str vault_addr, vault_token, ca_cert_path, vault_bin_str
); );
match std::process::Command::new("sh") match std::process::Command::new("sh")
.arg("-c") .arg("-c")
@ -1229,8 +1243,8 @@ EOF"#.to_string(),
if let Ok(output) = std::process::Command::new("sh") if let Ok(output) = std::process::Command::new("sh")
.arg("-c") .arg("-c")
.arg(format!( .arg(format!(
"unset VAULT_CLIENT_CERT VAULT_CLIENT_KEY VAULT_CACERT; VAULT_ADDR={} VAULT_TOKEN={} {} kv get -format=json secret/gbo/cache 2>/dev/null", "VAULT_ADDR={} VAULT_TOKEN={} VAULT_CACERT={} {} kv get -format=json secret/gbo/cache 2>/dev/null",
vault_addr, vault_token, vault_bin_str vault_addr, vault_token, ca_cert_path, vault_bin_str
)) ))
.output() .output()
{ {

View file

@ -90,8 +90,12 @@ impl SecretsManager {
let mut settings_builder = VaultClientSettingsBuilder::default(); let mut settings_builder = VaultClientSettingsBuilder::default();
settings_builder.address(&addr).token(&token); settings_builder.address(&addr).token(&token);
// Only warn about TLS verification for HTTPS connections
let is_https = addr.starts_with("https://");
if skip_verify { if skip_verify {
warn!("TLS verification disabled - NOT RECOMMENDED FOR PRODUCTION"); if is_https {
warn!("TLS verification disabled - NOT RECOMMENDED FOR PRODUCTION");
}
settings_builder.verify(false); settings_builder.verify(false);
} else { } else {
settings_builder.verify(true); settings_builder.verify(true);

View file

@ -0,0 +1,506 @@
//! Memory and CPU monitoring with thread tracking
//!
//! This module provides tools to track memory/CPU usage per thread
//! and identify potential leaks or CPU hogs in the botserver application.
//!
//! When compiled with the `jemalloc` feature, provides detailed allocation statistics.
use log::{debug, info, trace, warn};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, RwLock};
use std::time::{Duration, Instant};
use sysinfo::{Pid, ProcessesToUpdate, System};
static THREAD_REGISTRY: LazyLock<RwLock<HashMap<String, ThreadInfo>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
static COMPONENT_TRACKER: LazyLock<ComponentMemoryTracker> =
LazyLock::new(|| ComponentMemoryTracker::new(60));
#[derive(Debug, Clone)]
pub struct ThreadInfo {
pub name: String,
pub started_at: Instant,
pub last_activity: Instant,
pub activity_count: u64,
pub component: String,
}
pub fn register_thread(name: &str, component: &str) {
let info = ThreadInfo {
name: name.to_string(),
started_at: Instant::now(),
last_activity: Instant::now(),
activity_count: 0,
component: component.to_string(),
};
if let Ok(mut registry) = THREAD_REGISTRY.write() {
registry.insert(name.to_string(), info);
}
trace!("[THREAD] Registered: {} (component: {})", name, component);
}
pub fn record_thread_activity(name: &str) {
if let Ok(mut registry) = THREAD_REGISTRY.write() {
if let Some(info) = registry.get_mut(name) {
info.last_activity = Instant::now();
info.activity_count += 1;
}
}
}
pub fn unregister_thread(name: &str) {
if let Ok(mut registry) = THREAD_REGISTRY.write() {
registry.remove(name);
}
info!("[THREAD] Unregistered: {}", name);
}
pub fn log_thread_stats() {
if let Ok(registry) = THREAD_REGISTRY.read() {
info!("[THREADS] Active thread count: {}", registry.len());
for (name, info) in registry.iter() {
let uptime = info.started_at.elapsed().as_secs();
let idle = info.last_activity.elapsed().as_secs();
info!(
"[THREAD] {} | component={} | uptime={}s | idle={}s | activities={}",
name, info.component, uptime, idle, info.activity_count
);
}
}
}
#[derive(Debug, Clone)]
pub struct MemoryStats {
pub rss_bytes: u64,
pub virtual_bytes: u64,
pub timestamp: Instant,
}
impl MemoryStats {
pub fn current() -> Self {
let (rss, virt) = get_process_memory().unwrap_or((0, 0));
Self {
rss_bytes: rss,
virtual_bytes: virt,
timestamp: Instant::now(),
}
}
pub fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
pub fn log(&self) {
info!(
"[MEMORY] RSS={}, Virtual={}",
Self::format_bytes(self.rss_bytes),
Self::format_bytes(self.virtual_bytes),
);
}
}
/// Get jemalloc memory statistics when the feature is enabled
#[cfg(feature = "jemalloc")]
pub fn get_jemalloc_stats() -> Option<JemallocStats> {
use tikv_jemalloc_ctl::{epoch, stats};
// Advance the epoch to refresh statistics
if epoch::advance().is_err() {
return None;
}
let allocated = stats::allocated::read().ok()? as u64;
let active = stats::active::read().ok()? as u64;
let resident = stats::resident::read().ok()? as u64;
let mapped = stats::mapped::read().ok()? as u64;
let retained = stats::retained::read().ok()? as u64;
Some(JemallocStats {
allocated,
active,
resident,
mapped,
retained,
})
}
#[cfg(not(feature = "jemalloc"))]
pub fn get_jemalloc_stats() -> Option<JemallocStats> {
None
}
/// Jemalloc memory statistics
#[derive(Debug, Clone)]
pub struct JemallocStats {
/// Total bytes allocated by the application
pub allocated: u64,
/// Total bytes in active pages allocated by the application
pub active: u64,
/// Total bytes in physically resident pages
pub resident: u64,
/// Total bytes in active extents mapped by the allocator
pub mapped: u64,
/// Total bytes retained (not returned to OS)
pub retained: u64,
}
impl JemallocStats {
pub fn log(&self) {
info!(
"[JEMALLOC] allocated={} active={} resident={} mapped={} retained={}",
MemoryStats::format_bytes(self.allocated),
MemoryStats::format_bytes(self.active),
MemoryStats::format_bytes(self.resident),
MemoryStats::format_bytes(self.mapped),
MemoryStats::format_bytes(self.retained),
);
}
/// Calculate fragmentation ratio (1.0 = no fragmentation)
pub fn fragmentation_ratio(&self) -> f64 {
if self.allocated > 0 {
self.active as f64 / self.allocated as f64
} else {
1.0
}
}
}
/// Log jemalloc stats if available
pub fn log_jemalloc_stats() {
if let Some(stats) = get_jemalloc_stats() {
stats.log();
let frag = stats.fragmentation_ratio();
if frag > 1.5 {
warn!("[JEMALLOC] High fragmentation detected: {:.2}x", frag);
}
}
}
#[derive(Debug)]
pub struct MemoryCheckpoint {
pub name: String,
pub stats: MemoryStats,
}
impl MemoryCheckpoint {
pub fn new(name: &str) -> Self {
let stats = MemoryStats::current();
info!(
"[CHECKPOINT] {} started at RSS={}",
name,
MemoryStats::format_bytes(stats.rss_bytes)
);
Self {
name: name.to_string(),
stats,
}
}
pub fn compare_and_log(&self) {
let current = MemoryStats::current();
let diff = current.rss_bytes as i64 - self.stats.rss_bytes as i64;
if diff > 0 {
warn!(
"[CHECKPOINT] {} INCREASED by {}",
self.name,
MemoryStats::format_bytes(diff as u64),
);
} else if diff < 0 {
info!(
"[CHECKPOINT] {} decreased by {}",
self.name,
MemoryStats::format_bytes((-diff) as u64),
);
} else {
debug!("[CHECKPOINT] {} unchanged", self.name);
}
}
}
pub struct ComponentMemoryTracker {
components: Mutex<HashMap<String, Vec<MemoryStats>>>,
max_history: usize,
}
impl ComponentMemoryTracker {
pub fn new(max_history: usize) -> Self {
Self {
components: Mutex::new(HashMap::new()),
max_history,
}
}
pub fn record(&self, component: &str) {
let stats = MemoryStats::current();
if let Ok(mut components) = self.components.lock() {
let history = components.entry(component.to_string()).or_default();
history.push(stats);
if history.len() > self.max_history {
history.remove(0);
}
}
}
pub fn get_growth_rate(&self, component: &str) -> Option<f64> {
if let Ok(components) = self.components.lock() {
if let Some(history) = components.get(component) {
if history.len() >= 2 {
let first = &history[0];
let last = &history[history.len() - 1];
let duration = last.timestamp.duration_since(first.timestamp).as_secs_f64();
if duration > 0.0 {
let byte_diff = last.rss_bytes as f64 - first.rss_bytes as f64;
return Some(byte_diff / duration);
}
}
}
}
None
}
pub fn log_all(&self) {
if let Ok(components) = self.components.lock() {
for (name, history) in components.iter() {
if let Some(last) = history.last() {
let growth = self.get_growth_rate(name);
let growth_str = growth
.map(|g| {
let sign = if g >= 0.0 { "+" } else { "-" };
format!("{}{}/s", sign, MemoryStats::format_bytes(g.abs() as u64))
})
.unwrap_or_else(|| "N/A".to_string());
info!(
"[COMPONENT] {} | RSS={} | Growth={}",
name,
MemoryStats::format_bytes(last.rss_bytes),
growth_str
);
}
}
}
}
}
pub fn record_component(component: &str) {
COMPONENT_TRACKER.record(component);
}
pub fn log_component_stats() {
COMPONENT_TRACKER.log_all();
}
pub struct LeakDetector {
baseline: Mutex<u64>,
growth_threshold_bytes: u64,
consecutive_growth_count: Mutex<usize>,
max_consecutive_growth: usize,
}
impl LeakDetector {
pub fn new(growth_threshold_mb: u64, max_consecutive_growth: usize) -> Self {
Self {
baseline: Mutex::new(0),
growth_threshold_bytes: growth_threshold_mb * 1024 * 1024,
consecutive_growth_count: Mutex::new(0),
max_consecutive_growth,
}
}
pub fn reset_baseline(&self) {
let current = MemoryStats::current();
if let Ok(mut baseline) = self.baseline.lock() {
*baseline = current.rss_bytes;
}
if let Ok(mut count) = self.consecutive_growth_count.lock() {
*count = 0;
}
}
pub fn check(&self) -> Option<String> {
let current = MemoryStats::current();
let baseline_val = match self.baseline.lock() {
Ok(b) => *b,
Err(_) => return None,
};
if baseline_val == 0 {
if let Ok(mut baseline) = self.baseline.lock() {
*baseline = current.rss_bytes;
}
return None;
}
let growth = current.rss_bytes.saturating_sub(baseline_val);
if growth > self.growth_threshold_bytes {
let count = match self.consecutive_growth_count.lock() {
Ok(mut c) => {
*c += 1;
*c
}
Err(_) => return None,
};
if count >= self.max_consecutive_growth {
return Some(format!(
"POTENTIAL MEMORY LEAK: grew by {} over {} checks. RSS={}, Baseline={}",
MemoryStats::format_bytes(growth),
count,
MemoryStats::format_bytes(current.rss_bytes),
MemoryStats::format_bytes(baseline_val),
));
}
} else {
if let Ok(mut count) = self.consecutive_growth_count.lock() {
*count = 0;
}
if let Ok(mut baseline) = self.baseline.lock() {
*baseline = current.rss_bytes;
}
}
None
}
}
pub fn start_memory_monitor(interval_secs: u64, warn_threshold_mb: u64) {
let detector = LeakDetector::new(warn_threshold_mb, 5);
tokio::spawn(async move {
register_thread("memory-monitor", "monitoring");
info!(
"[MONITOR] Started (interval={}s, threshold={}MB)",
interval_secs, warn_threshold_mb
);
let mut prev_rss: u64 = 0;
let mut tick_count: u64 = 0;
// First 2 minutes: check every 10 seconds for aggressive tracking
// After that: use normal interval
let startup_interval = Duration::from_secs(10);
let normal_interval = Duration::from_secs(interval_secs);
let startup_ticks = 12; // 2 minutes of 10-second intervals
let mut interval = tokio::time::interval(startup_interval);
loop {
interval.tick().await;
tick_count += 1;
record_thread_activity("memory-monitor");
let stats = MemoryStats::current();
let rss_diff = if prev_rss > 0 {
stats.rss_bytes as i64 - prev_rss as i64
} else {
0
};
let diff_str = if rss_diff > 0 {
format!("+{}", MemoryStats::format_bytes(rss_diff as u64))
} else if rss_diff < 0 {
format!("-{}", MemoryStats::format_bytes((-rss_diff) as u64))
} else {
"±0".to_string()
};
trace!(
"[MONITOR] tick={} RSS={} ({}) Virtual={}",
tick_count,
MemoryStats::format_bytes(stats.rss_bytes),
diff_str,
MemoryStats::format_bytes(stats.virtual_bytes),
);
// Log jemalloc stats every 5 ticks if available
if tick_count % 5 == 0 {
log_jemalloc_stats();
}
prev_rss = stats.rss_bytes;
record_component("global");
if let Some(warning) = detector.check() {
warn!("[MONITOR] {}", warning);
stats.log();
log_component_stats();
log_thread_stats();
}
// Switch to normal interval after startup period
if tick_count == startup_ticks {
trace!("[MONITOR] Switching to normal interval ({}s)", interval_secs);
interval = tokio::time::interval(normal_interval);
}
}
});
}
pub fn get_process_memory() -> Option<(u64, u64)> {
let pid = Pid::from_u32(std::process::id());
let mut sys = System::new();
sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
sys.process(pid).map(|p| (p.memory(), p.virtual_memory()))
}
pub fn log_process_memory() {
if let Some((rss, virt)) = get_process_memory() {
trace!(
"[PROCESS] RSS={}, Virtual={}",
MemoryStats::format_bytes(rss),
MemoryStats::format_bytes(virt)
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_memory_stats() {
let stats = MemoryStats::current();
assert!(stats.rss_bytes > 0 || stats.virtual_bytes >= 0);
}
#[test]
fn test_format_bytes() {
assert_eq!(MemoryStats::format_bytes(500), "500 B");
assert_eq!(MemoryStats::format_bytes(1024), "1.00 KB");
assert_eq!(MemoryStats::format_bytes(1024 * 1024), "1.00 MB");
assert_eq!(MemoryStats::format_bytes(1024 * 1024 * 1024), "1.00 GB");
}
#[test]
fn test_checkpoint() {
let checkpoint = MemoryCheckpoint::new("test");
checkpoint.compare_and_log();
}
#[test]
fn test_thread_registry() {
register_thread("test-thread", "test-component");
record_thread_activity("test-thread");
log_thread_stats();
unregister_thread("test-thread");
}
}

View file

@ -6,6 +6,7 @@
pub mod admin; pub mod admin;
pub mod analytics; pub mod analytics;
pub mod enums; pub mod enums;
pub mod memory_monitor;
pub mod models; pub mod models;
pub mod schema; pub mod schema;
pub mod state; pub mod state;

View file

@ -42,6 +42,192 @@ pub struct AttendantNotification {
pub priority: i32, pub priority: i32,
} }
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AgentActivity {
pub phase: String,
pub items_processed: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub items_total: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub speed_per_min: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub eta_seconds: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub current_item: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bytes_processed: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_used: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub files_created: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tables_created: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub log_lines: Option<Vec<String>>,
}
impl AgentActivity {
pub fn new(phase: impl Into<String>) -> Self {
Self {
phase: phase.into(),
items_processed: 0,
items_total: None,
speed_per_min: None,
eta_seconds: None,
current_item: None,
bytes_processed: None,
tokens_used: None,
files_created: None,
tables_created: None,
log_lines: None,
}
}
#[must_use]
pub fn with_progress(mut self, processed: u32, total: Option<u32>) -> Self {
self.items_processed = processed;
self.items_total = total;
self
}
#[must_use]
pub fn with_speed(mut self, speed: f32, eta_seconds: Option<u32>) -> Self {
self.speed_per_min = Some(speed);
self.eta_seconds = eta_seconds;
self
}
#[must_use]
pub fn with_current_item(mut self, item: impl Into<String>) -> Self {
self.current_item = Some(item.into());
self
}
#[must_use]
pub fn with_bytes(mut self, bytes: u64) -> Self {
self.bytes_processed = Some(bytes);
self
}
#[must_use]
pub fn with_tokens(mut self, tokens: u32) -> Self {
self.tokens_used = Some(tokens);
self
}
#[must_use]
pub fn with_files(mut self, files: Vec<String>) -> Self {
self.files_created = Some(files);
self
}
#[must_use]
pub fn with_tables(mut self, tables: Vec<String>) -> Self {
self.tables_created = Some(tables);
self
}
#[must_use]
pub fn with_log_lines(mut self, lines: Vec<String>) -> Self {
self.log_lines = Some(lines);
self
}
#[must_use]
pub fn add_log_line(mut self, line: impl Into<String>) -> Self {
let lines = self.log_lines.get_or_insert_with(Vec::new);
lines.push(line.into());
self
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TaskProgressEvent {
#[serde(rename = "type")]
pub event_type: String,
pub task_id: String,
pub step: String,
pub message: String,
pub progress: u8,
pub total_steps: u8,
pub current_step: u8,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activity: Option<AgentActivity>,
}
impl TaskProgressEvent {
pub fn new(task_id: impl Into<String>, step: impl Into<String>, message: impl Into<String>) -> Self {
Self {
event_type: "task_progress".to_string(),
task_id: task_id.into(),
step: step.into(),
message: message.into(),
progress: 0,
total_steps: 0,
current_step: 0,
timestamp: chrono::Utc::now().to_rfc3339(),
details: None,
error: None,
activity: None,
}
}
#[must_use]
pub fn with_progress(mut self, current: u8, total: u8) -> Self {
self.current_step = current;
self.total_steps = total;
self.progress = if total > 0 { (current * 100) / total } else { 0 };
self
}
#[must_use]
pub fn with_details(mut self, details: impl Into<String>) -> Self {
self.details = Some(details.into());
self
}
#[must_use]
pub fn with_activity(mut self, activity: AgentActivity) -> Self {
self.activity = Some(activity);
self
}
#[must_use]
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.event_type = "task_error".to_string();
self.error = Some(error.into());
self
}
#[must_use]
pub fn completed(mut self) -> Self {
self.event_type = "task_completed".to_string();
self.progress = 100;
self
}
pub fn started(task_id: impl Into<String>, message: impl Into<String>, total_steps: u8) -> Self {
Self {
event_type: "task_started".to_string(),
task_id: task_id.into(),
step: "init".to_string(),
message: message.into(),
progress: 0,
total_steps,
current_step: 0,
timestamp: chrono::Utc::now().to_rfc3339(),
details: None,
error: None,
activity: None,
}
}
}
#[derive(Clone, Default)] #[derive(Clone, Default)]
pub struct Extensions { pub struct Extensions {
map: Arc<RwLock<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>>, map: Arc<RwLock<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>>,
@ -129,6 +315,7 @@ pub struct AppState {
pub task_engine: Arc<TaskEngine>, pub task_engine: Arc<TaskEngine>,
pub extensions: Extensions, pub extensions: Extensions,
pub attendant_broadcast: Option<broadcast::Sender<AttendantNotification>>, pub attendant_broadcast: Option<broadcast::Sender<AttendantNotification>>,
pub task_progress_broadcast: Option<broadcast::Sender<TaskProgressEvent>>,
} }
impl Clone for AppState { impl Clone for AppState {
@ -158,6 +345,7 @@ impl Clone for AppState {
task_engine: Arc::clone(&self.task_engine), task_engine: Arc::clone(&self.task_engine),
extensions: self.extensions.clone(), extensions: self.extensions.clone(),
attendant_broadcast: self.attendant_broadcast.clone(), attendant_broadcast: self.attendant_broadcast.clone(),
task_progress_broadcast: self.task_progress_broadcast.clone(),
} }
} }
} }
@ -198,10 +386,95 @@ impl std::fmt::Debug for AppState {
.field("task_engine", &"Arc<TaskEngine>") .field("task_engine", &"Arc<TaskEngine>")
.field("extensions", &self.extensions) .field("extensions", &self.extensions)
.field("attendant_broadcast", &self.attendant_broadcast.is_some()) .field("attendant_broadcast", &self.attendant_broadcast.is_some())
.field("task_progress_broadcast", &self.task_progress_broadcast.is_some())
.finish() .finish()
} }
} }
impl AppState {
pub fn broadcast_task_progress(&self, event: TaskProgressEvent) {
log::info!(
"[TASK_PROGRESS] Broadcasting: task_id={}, step={}, message={}",
event.task_id,
event.step,
event.message
);
if let Some(tx) = &self.task_progress_broadcast {
let receiver_count = tx.receiver_count();
log::info!("[TASK_PROGRESS] Broadcast channel has {} receivers", receiver_count);
match tx.send(event) {
Ok(_) => {
log::info!("[TASK_PROGRESS] Event sent successfully");
}
Err(e) => {
log::warn!("[TASK_PROGRESS] No listeners for task progress: {e}");
}
}
} else {
log::warn!("[TASK_PROGRESS] No broadcast channel configured!");
}
}
pub fn emit_progress(
&self,
task_id: &str,
step: &str,
message: &str,
current: u8,
total: u8,
) {
let event = TaskProgressEvent::new(task_id, step, message)
.with_progress(current, total);
self.broadcast_task_progress(event);
}
pub fn emit_progress_with_details(
&self,
task_id: &str,
step: &str,
message: &str,
current: u8,
total: u8,
details: &str,
) {
let event = TaskProgressEvent::new(task_id, step, message)
.with_progress(current, total)
.with_details(details);
self.broadcast_task_progress(event);
}
pub fn emit_activity(
&self,
task_id: &str,
step: &str,
message: &str,
current: u8,
total: u8,
activity: AgentActivity,
) {
let event = TaskProgressEvent::new(task_id, step, message)
.with_progress(current, total)
.with_activity(activity);
self.broadcast_task_progress(event);
}
pub fn emit_task_started(&self, task_id: &str, message: &str, total_steps: u8) {
let event = TaskProgressEvent::started(task_id, message, total_steps);
self.broadcast_task_progress(event);
}
pub fn emit_task_completed(&self, task_id: &str, message: &str) {
let event = TaskProgressEvent::new(task_id, "complete", message).completed();
self.broadcast_task_progress(event);
}
pub fn emit_task_error(&self, task_id: &str, step: &str, error: &str) {
let event = TaskProgressEvent::new(task_id, step, "Task failed")
.with_error(error);
self.broadcast_task_progress(event);
}
}
#[cfg(test)] #[cfg(test)]
impl Default for AppState { impl Default for AppState {
fn default() -> Self { fn default() -> Self {
@ -212,6 +485,7 @@ impl Default for AppState {
let pool = Pool::builder() let pool = Pool::builder()
.max_size(1) .max_size(1)
.test_on_check_out(false) .test_on_check_out(false)
.connection_timeout(std::time::Duration::from_secs(5))
.build(manager) .build(manager)
.expect("Failed to create test database pool"); .expect("Failed to create test database pool");
@ -219,6 +493,7 @@ impl Default for AppState {
let session_manager = SessionManager::new(conn, None); let session_manager = SessionManager::new(conn, None);
let (attendant_tx, _) = broadcast::channel(100); let (attendant_tx, _) = broadcast::channel(100);
let (task_progress_tx, _) = broadcast::channel(100);
Self { Self {
#[cfg(feature = "drive")] #[cfg(feature = "drive")]
@ -245,6 +520,7 @@ impl Default for AppState {
task_engine: Arc::new(TaskEngine::new(pool)), task_engine: Arc::new(TaskEngine::new(pool)),
extensions: Extensions::new(), extensions: Extensions::new(),
attendant_broadcast: Some(attendant_tx), attendant_broadcast: Some(attendant_tx),
task_progress_broadcast: Some(task_progress_tx),
} }
} }
} }

View file

@ -178,6 +178,7 @@ impl TestAppStateBuilder {
let pool = Pool::builder() let pool = Pool::builder()
.max_size(1) .max_size(1)
.test_on_check_out(false) .test_on_check_out(false)
.connection_timeout(std::time::Duration::from_secs(5))
.build(manager)?; .build(manager)?;
let conn = pool.get()?; let conn = pool.get()?;
@ -240,7 +241,10 @@ pub fn create_test_db_pool() -> Result<DbPool, Box<dyn std::error::Error + Send
let database_url = get_database_url_sync() let database_url = get_database_url_sync()
.unwrap_or_else(|_| "postgres://test:test@localhost:5432/test".to_string()); .unwrap_or_else(|_| "postgres://test:test@localhost:5432/test".to_string());
let manager = ConnectionManager::<PgConnection>::new(&database_url); let manager = ConnectionManager::<PgConnection>::new(&database_url);
let pool = Pool::builder().max_size(1).build(manager)?; let pool = Pool::builder()
.max_size(1)
.connection_timeout(std::time::Duration::from_secs(5))
.build(manager)?;
Ok(pool) Ok(pool)
} }

View file

@ -2,6 +2,8 @@ use crate::config::DriveConfig;
use crate::core::secrets::SecretsManager; use crate::core::secrets::SecretsManager;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use aws_config::BehaviorVersion; use aws_config::BehaviorVersion;
use aws_config::retry::RetryConfig;
use aws_config::timeout::TimeoutConfig;
use aws_sdk_s3::{config::Builder as S3ConfigBuilder, Client as S3Client}; use aws_sdk_s3::{config::Builder as S3ConfigBuilder, Client as S3Client};
use diesel::Connection; use diesel::Connection;
use diesel::{ use diesel::{
@ -101,18 +103,33 @@ pub async fn create_s3_operator(
(config.access_key.clone(), config.secret_key.clone()) (config.access_key.clone(), config.secret_key.clone())
}; };
// Set CA cert for self-signed TLS (dev stack)
if std::path::Path::new(CA_CERT_PATH).exists() { if std::path::Path::new(CA_CERT_PATH).exists() {
std::env::set_var("AWS_CA_BUNDLE", CA_CERT_PATH); std::env::set_var("AWS_CA_BUNDLE", CA_CERT_PATH);
std::env::set_var("SSL_CERT_FILE", CA_CERT_PATH); std::env::set_var("SSL_CERT_FILE", CA_CERT_PATH);
debug!("Set AWS_CA_BUNDLE and SSL_CERT_FILE to {} for S3 client", CA_CERT_PATH); debug!("Set AWS_CA_BUNDLE and SSL_CERT_FILE to {} for S3 client", CA_CERT_PATH);
} }
// Configure timeouts to prevent memory leaks on connection failures
let timeout_config = TimeoutConfig::builder()
.connect_timeout(Duration::from_secs(5))
.read_timeout(Duration::from_secs(30))
.operation_timeout(Duration::from_secs(30))
.operation_attempt_timeout(Duration::from_secs(15))
.build();
// Limit retries to prevent 100% CPU on connection failures
let retry_config = RetryConfig::standard()
.with_max_attempts(2);
let base_config = aws_config::defaults(BehaviorVersion::latest()) let base_config = aws_config::defaults(BehaviorVersion::latest())
.endpoint_url(endpoint) .endpoint_url(endpoint)
.region("auto") .region("auto")
.credentials_provider(aws_sdk_s3::config::Credentials::new( .credentials_provider(aws_sdk_s3::config::Credentials::new(
access_key, secret_key, None, None, "static", access_key, secret_key, None, None, "static",
)) ))
.timeout_config(timeout_config)
.retry_config(retry_config)
.load() .load()
.await; .await;
let s3_config = S3ConfigBuilder::from(&base_config) let s3_config = S3ConfigBuilder::from(&base_config)
@ -261,6 +278,11 @@ pub fn create_conn() -> Result<DbPool, anyhow::Error> {
let database_url = get_database_url_sync()?; let database_url = get_database_url_sync()?;
let manager = ConnectionManager::<PgConnection>::new(database_url); let manager = ConnectionManager::<PgConnection>::new(database_url);
Pool::builder() Pool::builder()
.max_size(10)
.min_idle(Some(1))
.connection_timeout(std::time::Duration::from_secs(5))
.idle_timeout(Some(std::time::Duration::from_secs(300)))
.max_lifetime(Some(std::time::Duration::from_secs(1800)))
.build(manager) .build(manager)
.map_err(|e| anyhow::anyhow!("Failed to create database pool: {}", e)) .map_err(|e| anyhow::anyhow!("Failed to create database pool: {}", e))
} }
@ -269,6 +291,11 @@ pub async fn create_conn_async() -> Result<DbPool, anyhow::Error> {
let database_url = get_database_url().await?; let database_url = get_database_url().await?;
let manager = ConnectionManager::<PgConnection>::new(database_url); let manager = ConnectionManager::<PgConnection>::new(database_url);
Pool::builder() Pool::builder()
.max_size(10)
.min_idle(Some(1))
.connection_timeout(std::time::Duration::from_secs(5))
.idle_timeout(Some(std::time::Duration::from_secs(300)))
.max_lifetime(Some(std::time::Duration::from_secs(1800)))
.build(manager) .build(manager)
.map_err(|e| anyhow::anyhow!("Failed to create database pool: {}", e)) .map_err(|e| anyhow::anyhow!("Failed to create database pool: {}", e))
} }

View file

@ -1,16 +1,22 @@
use crate::basic::compiler::BasicCompiler; use crate::basic::compiler::BasicCompiler;
use crate::config::ConfigManager; use crate::config::ConfigManager;
use crate::core::kb::embedding_generator::is_embedding_server_ready;
use crate::core::kb::KnowledgeBaseManager; use crate::core::kb::KnowledgeBaseManager;
use crate::core::shared::memory_monitor::{log_jemalloc_stats, MemoryStats};
use crate::shared::message_types::MessageType; use crate::shared::message_types::MessageType;
use crate::shared::state::AppState; use crate::shared::state::AppState;
use aws_sdk_s3::Client; use aws_sdk_s3::Client;
use log::{debug, error, info}; use log::{debug, error, info, trace, warn};
use std::collections::HashMap; use std::collections::HashMap;
use std::error::Error; use std::error::Error;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tokio::time::{interval, Duration}; use tokio::time::Duration;
const KB_INDEXING_TIMEOUT_SECS: u64 = 60;
const MAX_BACKOFF_SECS: u64 = 300;
const INITIAL_BACKOFF_SECS: u64 = 30;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FileState { pub struct FileState {
pub etag: String, pub etag: String,
@ -24,6 +30,7 @@ pub struct DriveMonitor {
kb_manager: Arc<KnowledgeBaseManager>, kb_manager: Arc<KnowledgeBaseManager>,
work_root: PathBuf, work_root: PathBuf,
is_processing: Arc<AtomicBool>, is_processing: Arc<AtomicBool>,
consecutive_failures: Arc<AtomicU32>,
} }
impl DriveMonitor { impl DriveMonitor {
pub fn new(state: Arc<AppState>, bucket_name: String, bot_id: uuid::Uuid) -> Self { pub fn new(state: Arc<AppState>, bucket_name: String, bot_id: uuid::Uuid) -> Self {
@ -38,29 +45,105 @@ impl DriveMonitor {
kb_manager, kb_manager,
work_root, work_root,
is_processing: Arc::new(AtomicBool::new(false)), is_processing: Arc::new(AtomicBool::new(false)),
consecutive_failures: Arc::new(AtomicU32::new(0)),
} }
} }
async fn check_drive_health(&self) -> bool {
let Some(client) = &self.state.drive else {
return false;
};
match tokio::time::timeout(
Duration::from_secs(5),
client.head_bucket().bucket(&self.bucket_name).send(),
)
.await
{
Ok(Ok(_)) => true,
Ok(Err(e)) => {
debug!("[DRIVE_MONITOR] Health check failed: {}", e);
false
}
Err(_) => {
debug!("[DRIVE_MONITOR] Health check timed out");
false
}
}
}
fn calculate_backoff(&self) -> Duration {
let failures = self.consecutive_failures.load(Ordering::Relaxed);
if failures == 0 {
return Duration::from_secs(INITIAL_BACKOFF_SECS);
}
let backoff_secs = INITIAL_BACKOFF_SECS * (1u64 << failures.min(4));
Duration::from_secs(backoff_secs.min(MAX_BACKOFF_SECS))
}
pub async fn start_monitoring(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { pub async fn start_monitoring(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("Starting DriveMonitor for bot {}", self.bot_id); trace!("[PROFILE] start_monitoring ENTER");
let start_mem = MemoryStats::current();
trace!("[DRIVE_MONITOR] Starting DriveMonitor for bot {}, RSS={}",
self.bot_id, MemoryStats::format_bytes(start_mem.rss_bytes));
if !self.check_drive_health().await {
warn!("[DRIVE_MONITOR] S3/MinIO not available for bucket {}, will retry with backoff",
self.bucket_name);
}
self.is_processing self.is_processing
.store(true, std::sync::atomic::Ordering::SeqCst); .store(true, std::sync::atomic::Ordering::SeqCst);
self.check_for_changes().await?; trace!("[PROFILE] start_monitoring: calling check_for_changes...");
info!("[DRIVE_MONITOR] Calling initial check_for_changes...");
match self.check_for_changes().await {
Ok(_) => {
self.consecutive_failures.store(0, Ordering::Relaxed);
}
Err(e) => {
warn!("[DRIVE_MONITOR] Initial check failed (will retry): {}", e);
self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
}
}
trace!("[PROFILE] start_monitoring: check_for_changes returned");
let after_initial = MemoryStats::current();
trace!("[DRIVE_MONITOR] After initial check, RSS={} (delta={})",
MemoryStats::format_bytes(after_initial.rss_bytes),
MemoryStats::format_bytes(after_initial.rss_bytes.saturating_sub(start_mem.rss_bytes)));
let self_clone = Arc::new(self.clone()); let self_clone = Arc::new(self.clone());
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(30));
while self_clone while self_clone
.is_processing .is_processing
.load(std::sync::atomic::Ordering::SeqCst) .load(std::sync::atomic::Ordering::SeqCst)
{ {
interval.tick().await; let backoff = self_clone.calculate_backoff();
tokio::time::sleep(backoff).await;
if let Err(e) = self_clone.check_for_changes().await { if !self_clone.check_drive_health().await {
error!("Error during sync for bot {}: {}", self_clone.bot_id, e); let failures = self_clone.consecutive_failures.fetch_add(1, Ordering::Relaxed) + 1;
if failures % 10 == 1 {
warn!("[DRIVE_MONITOR] S3/MinIO unavailable for bucket {} (failures: {}), backing off to {:?}",
self_clone.bucket_name, failures, self_clone.calculate_backoff());
}
continue;
}
match self_clone.check_for_changes().await {
Ok(_) => {
let prev_failures = self_clone.consecutive_failures.swap(0, Ordering::Relaxed);
if prev_failures > 0 {
info!("[DRIVE_MONITOR] S3/MinIO recovered for bucket {} after {} failures",
self_clone.bucket_name, prev_failures);
}
}
Err(e) => {
self_clone.consecutive_failures.fetch_add(1, Ordering::Relaxed);
error!("Error during sync for bot {}: {}", self_clone.bot_id, e);
}
} }
} }
}); });
@ -76,6 +159,7 @@ impl DriveMonitor {
.store(false, std::sync::atomic::Ordering::SeqCst); .store(false, std::sync::atomic::Ordering::SeqCst);
self.file_states.write().await.clear(); self.file_states.write().await.clear();
self.consecutive_failures.store(0, Ordering::Relaxed);
info!("DriveMonitor stopped for bot {}", self.bot_id); info!("DriveMonitor stopped for bot {}", self.bot_id);
Ok(()) Ok(())
@ -86,9 +170,9 @@ impl DriveMonitor {
"Drive Monitor service started for bucket: {}", "Drive Monitor service started for bucket: {}",
self.bucket_name self.bucket_name
); );
let mut tick = interval(Duration::from_secs(90));
loop { loop {
tick.tick().await; let backoff = self.calculate_backoff();
tokio::time::sleep(backoff).await;
if self.is_processing.load(Ordering::Acquire) { if self.is_processing.load(Ordering::Acquire) {
log::warn!( log::warn!(
@ -97,10 +181,29 @@ impl DriveMonitor {
continue; continue;
} }
if !self.check_drive_health().await {
let failures = self.consecutive_failures.fetch_add(1, Ordering::Relaxed) + 1;
if failures % 10 == 1 {
warn!("[DRIVE_MONITOR] S3/MinIO unavailable for bucket {} (failures: {}), backing off to {:?}",
self.bucket_name, failures, self.calculate_backoff());
}
continue;
}
self.is_processing.store(true, Ordering::Release); self.is_processing.store(true, Ordering::Release);
if let Err(e) = self.check_for_changes().await { match self.check_for_changes().await {
log::error!("Error checking for drive changes: {}", e); Ok(_) => {
let prev_failures = self.consecutive_failures.swap(0, Ordering::Relaxed);
if prev_failures > 0 {
info!("[DRIVE_MONITOR] S3/MinIO recovered for bucket {} after {} failures",
self.bucket_name, prev_failures);
}
}
Err(e) => {
self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
log::error!("Error checking for drive changes: {}", e);
}
} }
self.is_processing.store(false, Ordering::Release); self.is_processing.store(false, Ordering::Release);
@ -108,12 +211,52 @@ impl DriveMonitor {
}) })
} }
async fn check_for_changes(&self) -> Result<(), Box<dyn Error + Send + Sync>> { async fn check_for_changes(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
trace!("[PROFILE] check_for_changes ENTER");
let start_mem = MemoryStats::current();
trace!("[DRIVE_MONITOR] check_for_changes START, RSS={}",
MemoryStats::format_bytes(start_mem.rss_bytes));
let Some(client) = &self.state.drive else { let Some(client) = &self.state.drive else {
trace!("[PROFILE] check_for_changes: no drive client, returning");
return Ok(()); return Ok(());
}; };
trace!("[PROFILE] check_for_changes: calling check_gbdialog_changes...");
trace!("[DRIVE_MONITOR] Checking gbdialog...");
self.check_gbdialog_changes(client).await?; self.check_gbdialog_changes(client).await?;
trace!("[PROFILE] check_for_changes: check_gbdialog_changes done");
let after_dialog = MemoryStats::current();
trace!("[DRIVE_MONITOR] After gbdialog, RSS={} (delta={})",
MemoryStats::format_bytes(after_dialog.rss_bytes),
MemoryStats::format_bytes(after_dialog.rss_bytes.saturating_sub(start_mem.rss_bytes)));
trace!("[PROFILE] check_for_changes: calling check_gbot...");
trace!("[DRIVE_MONITOR] Checking gbot...");
self.check_gbot(client).await?; self.check_gbot(client).await?;
trace!("[PROFILE] check_for_changes: check_gbot done");
let after_gbot = MemoryStats::current();
trace!("[DRIVE_MONITOR] After gbot, RSS={} (delta={})",
MemoryStats::format_bytes(after_gbot.rss_bytes),
MemoryStats::format_bytes(after_gbot.rss_bytes.saturating_sub(after_dialog.rss_bytes)));
trace!("[PROFILE] check_for_changes: calling check_gbkb_changes...");
trace!("[DRIVE_MONITOR] Checking gbkb...");
self.check_gbkb_changes(client).await?; self.check_gbkb_changes(client).await?;
trace!("[PROFILE] check_for_changes: check_gbkb_changes done");
let after_gbkb = MemoryStats::current();
trace!("[DRIVE_MONITOR] After gbkb, RSS={} (delta={})",
MemoryStats::format_bytes(after_gbkb.rss_bytes),
MemoryStats::format_bytes(after_gbkb.rss_bytes.saturating_sub(after_gbot.rss_bytes)));
log_jemalloc_stats();
let total_delta = after_gbkb.rss_bytes.saturating_sub(start_mem.rss_bytes);
if total_delta > 50 * 1024 * 1024 {
warn!("[DRIVE_MONITOR] check_for_changes grew by {} - potential leak!",
MemoryStats::format_bytes(total_delta));
}
trace!("[PROFILE] check_for_changes EXIT");
Ok(()) Ok(())
} }
async fn check_gbdialog_changes( async fn check_gbdialog_changes(
@ -188,6 +331,7 @@ impl DriveMonitor {
Ok(()) Ok(())
} }
async fn check_gbot(&self, client: &Client) -> Result<(), Box<dyn Error + Send + Sync>> { async fn check_gbot(&self, client: &Client) -> Result<(), Box<dyn Error + Send + Sync>> {
trace!("[PROFILE] check_gbot ENTER");
let config_manager = ConfigManager::new(self.state.conn.clone()); let config_manager = ConfigManager::new(self.state.conn.clone());
debug!("check_gbot: Checking bucket {} for config.csv changes", self.bucket_name); debug!("check_gbot: Checking bucket {} for config.csv changes", self.bucket_name);
let mut continuation_token = None; let mut continuation_token = None;
@ -333,6 +477,7 @@ impl DriveMonitor {
} }
continuation_token = list_objects.next_continuation_token; continuation_token = list_objects.next_continuation_token;
} }
trace!("[PROFILE] check_gbot EXIT");
Ok(()) Ok(())
} }
async fn broadcast_theme_change( async fn broadcast_theme_change(
@ -467,6 +612,7 @@ impl DriveMonitor {
&self, &self,
client: &Client, client: &Client,
) -> Result<(), Box<dyn Error + Send + Sync>> { ) -> Result<(), Box<dyn Error + Send + Sync>> {
trace!("[PROFILE] check_gbkb_changes ENTER");
let bot_name = self let bot_name = self
.bucket_name .bucket_name
.strip_suffix(".gbai") .strip_suffix(".gbai")
@ -510,6 +656,12 @@ impl DriveMonitor {
continue; continue;
} }
let size = obj.size().unwrap_or(0);
if size == 0 {
trace!("Skipping 0-byte file in .gbkb: {}", path);
continue;
}
let file_state = FileState { let file_state = FileState {
etag: obj.e_tag().unwrap_or_default().to_string(), etag: obj.e_tag().unwrap_or_default().to_string(),
}; };
@ -561,9 +713,6 @@ impl DriveMonitor {
} }
let path_parts: Vec<&str> = path.split('/').collect(); let path_parts: Vec<&str> = path.split('/').collect();
// path_parts: [0] = "bot.gbkb", [1] = folder or file, [2+] = nested files
// Skip files directly in .gbkb root (path_parts.len() == 2 means root file)
// Only process files inside subfolders (path_parts.len() >= 3)
if path_parts.len() >= 3 { if path_parts.len() >= 3 {
let kb_name = path_parts[1]; let kb_name = path_parts[1];
let kb_folder_path = self let kb_folder_path = self
@ -572,30 +721,59 @@ impl DriveMonitor {
.join(&gbkb_prefix) .join(&gbkb_prefix)
.join(kb_name); .join(kb_name);
info!( let kb_indexing_disabled = std::env::var("DISABLE_KB_INDEXING")
"Triggering KB indexing for folder: {} (PDF text extraction enabled)", .map(|v| v == "true" || v == "1")
kb_folder_path.display() .unwrap_or(false);
);
match self if kb_indexing_disabled {
.kb_manager debug!("KB indexing disabled via DISABLE_KB_INDEXING, skipping {}", kb_folder_path.display());
.handle_gbkb_change(bot_name, &kb_folder_path) continue;
.await
{
Ok(_) => {
debug!(
"Successfully processed KB change for {}/{}",
bot_name, kb_name
);
}
Err(e) => {
log::error!(
"Failed to process .gbkb change for {}/{}: {}",
bot_name,
kb_name,
e
);
}
} }
if !is_embedding_server_ready() {
info!("[DRIVE_MONITOR] Embedding server not ready, deferring KB indexing for {}", kb_folder_path.display());
continue;
}
let kb_manager = Arc::clone(&self.kb_manager);
let bot_name_owned = bot_name.to_string();
let kb_name_owned = kb_name.to_string();
let kb_folder_owned = kb_folder_path.clone();
tokio::spawn(async move {
info!(
"Triggering KB indexing for folder: {} (PDF text extraction enabled)",
kb_folder_owned.display()
);
match tokio::time::timeout(
Duration::from_secs(KB_INDEXING_TIMEOUT_SECS),
kb_manager.handle_gbkb_change(&bot_name_owned, &kb_folder_owned)
).await {
Ok(Ok(_)) => {
debug!(
"Successfully processed KB change for {}/{}",
bot_name_owned, kb_name_owned
);
}
Ok(Err(e)) => {
log::error!(
"Failed to process .gbkb change for {}/{}: {}",
bot_name_owned,
kb_name_owned,
e
);
}
Err(_) => {
log::error!(
"KB indexing timed out after {}s for {}/{}",
KB_INDEXING_TIMEOUT_SECS,
bot_name_owned,
kb_name_owned
);
}
}
});
} }
} }
} }
@ -640,6 +818,7 @@ impl DriveMonitor {
} }
} }
trace!("[PROFILE] check_gbkb_changes EXIT");
Ok(()) Ok(())
} }

View file

@ -8,7 +8,7 @@ use axum::{
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
@ -169,61 +169,82 @@ pub struct RestoreResponse {
} }
#[allow(unused)] #[allow(unused)]
#[derive(Debug, Serialize)]
pub struct BucketInfo {
pub name: String,
pub is_gbai: bool,
}
pub fn configure() -> Router<Arc<AppState>> { pub fn configure() -> Router<Arc<AppState>> {
Router::new() Router::new()
.route("/files/list", get(list_files)) .route("/api/files/buckets", get(list_buckets))
.route("/files/read", post(read_file)) .route("/api/files/list", get(list_files))
.route("/files/write", post(write_file)) .route("/api/files/read", post(read_file))
.route("/files/save", post(write_file)) .route("/api/files/write", post(write_file))
.route("/files/getContents", post(read_file)) .route("/api/files/save", post(write_file))
.route("/files/delete", post(delete_file)) .route("/api/files/getContents", post(read_file))
.route("/files/upload", post(upload_file_to_drive)) .route("/api/files/delete", post(delete_file))
.route("/files/download", post(download_file)) .route("/api/files/upload", post(upload_file_to_drive))
.route("/files/copy", post(copy_file)) .route("/api/files/download", post(download_file))
.route("/files/move", post(move_file)) .route("/api/files/copy", post(copy_file))
.route("/files/createFolder", post(create_folder)) .route("/api/files/move", post(move_file))
.route("/files/create-folder", post(create_folder)) .route("/api/files/createFolder", post(create_folder))
.route("/files/dirFolder", post(list_folder_contents)) .route("/api/files/create-folder", post(create_folder))
.route("/files/search", get(search_files)) .route("/api/files/dirFolder", post(list_folder_contents))
.route("/files/recent", get(recent_files)) .route("/api/files/search", get(search_files))
.route("/files/favorite", get(list_favorites)) .route("/api/files/recent", get(recent_files))
.route("/files/shareFolder", post(share_folder)) .route("/api/files/favorite", get(list_favorites))
.route("/files/shared", get(list_shared)) .route("/api/files/shareFolder", post(share_folder))
.route("/files/permissions", get(get_permissions)) .route("/api/files/shared", get(list_shared))
.route("/files/quota", get(get_quota)) .route("/api/files/permissions", get(get_permissions))
.route("/files/sync/status", get(sync_status)) .route("/api/files/quota", get(get_quota))
.route("/files/sync/start", post(start_sync)) .route("/api/files/sync/status", get(sync_status))
.route("/files/sync/stop", post(stop_sync)) .route("/api/files/sync/start", post(start_sync))
.route("/files/versions", get(list_versions)) .route("/api/files/sync/stop", post(stop_sync))
.route("/files/restore", post(restore_version)) .route("/api/files/versions", get(list_versions))
.route("/docs/merge", post(document_processing::merge_documents)) .route("/api/files/restore", post(restore_version))
.route("/docs/convert", post(document_processing::convert_document)) .route("/api/docs/merge", post(document_processing::merge_documents))
.route("/docs/fill", post(document_processing::fill_document)) .route("/api/docs/convert", post(document_processing::convert_document))
.route("/docs/export", post(document_processing::export_document)) .route("/api/docs/fill", post(document_processing::fill_document))
.route("/docs/import", post(document_processing::import_document)) .route("/api/docs/export", post(document_processing::export_document))
.route("/api/docs/import", post(document_processing::import_document))
}
pub async fn list_buckets(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<BucketInfo>>, (StatusCode, Json<serde_json::Value>)> {
let s3_client = state.drive.as_ref().ok_or_else(|| {
(
StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({"error": "S3 service not available"})),
)
})?;
let result = s3_client.list_buckets().send().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to list buckets: {}", e)})),
)
})?;
let buckets: Vec<BucketInfo> = result
.buckets()
.iter()
.filter_map(|b| {
b.name().map(|name| BucketInfo {
name: name.to_string(),
is_gbai: name.to_lowercase().ends_with(".gbai"),
})
})
.collect();
Ok(Json(buckets))
} }
pub async fn list_files( pub async fn list_files(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Query(params): Query<ListQuery>, Query(params): Query<ListQuery>,
) -> Result<Json<Vec<FileItem>>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<Vec<FileItem>>, (StatusCode, Json<serde_json::Value>)> {
#[cfg(feature = "console")]
let result = {
let mut tree = FileTree::new(state.clone());
if let Some(bucket) = &params.bucket {
if let Some(path) = &params.path {
tree.enter_folder(bucket.clone(), path.clone()).await.ok();
} else {
tree.enter_bucket(bucket.clone()).await.ok();
}
} else {
tree.load_root().await.ok();
}
Ok::<Vec<FileItem>, (StatusCode, Json<serde_json::Value>)>(vec![])
};
#[cfg(not(feature = "console"))]
let result: Result<Vec<FileItem>, (StatusCode, Json<serde_json::Value>)> = { let result: Result<Vec<FileItem>, (StatusCode, Json<serde_json::Value>)> = {
let s3_client = state.drive.as_ref().ok_or_else(|| { let s3_client = state.drive.as_ref().ok_or_else(|| {
( (
@ -402,33 +423,117 @@ pub async fn write_file(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<WriteRequest>, Json(req): Json<WriteRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<serde_json::Value>)> { ) -> Result<Json<SuccessResponse>, (StatusCode, Json<serde_json::Value>)> {
tracing::debug!(
"write_file called: bucket={}, path={}, content_len={}",
req.bucket,
req.path,
req.content.len()
);
let s3_client = state.drive.as_ref().ok_or_else(|| { let s3_client = state.drive.as_ref().ok_or_else(|| {
tracing::error!("S3 client not available for write_file");
( (
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
Json(serde_json::json!({ "error": "S3 service not available" })), Json(serde_json::json!({ "error": "S3 service not available" })),
) )
})?; })?;
// Try to decode as base64, otherwise use content directly
// Base64 content from file uploads won't have whitespace/newlines at start
// and will only contain valid base64 characters
let is_base64 = is_likely_base64(&req.content);
tracing::debug!("Content detected as base64: {}", is_base64);
let body_bytes: Vec<u8> = if is_base64 {
match BASE64.decode(&req.content) {
Ok(decoded) => {
tracing::debug!("Base64 decoded successfully, size: {} bytes", decoded.len());
decoded
}
Err(e) => {
tracing::warn!("Base64 decode failed ({}), using raw content", e);
req.content.clone().into_bytes()
}
}
} else {
req.content.into_bytes()
};
let sanitized_path = req.path
.replace("//", "/")
.trim_start_matches('/')
.to_string();
tracing::debug!("Writing {} bytes to {}/{}", body_bytes.len(), req.bucket, sanitized_path);
s3_client s3_client
.put_object() .put_object()
.bucket(&req.bucket) .bucket(&req.bucket)
.key(&req.path) .key(&sanitized_path)
.body(req.content.into_bytes().into()) .body(body_bytes.into())
.send() .send()
.await .await
.map_err(|e| { .map_err(|e| {
tracing::error!("S3 put_object failed: {:?}", e);
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": format!("Failed to write file: {}", e) })), Json(serde_json::json!({ "error": format!("Failed to write file: {}", e) })),
) )
})?; })?;
tracing::info!("File written successfully: {}/{}", req.bucket, sanitized_path);
Ok(Json(SuccessResponse { Ok(Json(SuccessResponse {
success: true, success: true,
message: Some("File written successfully".to_string()), message: Some("File written successfully".to_string()),
})) }))
} }
/// Check if a string is likely base64 encoded content (from file upload)
/// Base64 from DataURL will be pure base64 without newlines at start
fn is_likely_base64(s: &str) -> bool {
// Empty or very short strings are not base64 uploads
if s.len() < 20 {
return false;
}
// If it starts with common text patterns, it's not base64
let trimmed = s.trim_start();
if trimmed.starts_with('#') // Markdown, shell scripts
|| trimmed.starts_with("//") // Comments
|| trimmed.starts_with("/*") // C-style comments
|| trimmed.starts_with('{') // JSON
|| trimmed.starts_with('[') // JSON array
|| trimmed.starts_with('<') // XML/HTML
|| trimmed.starts_with("<!") // HTML doctype
|| trimmed.starts_with("function") // JavaScript
|| trimmed.starts_with("const ") // JavaScript
|| trimmed.starts_with("let ") // JavaScript
|| trimmed.starts_with("var ") // JavaScript
|| trimmed.starts_with("import ") // Various languages
|| trimmed.starts_with("from ") // Python
|| trimmed.starts_with("def ") // Python
|| trimmed.starts_with("class ") // Various languages
|| trimmed.starts_with("pub ") // Rust
|| trimmed.starts_with("use ") // Rust
|| trimmed.starts_with("mod ") // Rust
{
return false;
}
// Check if string contains only valid base64 characters
// and try to decode it
let base64_chars = s.chars().all(|c| {
c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='
});
if !base64_chars {
return false;
}
// Final check: try to decode and see if it works
BASE64.decode(s).is_ok()
}
pub async fn delete_file( pub async fn delete_file(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Json(req): Json<DeleteRequest>, Json(req): Json<DeleteRequest>,

View file

@ -3,10 +3,13 @@ use futures::StreamExt;
use log::{info, trace, warn}; use log::{info, trace, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use super::{llm_models::get_handler, LLMProvider}; use super::{llm_models::get_handler, LLMProvider};
const LLM_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeMessage { pub struct ClaudeMessage {
pub role: String, pub role: String,
@ -74,8 +77,14 @@ impl ClaudeClient {
pub fn new(base_url: String, deployment_name: Option<String>) -> Self { pub fn new(base_url: String, deployment_name: Option<String>) -> Self {
let is_azure = base_url.contains("azure.com") || base_url.contains("openai.azure.com"); let is_azure = base_url.contains("azure.com") || base_url.contains("openai.azure.com");
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(LLM_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { Self {
client: reqwest::Client::new(), client,
base_url, base_url,
deployment_name: deployment_name.unwrap_or_else(|| "claude-opus-4-5".to_string()), deployment_name: deployment_name.unwrap_or_else(|| "claude-opus-4-5".to_string()),
is_azure, is_azure,
@ -83,8 +92,14 @@ impl ClaudeClient {
} }
pub fn azure(endpoint: String, deployment_name: String) -> Self { pub fn azure(endpoint: String, deployment_name: String) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(LLM_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(30))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { Self {
client: reqwest::Client::new(), client,
base_url: endpoint, base_url: endpoint,
deployment_name, deployment_name,
is_azure: true, is_azure: true,
@ -93,7 +108,6 @@ impl ClaudeClient {
fn build_url(&self) -> String { fn build_url(&self) -> String {
if self.is_azure { if self.is_azure {
// Azure Claude exposes Anthropic API directly at /v1/messages
format!("{}/v1/messages", self.base_url.trim_end_matches('/')) format!("{}/v1/messages", self.base_url.trim_end_matches('/'))
} else { } else {
format!("{}/v1/messages", self.base_url.trim_end_matches('/')) format!("{}/v1/messages", self.base_url.trim_end_matches('/'))
@ -103,8 +117,6 @@ impl ClaudeClient {
fn build_headers(&self, api_key: &str) -> reqwest::header::HeaderMap { fn build_headers(&self, api_key: &str) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new(); let mut headers = reqwest::header::HeaderMap::new();
// Both Azure Claude and direct Anthropic use the same headers
// Azure Claude proxies the Anthropic API format
if let Ok(val) = api_key.parse() { if let Ok(val) = api_key.parse() {
headers.insert("x-api-key", val); headers.insert("x-api-key", val);
} }
@ -119,17 +131,12 @@ impl ClaudeClient {
headers headers
} }
/// Normalize role names for Claude API compatibility.
/// Claude only accepts "user" or "assistant" roles in messages.
/// - "episodic" and "compact" roles (conversation summaries) are converted to "user" with a context prefix
/// - "system" roles should be handled separately (not in messages array)
/// - Unknown roles default to "user"
fn normalize_role(role: &str) -> Option<(String, bool)> { fn normalize_role(role: &str) -> Option<(String, bool)> {
match role { match role {
"user" => Some(("user".to_string(), false)), "user" => Some(("user".to_string(), false)),
"assistant" => Some(("assistant".to_string(), false)), "assistant" => Some(("assistant".to_string(), false)),
"system" => None, // System messages handled separately "system" => None,
"episodic" | "compact" => Some(("user".to_string(), true)), // Mark as context "episodic" | "compact" => Some(("user".to_string(), true)),
_ => Some(("user".to_string(), false)), _ => Some(("user".to_string(), false)),
} }
} }
@ -148,10 +155,9 @@ impl ClaudeClient {
system_parts.push(context_data.to_string()); system_parts.push(context_data.to_string());
} }
// Extract episodic memory content and add to system prompt
for (role, content) in history { for (role, content) in history {
if role == "episodic" || role == "compact" { if role == "episodic" || role == "compact" {
system_parts.push(format!("[Previous conversation summary]: {}", content)); system_parts.push(format!("[Previous conversation summary]: {content}"));
} }
} }
@ -171,7 +177,7 @@ impl ClaudeClient {
content: content.clone(), content: content.clone(),
}) })
} }
_ => None, // Skip system, episodic, compact (already in system prompt) _ => None,
} }
}) })
.collect(); .collect();
@ -221,7 +227,6 @@ impl LLMProvider for ClaudeClient {
.filter_map(|m| { .filter_map(|m| {
let role = m["role"].as_str().unwrap_or("user"); let role = m["role"].as_str().unwrap_or("user");
let content = m["content"].as_str().unwrap_or(""); let content = m["content"].as_str().unwrap_or("");
// Skip system messages (handled separately), episodic/compact (context), and empty content
if role == "system" || role == "episodic" || role == "compact" || content.is_empty() { if role == "system" || role == "episodic" || role == "compact" || content.is_empty() {
None None
} else { } else {
@ -244,7 +249,6 @@ impl LLMProvider for ClaudeClient {
}] }]
}; };
// Ensure at least one user message exists
if claude_messages.is_empty() && !prompt.is_empty() { if claude_messages.is_empty() && !prompt.is_empty() {
claude_messages.push(ClaudeMessage { claude_messages.push(ClaudeMessage {
role: "user".to_string(), role: "user".to_string(),
@ -268,7 +272,6 @@ impl LLMProvider for ClaudeClient {
let system = system_prompt.filter(|s| !s.is_empty()); let system = system_prompt.filter(|s| !s.is_empty());
// Validate we have at least one message with content
if claude_messages.is_empty() { if claude_messages.is_empty() {
return Err("Cannot send request to Claude: no messages with content".into()); return Err("Cannot send request to Claude: no messages with content".into());
} }
@ -281,26 +284,58 @@ impl LLMProvider for ClaudeClient {
stream: None, stream: None,
}; };
info!("Claude request to {}: model={}", url, model_name); info!("Claude request to {url}: model={model_name}");
trace!("Claude request body: {:?}", serde_json::to_string(&request)); trace!("Claude request body: {:?}", serde_json::to_string(&request));
let response = self let start_time = std::time::Instant::now();
let response = match self
.client .client
.post(&url) .post(&url)
.headers(headers) .headers(headers)
.json(&request) .json(&request)
.send() .send()
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
let elapsed = start_time.elapsed();
if e.is_timeout() {
warn!("Claude request timed out after {elapsed:?} (limit: {LLM_TIMEOUT_SECS}s)");
return Err(format!("Claude request timed out after {LLM_TIMEOUT_SECS}s").into());
}
warn!("Claude request failed after {elapsed:?}: {e}");
return Err(e.into());
}
};
let elapsed = start_time.elapsed();
let status = response.status(); let status = response.status();
if !status.is_success() { if !status.is_success() {
let error_text = response.text().await.unwrap_or_default(); let error_text = response.text().await.unwrap_or_default();
warn!("Claude API error ({}): {}", status, error_text); warn!("Claude API error ({status}) after {elapsed:?}: {error_text}");
return Err(format!("Claude API error ({}): {}", status, error_text).into()); return Err(format!("Claude API error ({status}): {error_text}").into());
} }
let result: ClaudeResponse = response.json().await?; info!("Claude response received in {elapsed:?}, status={status}");
let result: ClaudeResponse = match response.json().await {
Ok(r) => r,
Err(e) => {
warn!("Failed to parse Claude response: {e}");
return Err(format!("Failed to parse Claude response: {e}").into());
}
};
let raw_content = self.extract_text_from_response(&result); let raw_content = self.extract_text_from_response(&result);
let content_len = raw_content.len();
info!(
"Claude response parsed: id={}, stop_reason={:?}, content_length={content_len}",
result.id,
result.stop_reason
);
let handler = get_handler(model_name); let handler = get_handler(model_name);
let content = handler.process_content(&raw_content); let content = handler.process_content(&raw_content);
@ -338,7 +373,6 @@ impl LLMProvider for ClaudeClient {
.filter_map(|m| { .filter_map(|m| {
let role = m["role"].as_str().unwrap_or("user"); let role = m["role"].as_str().unwrap_or("user");
let content = m["content"].as_str().unwrap_or(""); let content = m["content"].as_str().unwrap_or("");
// Skip system messages (handled separately), episodic/compact (context), and empty content
if role == "system" || role == "episodic" || role == "compact" || content.is_empty() { if role == "system" || role == "episodic" || role == "compact" || content.is_empty() {
None None
} else { } else {
@ -361,7 +395,6 @@ impl LLMProvider for ClaudeClient {
}] }]
}; };
// Ensure at least one user message exists
if claude_messages.is_empty() && !prompt.is_empty() { if claude_messages.is_empty() && !prompt.is_empty() {
claude_messages.push(ClaudeMessage { claude_messages.push(ClaudeMessage {
role: "user".to_string(), role: "user".to_string(),
@ -385,7 +418,6 @@ impl LLMProvider for ClaudeClient {
let system = system_prompt.filter(|s| !s.is_empty()); let system = system_prompt.filter(|s| !s.is_empty());
// Validate we have at least one message with content
if claude_messages.is_empty() { if claude_messages.is_empty() {
return Err("Cannot send streaming request to Claude: no messages with content".into()); return Err("Cannot send streaming request to Claude: no messages with content".into());
} }
@ -398,25 +430,42 @@ impl LLMProvider for ClaudeClient {
stream: Some(true), stream: Some(true),
}; };
info!("Claude streaming request to {}: model={}", url, model_name); info!("Claude streaming request to {url}: model={model_name}");
let response = self let start_time = std::time::Instant::now();
let response = match self
.client .client
.post(&url) .post(&url)
.headers(headers) .headers(headers)
.json(&request) .json(&request)
.send() .send()
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
let elapsed = start_time.elapsed();
if e.is_timeout() {
warn!("Claude streaming request timed out after {elapsed:?}");
return Err(format!("Claude streaming request timed out after {LLM_TIMEOUT_SECS}s").into());
}
warn!("Claude streaming request failed after {elapsed:?}: {e}");
return Err(e.into());
}
};
let status = response.status(); let status = response.status();
if !status.is_success() { if !status.is_success() {
let error_text = response.text().await.unwrap_or_default(); let error_text = response.text().await.unwrap_or_default();
warn!("Claude streaming API error ({}): {}", status, error_text); warn!("Claude streaming API error ({status}): {error_text}");
return Err(format!("Claude streaming API error ({}): {}", status, error_text).into()); return Err(format!("Claude streaming API error ({status}): {error_text}").into());
} }
info!("Claude streaming connection established in {:?}", start_time.elapsed());
let handler = get_handler(model_name); let handler = get_handler(model_name);
let mut stream = response.bytes_stream(); let mut stream = response.bytes_stream();
let mut total_chunks = 0;
while let Some(chunk) = stream.next().await { while let Some(chunk) = stream.next().await {
let chunk = chunk?; let chunk = chunk?;
@ -439,6 +488,7 @@ impl LLMProvider for ClaudeClient {
let processed = handler.process_content(&delta.text); let processed = handler.process_content(&delta.text);
if !processed.is_empty() { if !processed.is_empty() {
let _ = tx.send(processed).await; let _ = tx.send(processed).await;
total_chunks += 1;
} }
} }
} }
@ -448,6 +498,12 @@ impl LLMProvider for ClaudeClient {
} }
} }
info!(
"Claude streaming completed in {:?}, chunks={}",
start_time.elapsed(),
total_chunks
);
Ok(()) Ok(())
} }
@ -490,7 +546,6 @@ mod tests {
"claude-opus-4-5".to_string(), "claude-opus-4-5".to_string(),
); );
let url = client.build_url(); let url = client.build_url();
// Azure Claude uses Anthropic API format directly
assert!(url.contains("/v1/messages")); assert!(url.contains("/v1/messages"));
} }

View file

@ -1,8 +1,10 @@
use crate::config::ConfigManager; use crate::config::ConfigManager;
use crate::core::kb::embedding_generator::set_embedding_server_ready;
use crate::core::shared::memory_monitor::{log_jemalloc_stats, MemoryStats};
use crate::shared::models::schema::bots::dsl::*; use crate::shared::models::schema::bots::dsl::*;
use crate::shared::state::AppState; use crate::shared::state::AppState;
use diesel::prelude::*; use diesel::prelude::*;
use log::{error, info, warn}; use log::{error, info, trace, warn};
use reqwest; use reqwest;
use std::fmt::Write; use std::fmt::Write;
use std::sync::Arc; use std::sync::Arc;
@ -11,7 +13,14 @@ use tokio;
pub async fn ensure_llama_servers_running( pub async fn ensure_llama_servers_running(
app_state: Arc<AppState>, app_state: Arc<AppState>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
trace!("[PROFILE] ensure_llama_servers_running ENTER");
let start_mem = MemoryStats::current();
trace!("[LLM_LOCAL] ensure_llama_servers_running START, RSS={}",
MemoryStats::format_bytes(start_mem.rss_bytes));
log_jemalloc_stats();
if std::env::var("SKIP_LLM_SERVER").is_ok() { if std::env::var("SKIP_LLM_SERVER").is_ok() {
trace!("[PROFILE] SKIP_LLM_SERVER set, returning early");
info!("SKIP_LLM_SERVER set - skipping local LLM server startup (using mock/external LLM)"); info!("SKIP_LLM_SERVER set - skipping local LLM server startup (using mock/external LLM)");
return Ok(()); return Ok(());
} }
@ -74,6 +83,9 @@ pub async fn ensure_llama_servers_running(
info!(" Embedding Model: {embedding_model}"); info!(" Embedding Model: {embedding_model}");
info!(" LLM Server Path: {llm_server_path}"); info!(" LLM Server Path: {llm_server_path}");
info!("Restarting any existing llama-server processes..."); info!("Restarting any existing llama-server processes...");
trace!("[PROFILE] About to pkill llama-server...");
let before_pkill = MemoryStats::current();
trace!("[LLM_LOCAL] Before pkill, RSS={}", MemoryStats::format_bytes(before_pkill.rss_bytes));
if let Err(e) = tokio::process::Command::new("sh") if let Err(e) = tokio::process::Command::new("sh")
.arg("-c") .arg("-c")
@ -85,6 +97,12 @@ pub async fn ensure_llama_servers_running(
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
info!("Existing llama-server processes terminated (if any)"); info!("Existing llama-server processes terminated (if any)");
} }
trace!("[PROFILE] pkill done");
let after_pkill = MemoryStats::current();
trace!("[LLM_LOCAL] After pkill, RSS={} (delta={})",
MemoryStats::format_bytes(after_pkill.rss_bytes),
MemoryStats::format_bytes(after_pkill.rss_bytes.saturating_sub(before_pkill.rss_bytes)));
let llm_running = if llm_url.starts_with("https://") { let llm_running = if llm_url.starts_with("https://") {
info!("Using external HTTPS LLM server, skipping local startup"); info!("Using external HTTPS LLM server, skipping local startup");
@ -135,13 +153,27 @@ pub async fn ensure_llama_servers_running(
task.await??; task.await??;
} }
info!("Waiting for servers to become ready..."); info!("Waiting for servers to become ready...");
trace!("[PROFILE] Starting wait loop for servers...");
let before_wait = MemoryStats::current();
trace!("[LLM_LOCAL] Before wait loop, RSS={}", MemoryStats::format_bytes(before_wait.rss_bytes));
let mut llm_ready = llm_running || llm_model.is_empty(); let mut llm_ready = llm_running || llm_model.is_empty();
let mut embedding_ready = embedding_running || embedding_model.is_empty(); let mut embedding_ready = embedding_running || embedding_model.is_empty();
let mut attempts = 0; let mut attempts = 0;
let max_attempts = 120; let max_attempts = 120;
while attempts < max_attempts && (!llm_ready || !embedding_ready) { while attempts < max_attempts && (!llm_ready || !embedding_ready) {
trace!("[PROFILE] Wait loop iteration {}", attempts);
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
if attempts % 5 == 0 {
let loop_mem = MemoryStats::current();
trace!("[LLM_LOCAL] Wait loop attempt {}, RSS={} (delta from start={})",
attempts,
MemoryStats::format_bytes(loop_mem.rss_bytes),
MemoryStats::format_bytes(loop_mem.rss_bytes.saturating_sub(before_wait.rss_bytes)));
log_jemalloc_stats();
}
if attempts % 5 == 0 { if attempts % 5 == 0 {
info!( info!(
"Checking server health (attempt {}/{max_attempts})...", "Checking server health (attempt {}/{max_attempts})...",
@ -160,6 +192,7 @@ pub async fn ensure_llama_servers_running(
if is_server_running(&embedding_url).await { if is_server_running(&embedding_url).await {
info!("Embedding server ready at {embedding_url}"); info!("Embedding server ready at {embedding_url}");
embedding_ready = true; embedding_ready = true;
set_embedding_server_ready(true);
} else if attempts % 10 == 0 { } else if attempts % 10 == 0 {
warn!("Embedding server not ready yet at {embedding_url}"); warn!("Embedding server not ready yet at {embedding_url}");
@ -185,11 +218,29 @@ pub async fn ensure_llama_servers_running(
} }
if llm_ready && embedding_ready { if llm_ready && embedding_ready {
info!("All llama.cpp servers are ready and responding!"); info!("All llama.cpp servers are ready and responding!");
if !embedding_model.is_empty() {
set_embedding_server_ready(true);
}
trace!("[PROFILE] Servers ready!");
let after_ready = MemoryStats::current();
trace!("[LLM_LOCAL] Servers ready, RSS={} (delta from start={})",
MemoryStats::format_bytes(after_ready.rss_bytes),
MemoryStats::format_bytes(after_ready.rss_bytes.saturating_sub(start_mem.rss_bytes)));
log_jemalloc_stats();
let _llm_provider1 = Arc::new(crate::llm::OpenAIClient::new( let _llm_provider1 = Arc::new(crate::llm::OpenAIClient::new(
llm_model.clone(), llm_model.clone(),
Some(llm_url.clone()), Some(llm_url.clone()),
)); ));
let end_mem = MemoryStats::current();
trace!("[LLM_LOCAL] ensure_llama_servers_running END, RSS={} (total delta={})",
MemoryStats::format_bytes(end_mem.rss_bytes),
MemoryStats::format_bytes(end_mem.rss_bytes.saturating_sub(start_mem.rss_bytes)));
log_jemalloc_stats();
trace!("[PROFILE] ensure_llama_servers_running EXIT OK");
Ok(()) Ok(())
} else { } else {
let mut error_msg = "Servers failed to start within timeout:".to_string(); let mut error_msg = "Servers failed to start within timeout:".to_string();

View file

@ -1,3 +1,11 @@
// Use jemalloc as the global allocator when the feature is enabled
#[cfg(feature = "jemalloc")]
use tikv_jemallocator::Jemalloc;
#[cfg(feature = "jemalloc")]
#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::middleware; use axum::middleware;
@ -25,6 +33,10 @@ use botlib::SystemLimits;
use botserver::core; use botserver::core;
use botserver::shared; use botserver::shared;
use botserver::core::shared::memory_monitor::{
start_memory_monitor, log_process_memory, MemoryStats,
register_thread, record_thread_activity
};
use botserver::core::automation; use botserver::core::automation;
use botserver::core::bootstrap; use botserver::core::bootstrap;
@ -105,8 +117,7 @@ async fn health_check_simple() -> (StatusCode, Json<serde_json::Value>) {
fn print_shutdown_message() { fn print_shutdown_message() {
println!(); println!();
println!("\x1b[33m✨ Thank you for using General Bots!\x1b[0m"); println!("Thank you for using General Bots!");
println!("\x1b[36m pragmatismo.com.br\x1b[0m");
println!(); println!();
} }
@ -819,6 +830,10 @@ async fn main() -> std::io::Result<()> {
botserver::core::shared::state::AttendantNotification, botserver::core::shared::state::AttendantNotification,
>(1000); >(1000);
let (task_progress_tx, _task_progress_rx) = tokio::sync::broadcast::channel::<
botserver::core::shared::state::TaskProgressEvent,
>(1000);
let app_state = Arc::new(AppState { let app_state = Arc::new(AppState {
drive: Some(drive.clone()), drive: Some(drive.clone()),
s3_client: Some(drive), s3_client: Some(drive),
@ -852,6 +867,7 @@ async fn main() -> std::io::Result<()> {
ext ext
}, },
attendant_broadcast: Some(attendant_tx), attendant_broadcast: Some(attendant_tx),
task_progress_broadcast: Some(task_progress_tx),
}); });
let task_scheduler = Arc::new(botserver::tasks::scheduler::TaskScheduler::new( let task_scheduler = Arc::new(botserver::tasks::scheduler::TaskScheduler::new(
@ -864,6 +880,11 @@ async fn main() -> std::io::Result<()> {
log::warn!("Failed to start website crawler service: {}", e); log::warn!("Failed to start website crawler service: {}", e);
} }
// Start memory monitoring - check every 30 seconds, warn if growth > 50MB
start_memory_monitor(30, 50);
info!("Memory monitor started");
log_process_memory();
let _ = state_tx.try_send(app_state.clone()); let _ = state_tx.try_send(app_state.clone());
progress_tx.send(BootstrapProgress::BootstrapComplete).ok(); progress_tx.send(BootstrapProgress::BootstrapComplete).ok();
@ -876,10 +897,6 @@ async fn main() -> std::io::Result<()> {
.map(|n| n.get()) .map(|n| n.get())
.unwrap_or(4); .unwrap_or(4);
let _automation_service =
botserver::core::automation::AutomationService::new(app_state.clone());
info!("Automation service initialized with episodic memory scheduler");
let bot_orchestrator = BotOrchestrator::new(app_state.clone()); let bot_orchestrator = BotOrchestrator::new(app_state.clone());
if let Err(e) = bot_orchestrator.mount_all_bots() { if let Err(e) = bot_orchestrator.mount_all_bots() {
error!("Failed to mount bots: {}", e); error!("Failed to mount bots: {}", e);
@ -891,37 +908,57 @@ async fn main() -> std::io::Result<()> {
let bucket_name = "default.gbai".to_string(); let bucket_name = "default.gbai".to_string();
let monitor_bot_id = default_bot_id; let monitor_bot_id = default_bot_id;
tokio::spawn(async move { tokio::spawn(async move {
register_thread("drive-monitor", "drive");
trace!("[PROFILE] DriveMonitor::new starting...");
let monitor = botserver::DriveMonitor::new( let monitor = botserver::DriveMonitor::new(
drive_monitor_state, drive_monitor_state,
bucket_name.clone(), bucket_name.clone(),
monitor_bot_id, monitor_bot_id,
); );
trace!("[PROFILE] DriveMonitor::new done, calling start_monitoring...");
info!("Starting DriveMonitor for bucket: {}", bucket_name); info!("Starting DriveMonitor for bucket: {}", bucket_name);
if let Err(e) = monitor.start_monitoring().await { if let Err(e) = monitor.start_monitoring().await {
error!("DriveMonitor failed: {}", e); error!("DriveMonitor failed: {}", e);
} }
trace!("[PROFILE] DriveMonitor start_monitoring returned");
}); });
} }
let automation_state = app_state.clone(); let automation_state = app_state.clone();
tokio::spawn(async move { tokio::spawn(async move {
register_thread("automation-service", "automation");
let automation = AutomationService::new(automation_state); let automation = AutomationService::new(automation_state);
automation.spawn().await.ok(); trace!("[TASK] AutomationService starting, RSS={}",
MemoryStats::format_bytes(MemoryStats::current().rss_bytes));
loop {
record_thread_activity("automation-service");
if let Err(e) = automation.check_scheduled_tasks().await {
error!("Error checking scheduled tasks: {}", e);
}
tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
}
}); });
let app_state_for_llm = app_state.clone(); let app_state_for_llm = app_state.clone();
tokio::spawn(async move { tokio::spawn(async move {
register_thread("llm-server-init", "llm");
eprintln!("[PROFILE] ensure_llama_servers_running starting...");
if let Err(e) = ensure_llama_servers_running(app_state_for_llm).await { if let Err(e) = ensure_llama_servers_running(app_state_for_llm).await {
error!("Failed to start LLM servers: {}", e); error!("Failed to start LLM servers: {}", e);
} }
eprintln!("[PROFILE] ensure_llama_servers_running completed");
record_thread_activity("llm-server-init");
}); });
trace!("Initial data setup task spawned"); trace!("Initial data setup task spawned");
eprintln!("[PROFILE] All background tasks spawned, starting HTTP server...");
trace!("Starting HTTP server on port {}...", config.server.port); trace!("Starting HTTP server on port {}...", config.server.port);
eprintln!("[PROFILE] run_axum_server starting on port {}...", config.server.port);
if let Err(e) = run_axum_server(app_state, config.server.port, worker_count).await { if let Err(e) = run_axum_server(app_state, config.server.port, worker_count).await {
error!("Failed to start HTTP server: {}", e); error!("Failed to start HTTP server: {}", e);
std::process::exit(1); std::process::exit(1);
} }
eprintln!("[PROFILE] run_axum_server returned (should not happen normally)");
if let Some(handle) = ui_handle { if let Some(handle) = ui_handle {
handle.join().ok(); handle.join().ok();

View file

@ -95,10 +95,10 @@ impl TlsIntegration {
services.insert( services.insert(
"minio".to_string(), "minio".to_string(),
ServiceUrls { ServiceUrls {
original: "http://localhost:9000".to_string(), original: "https://localhost:9000".to_string(),
secure: "https://localhost:9001".to_string(), secure: "https://localhost:9000".to_string(),
port: 9000, port: 9000,
tls_port: 9001, tls_port: 9000,
}, },
); );

View file

@ -351,15 +351,108 @@ pub async fn handle_task_delete(
pub async fn handle_task_get( pub async fn handle_task_get(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>, Path(id): Path<String>,
) -> Result<Json<TaskResponse>, StatusCode> { ) -> impl IntoResponse {
let task_engine = &state.task_engine; let conn = state.conn.clone();
let task_id = id.clone();
match task_engine.get_task(id).await { let result = tokio::task::spawn_blocking(move || {
Ok(task) => Ok(Json(task.into())), let mut db_conn = conn
.get()
.map_err(|e| format!("DB connection error: {}", e))?;
#[derive(Debug, QueryableByName, serde::Serialize)]
struct AutoTaskRow {
#[diesel(sql_type = diesel::sql_types::Uuid)]
pub id: Uuid,
#[diesel(sql_type = diesel::sql_types::Text)]
pub title: String,
#[diesel(sql_type = diesel::sql_types::Text)]
pub status: String,
#[diesel(sql_type = diesel::sql_types::Text)]
pub priority: String,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
pub intent: Option<String>,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
pub error: Option<String>,
#[diesel(sql_type = diesel::sql_types::Float)]
pub progress: f32,
#[diesel(sql_type = diesel::sql_types::Timestamptz)]
pub created_at: chrono::DateTime<chrono::Utc>,
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Timestamptz>)]
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
}
let parsed_uuid = match Uuid::parse_str(&task_id) {
Ok(u) => u,
Err(_) => return Err(format!("Invalid task ID: {}", task_id)),
};
let task: Option<AutoTaskRow> = diesel::sql_query(
"SELECT id, title, status, priority, intent, error, progress, created_at, completed_at
FROM auto_tasks WHERE id = $1 LIMIT 1"
)
.bind::<diesel::sql_types::Uuid, _>(parsed_uuid)
.get_result(&mut db_conn)
.ok();
Ok::<_, String>(task)
})
.await
.unwrap_or_else(|e| {
log::error!("Task query failed: {}", e);
Err(format!("Task query failed: {}", e))
});
match result {
Ok(Some(task)) => {
let status_class = match task.status.as_str() {
"completed" | "done" => "completed",
"running" | "pending" => "running",
"failed" | "error" => "error",
_ => "pending"
};
let progress_percent = (task.progress * 100.0) as u8;
let created = task.created_at.format("%Y-%m-%d %H:%M").to_string();
let completed = task.completed_at.map(|d| d.format("%Y-%m-%d %H:%M").to_string()).unwrap_or_default();
let html = format!(r#"
<div class="task-detail-header">
<h2 class="task-detail-title">{}</h2>
<span class="task-status task-status-{}">{}</span>
</div>
<div class="task-detail-meta">
<div class="meta-item"><span class="meta-label">Priority:</span> <span class="meta-value">{}</span></div>
<div class="meta-item"><span class="meta-label">Created:</span> <span class="meta-value">{}</span></div>
{}
</div>
<div class="task-detail-progress">
<div class="progress-label">Progress: {}%</div>
<div class="progress-bar-container">
<div class="progress-bar-fill" style="width: {}%"></div>
</div>
</div>
{}
{}
"#,
task.title,
status_class,
task.status,
task.priority,
created,
if !completed.is_empty() { format!(r#"<div class="meta-item"><span class="meta-label">Completed:</span> <span class="meta-value">{}</span></div>"#, completed) } else { String::new() },
progress_percent,
progress_percent,
task.intent.map(|i| format!(r#"<div class="task-detail-section"><h3>Intent</h3><p class="intent-text">{}</p></div>"#, i)).unwrap_or_default(),
task.error.map(|e| format!(r#"<div class="task-detail-section error-section"><h3>Error</h3><p class="error-text">{}</p></div>"#, e)).unwrap_or_default()
);
(StatusCode::OK, axum::response::Html(html)).into_response()
}
Ok(None) => {
(StatusCode::NOT_FOUND, axum::response::Html("<div class='error'>Task not found</div>".to_string())).into_response()
}
Err(e) => { Err(e) => {
log::error!("Failed to get task: {}", e); (StatusCode::INTERNAL_SERVER_ERROR, axum::response::Html(format!("<div class='error'>{}</div>", e))).into_response()
Err(StatusCode::NOT_FOUND)
} }
} }
} }
@ -1209,6 +1302,7 @@ pub fn configure_task_routes() -> Router<Arc<AppState>> {
) )
.route("/api/tasks/stats", get(handle_task_stats_htmx)) .route("/api/tasks/stats", get(handle_task_stats_htmx))
.route("/api/tasks/stats/json", get(handle_task_stats)) .route("/api/tasks/stats/json", get(handle_task_stats))
.route("/api/tasks/time-saved", get(handle_time_saved))
.route("/api/tasks/completed", delete(handle_clear_completed)) .route("/api/tasks/completed", delete(handle_clear_completed))
.route( .route(
&ApiUrls::TASK_BY_ID.replace(":id", "{id}"), &ApiUrls::TASK_BY_ID.replace(":id", "{id}"),
@ -1270,12 +1364,15 @@ pub async fn handle_task_list_htmx(
.map_err(|e| format!("DB connection error: {}", e))?; .map_err(|e| format!("DB connection error: {}", e))?;
let mut query = String::from( let mut query = String::from(
"SELECT id, title, status, priority, description, due_date FROM tasks WHERE 1=1", "SELECT id, title, status, priority, intent as description, NULL::timestamp as due_date FROM auto_tasks WHERE 1=1",
); );
match filter_clone.as_str() { match filter_clone.as_str() {
"active" => query.push_str(" AND status NOT IN ('done', 'completed', 'cancelled')"), "complete" | "completed" => query.push_str(" AND status IN ('done', 'completed')"),
"completed" => query.push_str(" AND status IN ('done', 'completed')"), "active" => query.push_str(" AND status IN ('running', 'pending', 'in_progress')"),
"awaiting" => query.push_str(" AND status IN ('awaiting_decision', 'awaiting', 'waiting')"),
"paused" => query.push_str(" AND status = 'paused'"),
"blocked" => query.push_str(" AND status IN ('blocked', 'failed', 'error')"),
"priority" => query.push_str(" AND priority IN ('high', 'urgent')"), "priority" => query.push_str(" AND priority IN ('high', 'urgent')"),
_ => {} _ => {}
} }
@ -1297,15 +1394,8 @@ pub async fn handle_task_list_htmx(
for task in tasks { for task in tasks {
let is_completed = task.status == "done" || task.status == "completed"; let is_completed = task.status == "done" || task.status == "completed";
let is_high_priority = task.priority == "high" || task.priority == "urgent";
let completed_class = if is_completed { "completed" } else { "" }; let completed_class = if is_completed { "completed" } else { "" };
let priority_class = if is_high_priority { "active" } else { "" };
let checked = if is_completed { "checked" } else { "" };
let status_html = format!(
r#"<span class="task-status task-status-{}">{}</span>"#,
task.status, task.status
);
let due_date_html = if let Some(due) = &task.due_date { let due_date_html = if let Some(due) = &task.due_date {
format!( format!(
r#"<span class="task-due-date"> {}</span>"#, r#"<span class="task-due-date"> {}</span>"#,
@ -1314,52 +1404,47 @@ pub async fn handle_task_list_htmx(
} else { } else {
String::new() String::new()
}; };
let status_class = match task.status.as_str() {
"completed" | "done" => "status-complete",
"running" | "pending" | "in_progress" => "status-running",
"failed" | "error" | "blocked" => "status-error",
"paused" => "status-paused",
"awaiting" | "awaiting_decision" => "status-awaiting",
_ => "status-pending"
};
let _ = write!( let _ = write!(
html, html,
r#" r#"
<div class="task-item {completed_class}" data-task-id="{}" onclick="selectTask('{}')"> <div class="task-card {completed_class} {status_class}" data-task-id="{}" onclick="selectTask('{}')">
<input type="checkbox" <div class="task-card-header">
class="task-checkbox" <span class="task-card-title">{}</span>
data-task-id="{}" <span class="task-card-status {}">{}</span>
{checked}
onclick="event.stopPropagation()">
<div class="task-content">
<div class="task-text-wrapper">
<span class="task-text">{}</span>
<div class="task-meta">
{status_html}
<span class="task-priority task-priority-{}">{}</span>
{due_date_html}
</div>
</div>
</div> </div>
<div class="task-actions"> <div class="task-card-body">
<button class="action-btn priority-btn {priority_class}" <div class="task-card-priority">
data-action="priority" <span class="priority-badge priority-{}">{}</span>
data-task-id="{}"> </div>
{due_date_html}
</div>
<div class="task-card-footer">
<button class="task-action-btn" data-action="priority" data-task-id="{}" onclick="event.stopPropagation()">
</button> </button>
<button class="action-btn edit-btn" <button class="task-action-btn" data-action="delete" data-task-id="{}" onclick="event.stopPropagation()">
data-action="edit" 🗑
data-task-id="{}">
</button>
<button class="action-btn delete-btn"
data-action="delete"
data-task-id="{}">
</button> </button>
</div> </div>
</div> </div>
"#, "#,
task.id, task.id,
task.id, task.id,
task.id,
task.title, task.title,
status_class,
task.status,
task.priority, task.priority,
task.priority, task.priority,
task.id, task.id,
task.id,
task.id task.id
); );
} }
@ -1396,34 +1481,58 @@ pub async fn handle_task_stats_htmx(State(state): State<Arc<AppState>>) -> impl
.get() .get()
.map_err(|e| format!("DB connection error: {}", e))?; .map_err(|e| format!("DB connection error: {}", e))?;
let total: i64 = diesel::sql_query("SELECT COUNT(*) as count FROM tasks") let total: i64 = diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks")
.get_result::<CountResult>(&mut db_conn) .get_result::<CountResult>(&mut db_conn)
.map(|r| r.count) .map(|r| r.count)
.unwrap_or(0); .unwrap_or(0);
let active: i64 = let active: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM tasks WHERE status NOT IN ('done', 'completed', 'cancelled')") diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status IN ('running', 'pending', 'in_progress')")
.get_result::<CountResult>(&mut db_conn) .get_result::<CountResult>(&mut db_conn)
.map(|r| r.count) .map(|r| r.count)
.unwrap_or(0); .unwrap_or(0);
let completed: i64 = let completed: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM tasks WHERE status IN ('done', 'completed')") diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status IN ('done', 'completed')")
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0);
let awaiting: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status IN ('awaiting_decision', 'awaiting', 'waiting')")
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0);
let paused: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status = 'paused'")
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0);
let blocked: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status IN ('blocked', 'failed', 'error')")
.get_result::<CountResult>(&mut db_conn) .get_result::<CountResult>(&mut db_conn)
.map(|r| r.count) .map(|r| r.count)
.unwrap_or(0); .unwrap_or(0);
let priority: i64 = let priority: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM tasks WHERE priority IN ('high', 'urgent')") diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE priority IN ('high', 'urgent')")
.get_result::<CountResult>(&mut db_conn) .get_result::<CountResult>(&mut db_conn)
.map(|r| r.count) .map(|r| r.count)
.unwrap_or(0); .unwrap_or(0);
let time_saved = format!("{}h", completed * 2);
Ok::<_, String>(TaskStats { Ok::<_, String>(TaskStats {
total: total as usize, total: total as usize,
active: active as usize, active: active as usize,
completed: completed as usize, completed: completed as usize,
awaiting: awaiting as usize,
paused: paused as usize,
blocked: blocked as usize,
priority: priority as usize, priority: priority as usize,
time_saved,
}) })
}) })
.await .await
@ -1435,18 +1544,25 @@ pub async fn handle_task_stats_htmx(State(state): State<Arc<AppState>>) -> impl
total: 0, total: 0,
active: 0, active: 0,
completed: 0, completed: 0,
awaiting: 0,
paused: 0,
blocked: 0,
priority: 0, priority: 0,
time_saved: "0h".to_string(),
}); });
let html = format!( let html = format!(
"{} tasks "{} tasks
<script> <script>
document.getElementById('count-all').textContent = '{}'; document.getElementById('count-all').textContent = '{}';
document.getElementById('count-complete').textContent = '{}';
document.getElementById('count-active').textContent = '{}'; document.getElementById('count-active').textContent = '{}';
document.getElementById('count-completed').textContent = '{}'; document.getElementById('count-awaiting').textContent = '{}';
document.getElementById('count-priority').textContent = '{}'; document.getElementById('count-paused').textContent = '{}';
document.getElementById('count-blocked').textContent = '{}';
document.getElementById('time-saved-value').textContent = '{}';
</script>", </script>",
stats.total, stats.total, stats.active, stats.completed, stats.priority stats.total, stats.total, stats.completed, stats.active, stats.awaiting, stats.paused, stats.blocked, stats.time_saved
); );
axum::response::Html(html) axum::response::Html(html)
@ -1460,34 +1576,58 @@ pub async fn handle_task_stats(State(state): State<Arc<AppState>>) -> Json<TaskS
.get() .get()
.map_err(|e| format!("DB connection error: {}", e))?; .map_err(|e| format!("DB connection error: {}", e))?;
let total: i64 = diesel::sql_query("SELECT COUNT(*) as count FROM tasks") let total: i64 = diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks")
.get_result::<CountResult>(&mut db_conn) .get_result::<CountResult>(&mut db_conn)
.map(|r| r.count) .map(|r| r.count)
.unwrap_or(0); .unwrap_or(0);
let active: i64 = let active: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM tasks WHERE status NOT IN ('done', 'completed', 'cancelled')") diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status IN ('running', 'pending', 'in_progress')")
.get_result::<CountResult>(&mut db_conn) .get_result::<CountResult>(&mut db_conn)
.map(|r| r.count) .map(|r| r.count)
.unwrap_or(0); .unwrap_or(0);
let completed: i64 = let completed: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM tasks WHERE status IN ('done', 'completed')") diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status IN ('done', 'completed')")
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0);
let awaiting: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status IN ('awaiting_decision', 'awaiting', 'waiting')")
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0);
let paused: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status = 'paused'")
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0);
let blocked: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status IN ('blocked', 'failed', 'error')")
.get_result::<CountResult>(&mut db_conn) .get_result::<CountResult>(&mut db_conn)
.map(|r| r.count) .map(|r| r.count)
.unwrap_or(0); .unwrap_or(0);
let priority: i64 = let priority: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM tasks WHERE priority IN ('high', 'urgent')") diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE priority IN ('high', 'urgent')")
.get_result::<CountResult>(&mut db_conn) .get_result::<CountResult>(&mut db_conn)
.map(|r| r.count) .map(|r| r.count)
.unwrap_or(0); .unwrap_or(0);
let time_saved = format!("{}h", completed * 2);
Ok::<_, String>(TaskStats { Ok::<_, String>(TaskStats {
total: total as usize, total: total as usize,
active: active as usize, active: active as usize,
completed: completed as usize, completed: completed as usize,
awaiting: awaiting as usize,
paused: paused as usize,
blocked: blocked as usize,
priority: priority as usize, priority: priority as usize,
time_saved,
}) })
}) })
.await .await
@ -1499,12 +1639,43 @@ pub async fn handle_task_stats(State(state): State<Arc<AppState>>) -> Json<TaskS
total: 0, total: 0,
active: 0, active: 0,
completed: 0, completed: 0,
awaiting: 0,
paused: 0,
blocked: 0,
priority: 0, priority: 0,
time_saved: "0h".to_string(),
}); });
Json(stats) Json(stats)
} }
pub async fn handle_time_saved(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone();
let time_saved = tokio::task::spawn_blocking(move || {
let mut db_conn = match conn.get() {
Ok(c) => c,
Err(_) => return "0h".to_string(),
};
let completed: i64 =
diesel::sql_query("SELECT COUNT(*) as count FROM auto_tasks WHERE status IN ('done', 'completed')")
.get_result::<CountResult>(&mut db_conn)
.map(|r| r.count)
.unwrap_or(0);
format!("{}h", completed * 2)
})
.await
.unwrap_or_else(|_| "0h".to_string());
axum::response::Html(format!(
r#"<span class="time-label">Active Time Saved:</span>
<span class="time-value" id="time-saved-value">{}</span>"#,
time_saved
))
}
pub async fn handle_clear_completed(State(state): State<Arc<AppState>>) -> impl IntoResponse { pub async fn handle_clear_completed(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let conn = state.conn.clone(); let conn = state.conn.clone();
@ -1513,7 +1684,7 @@ pub async fn handle_clear_completed(State(state): State<Arc<AppState>>) -> impl
.get() .get()
.map_err(|e| format!("DB connection error: {}", e))?; .map_err(|e| format!("DB connection error: {}", e))?;
diesel::sql_query("DELETE FROM tasks WHERE status IN ('done', 'completed')") diesel::sql_query("DELETE FROM auto_tasks WHERE status IN ('done', 'completed')")
.execute(&mut db_conn) .execute(&mut db_conn)
.map_err(|e| format!("Delete failed: {}", e))?; .map_err(|e| format!("Delete failed: {}", e))?;
@ -1595,7 +1766,11 @@ pub struct TaskStats {
pub total: usize, pub total: usize,
pub active: usize, pub active: usize,
pub completed: usize, pub completed: usize,
pub awaiting: usize,
pub paused: usize,
pub blocked: usize,
pub priority: usize, pub priority: usize,
pub time_saved: String,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]