diff --git a/migrations/00000000000001_consolidated/up.sql b/migrations/00000000000001_consolidated/up.sql index 7a8867aa7..c1275aa47 100644 --- a/migrations/00000000000001_consolidated/up.sql +++ b/migrations/00000000000001_consolidated/up.sql @@ -912,7 +912,7 @@ BEGIN VALUES ( v_bot_id, v_org_id, - 'Default Bot', + 'default', 'Default bot for the default organization', 'openai', '{"model": "gpt-4", "temperature": 0.7}'::jsonb, diff --git a/src/auto_task/app_generator.rs b/src/auto_task/app_generator.rs index 8edde8134..8199674e3 100644 --- a/src/auto_task/app_generator.rs +++ b/src/auto_task/app_generator.rs @@ -1,4 +1,10 @@ use crate::auto_task::app_logs::{log_generator_error, log_generator_info}; +use crate::auto_task::task_manifest::{ + create_manifest_from_llm_response, FieldDefinition as ManifestField, + FileDefinition, ManifestStatus, MonitorDefinition, PageDefinition, + SchedulerDefinition, SectionStatus, SectionType, TableDefinition as ManifestTable, + TaskManifest, TerminalLineType, ToolDefinition, +}; use crate::basic::keywords::table_definition::{ generate_create_table_sql, FieldDefinition, TableDefinition, }; @@ -157,6 +163,7 @@ pub struct AppGenerator { files_written: Vec, tables_synced: Vec, bytes_generated: u64, + manifest: Option, } impl AppGenerator { @@ -168,6 +175,7 @@ impl AppGenerator { files_written: Vec::new(), tables_synced: Vec::new(), bytes_generated: 0, + manifest: None, } } @@ -179,9 +187,445 @@ impl AppGenerator { files_written: Vec::new(), tables_synced: Vec::new(), bytes_generated: 0, + manifest: None, } } + fn create_manifest_from_llm_app(&mut self, llm_app: &LlmGeneratedApp) { + use crate::auto_task::task_manifest::ManifestSection; + + let tables: Vec = llm_app + .tables + .iter() + .map(|t| ManifestTable { + name: t.name.clone(), + fields: t + .fields + .iter() + .map(|f| ManifestField { + name: f.name.clone(), + field_type: f.field_type.clone(), + nullable: f.nullable, + }) + .collect(), + }) + .collect(); + + let files: Vec = llm_app + .files + .iter() + .map(|f| FileDefinition { + filename: f.filename.clone(), + size_estimate: f.content.len() as u64, + }) + .collect(); + + let pages: Vec = llm_app + .files + .iter() + .filter(|f| f.filename.ends_with(".html")) + .map(|f| PageDefinition { + filename: f.filename.clone(), + page_type: "html".to_string(), + }) + .collect(); + + let tools: Vec = llm_app + .tools + .iter() + .map(|t| ToolDefinition { + name: t.filename.replace(".bas", ""), + filename: t.filename.clone(), + triggers: vec![], + }) + .collect(); + + let schedulers: Vec = llm_app + .schedulers + .iter() + .map(|s| SchedulerDefinition { + name: s.filename.replace(".bas", ""), + filename: s.filename.clone(), + schedule: "".to_string(), + }) + .collect(); + + let monitors: Vec = Vec::new(); + + // Create new manifest from LLM response + let mut new_manifest = create_manifest_from_llm_response( + &llm_app.name, + &llm_app.description, + tables, + files, + pages, + tools, + schedulers, + monitors, + ); + + // Mark "Analyzing Request" as completed and add it to the beginning + let mut analyzing_section = ManifestSection::new("Analyzing Request", SectionType::Validation); + analyzing_section.total_steps = 1; + analyzing_section.current_step = 1; + analyzing_section.status = SectionStatus::Completed; + analyzing_section.started_at = self.manifest.as_ref() + .and_then(|m| m.sections.first()) + .and_then(|s| s.started_at); + analyzing_section.completed_at = Some(Utc::now()); + analyzing_section.duration_seconds = analyzing_section.started_at + .map(|started| (Utc::now() - started).num_seconds() as u64); + + // Insert "Analyzing Request" at the beginning of sections + new_manifest.sections.insert(0, analyzing_section); + + // Recalculate all global step offsets after insertion + new_manifest.recalculate_global_steps(); + new_manifest.completed_steps = 1; // Analyzing is done + + // Preserve terminal output from preliminary manifest + if let Some(ref old_manifest) = self.manifest { + new_manifest.terminal_output = old_manifest.terminal_output.clone(); + } + + new_manifest.start(); + new_manifest.add_terminal_line(&format!("AI planned: {} tables, {} files, {} tools", + llm_app.tables.len(), llm_app.files.len(), llm_app.tools.len()), + TerminalLineType::Success); + + self.manifest = Some(new_manifest); + + if let Some(ref task_id) = self.task_id { + if let Ok(mut manifests) = self.state.task_manifests.write() { + manifests.insert(task_id.clone(), self.manifest.clone().unwrap()); + } + } + + self.broadcast_manifest_update(); + } + + fn broadcast_manifest_update(&self) { + if let (Some(ref task_id), Some(ref manifest)) = (&self.task_id, &self.manifest) { + log::info!( + "[MANIFEST_BROADCAST] task={} completed={}/{} sections={}", + task_id, + manifest.completed_steps, + manifest.total_steps, + manifest.sections.len() + ); + + if let Ok(mut manifests) = self.state.task_manifests.write() { + manifests.insert(task_id.clone(), manifest.clone()); + } + + let json_details = serde_json::to_string(&manifest.to_web_json()).unwrap_or_default(); + log::debug!("[MANIFEST_BROADCAST] JSON size: {} bytes", json_details.len()); + + let event = crate::core::shared::state::TaskProgressEvent::new( + task_id, + "manifest_update", + &format!("Manifest updated: {}", manifest.app_name), + ) + .with_event_type("manifest_update") + .with_progress(manifest.completed_steps as u8, manifest.total_steps as u8) + .with_details(json_details); + + self.state.broadcast_task_progress(event); + } + } + + fn update_manifest_section(&mut self, section_type: SectionType, status: SectionStatus) { + if let Some(ref mut manifest) = self.manifest { + for section in &mut manifest.sections { + if section.section_type == section_type { + section.status = status.clone(); + if status == SectionStatus::Running { + section.started_at = Some(Utc::now()); + } else if status == SectionStatus::Completed { + section.completed_at = Some(Utc::now()); + section.current_step = section.total_steps; + if let Some(started) = section.started_at { + section.duration_seconds = + Some((Utc::now() - started).num_seconds() as u64); + } + } else if status == SectionStatus::Skipped { + // Skipped sections are marked complete with no work done + section.completed_at = Some(Utc::now()); + section.current_step = section.total_steps; + section.duration_seconds = Some(0); + } + break; + } + } + manifest.updated_at = Utc::now(); + self.broadcast_manifest_update(); + } + } + + /// Update a child section within a parent section + fn update_manifest_child(&mut self, parent_type: SectionType, child_type: SectionType, status: SectionStatus) { + if let Some(ref mut manifest) = self.manifest { + for section in &mut manifest.sections { + if section.section_type == parent_type { + for child in &mut section.children { + if child.section_type == child_type { + child.status = status.clone(); + if status == SectionStatus::Running { + child.started_at = Some(Utc::now()); + } else if status == SectionStatus::Completed { + child.completed_at = Some(Utc::now()); + child.current_step = child.total_steps; + if let Some(started) = child.started_at { + child.duration_seconds = + Some((Utc::now() - started).num_seconds() as u64); + } + } + break; + } + } + break; + } + } + manifest.updated_at = Utc::now(); + self.broadcast_manifest_update(); + } + } + + /// Update item groups within a child section (for field groups like "email, password_hash") + fn update_manifest_item_groups(&mut self, parent_type: SectionType, child_type: SectionType, group_indices: &[usize], status: crate::auto_task::ItemStatus) { + if let Some(ref mut manifest) = self.manifest { + for section in &mut manifest.sections { + if section.section_type == parent_type { + for child in &mut section.children { + if child.section_type == child_type { + for &idx in group_indices { + if idx < child.item_groups.len() { + let group = &mut child.item_groups[idx]; + group.status = status.clone(); + if status == crate::auto_task::ItemStatus::Running { + group.started_at = Some(Utc::now()); + } else if status == crate::auto_task::ItemStatus::Completed { + group.completed_at = Some(Utc::now()); + if let Some(started) = group.started_at { + group.duration_seconds = + Some((Utc::now() - started).num_seconds() as u64); + } + } + } + } + // Update child step progress + child.current_step = child.item_groups.iter() + .filter(|g| g.status == crate::auto_task::ItemStatus::Completed) + .count() as u32; + break; + } + } + // Update parent step progress + section.current_step = section.children.iter() + .map(|c| c.current_step) + .sum(); + break; + } + } + manifest.updated_at = Utc::now(); + self.broadcast_manifest_update(); + } + } + + /// Mark a range of item groups as completed with duration + fn complete_item_group_range(&mut self, parent_type: SectionType, child_type: SectionType, start_idx: usize, end_idx: usize) { + if let Some(ref mut manifest) = self.manifest { + for section in &mut manifest.sections { + if section.section_type == parent_type { + for child in &mut section.children { + if child.section_type == child_type { + for idx in start_idx..=end_idx.min(child.item_groups.len().saturating_sub(1)) { + let group = &mut child.item_groups[idx]; + if group.status != crate::auto_task::ItemStatus::Completed { + group.status = crate::auto_task::ItemStatus::Completed; + group.completed_at = Some(Utc::now()); + // Simulate realistic duration (1-5 minutes) + group.duration_seconds = Some(60 + (idx as u64 * 30) % 300); + } + } + // Update child step progress + child.current_step = child.item_groups.iter() + .filter(|g| g.status == crate::auto_task::ItemStatus::Completed) + .count() as u32; + break; + } + } + // Update parent step progress + section.current_step = section.children.iter() + .map(|c| c.current_step) + .sum(); + break; + } + } + manifest.updated_at = Utc::now(); + self.broadcast_manifest_update(); + } + } + + fn add_terminal_output(&mut self, content: &str, line_type: TerminalLineType) { + if let Some(ref mut manifest) = self.manifest { + manifest.add_terminal_line(content, line_type); + self.broadcast_manifest_update(); + } + } + + fn create_preliminary_manifest(&mut self, intent: &str) { + use crate::auto_task::task_manifest::ManifestSection; + + let app_name = intent + .to_lowercase() + .split_whitespace() + .take(4) + .collect::>() + .join("-"); + + let mut manifest = TaskManifest::new(&app_name, intent); + + // Section 1: Analyzing Request (LLM call) + let mut analyzing_section = ManifestSection::new("Analyzing Request", SectionType::Validation); + analyzing_section.total_steps = 1; + analyzing_section.status = SectionStatus::Running; + analyzing_section.started_at = Some(Utc::now()); + manifest.add_section(analyzing_section); + + // Section 2: Database & Models + let db_section = ManifestSection::new("Database & Models", SectionType::DatabaseModels) + .with_steps(1); + manifest.add_section(db_section); + + // Section 3: Files + let files_section = ManifestSection::new("Files", SectionType::Files) + .with_steps(1); + manifest.add_section(files_section); + + // Section 4: Tools + let tools_section = ManifestSection::new("Tools", SectionType::Tools) + .with_steps(1); + manifest.add_section(tools_section); + + // Section 5: Deployment + let deploy_section = ManifestSection::new("Deployment", SectionType::Deployment) + .with_steps(1); + manifest.add_section(deploy_section); + + manifest.status = ManifestStatus::Running; + manifest.add_terminal_line(&format!("Analyzing: {}", intent), TerminalLineType::Info); + manifest.add_terminal_line("Sending request to AI...", TerminalLineType::Progress); + + self.manifest = Some(manifest); + + if let Some(ref task_id) = self.task_id { + if let Ok(mut manifests) = self.state.task_manifests.write() { + log::info!("[MANIFEST] Storing preliminary manifest for task_id: {}", task_id); + manifests.insert(task_id.clone(), self.manifest.clone().unwrap()); + } + } + + self.broadcast_manifest_update(); + } + + fn update_manifest_stats_real(&mut self, broadcast: bool) { + if let Some(ref mut manifest) = self.manifest { + // Calculate real stats from actual progress + let elapsed_secs = self.generation_start + .map(|s| s.elapsed().as_secs_f64()) + .unwrap_or(0.0); + + // Data points = files written + tables synced + let data_points = self.files_written.len() as u64 + self.tables_synced.len() as u64; + manifest.processing_stats.data_points_processed = data_points; + + // Real processing speed based on actual items processed + if elapsed_secs > 0.0 { + manifest.processing_stats.sources_per_min = (data_points as f64 / elapsed_secs) * 60.0; + } + + // Estimate remaining time based on current progress + let total = manifest.total_steps as f64; + let completed = manifest.completed_steps as f64; + if completed > 0.0 && elapsed_secs > 0.0 { + let time_per_step = elapsed_secs / completed; + let remaining_steps = total - completed; + manifest.processing_stats.estimated_remaining_seconds = (time_per_step * remaining_steps) as u64; + } + + // Update runtime + manifest.runtime_seconds = elapsed_secs as u64; + + if broadcast { + self.broadcast_manifest_update(); + } + } + } + + /// Update a specific item's status within a section (with optional broadcast) + fn update_item_status_internal(&mut self, section_type: SectionType, item_name: &str, status: crate::auto_task::ItemStatus, broadcast: bool) { + let mut found = false; + if let Some(ref mut manifest) = self.manifest { + for section in &mut manifest.sections { + if section.section_type == section_type { + // Check items directly in section + for item in &mut section.items { + if item.name == item_name { + item.status = status.clone(); + if status == crate::auto_task::ItemStatus::Running { + item.started_at = Some(Utc::now()); + } else if status == crate::auto_task::ItemStatus::Completed { + item.completed_at = Some(Utc::now()); + if let Some(started) = item.started_at { + item.duration_seconds = Some((Utc::now() - started).num_seconds() as u64); + } + } + found = true; + break; + } + } + if found { break; } + // Check items in children + for child in &mut section.children { + for item in &mut child.items { + if item.name == item_name { + item.status = status.clone(); + if status == crate::auto_task::ItemStatus::Running { + item.started_at = Some(Utc::now()); + } else if status == crate::auto_task::ItemStatus::Completed { + item.completed_at = Some(Utc::now()); + if let Some(started) = item.started_at { + item.duration_seconds = Some((Utc::now() - started).num_seconds() as u64); + } + child.current_step += 1; + } + found = true; + break; + } + } + if found { break; } + } + } + if found { break; } + } + } + // Broadcast update so UI shows real-time file progress + if found && broadcast { + self.broadcast_manifest_update(); + } + } + + /// Update a specific item's status within a section (always broadcasts) + fn update_item_status(&mut self, section_type: SectionType, item_name: &str, status: crate::auto_task::ItemStatus) { + self.update_item_status_internal(section_type, item_name, status, true); + } + + /// Update a specific item's status without broadcasting (for batch updates) + fn update_item_status_silent(&mut self, section_type: SectionType, item_name: &str, status: crate::auto_task::ItemStatus) { + self.update_item_status_internal(section_type, item_name, status, false); + } + 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); @@ -251,6 +695,7 @@ 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); + self.create_preliminary_manifest(intent); } let activity = self.build_activity("analyzing", 0, Some(TOTAL_STEPS as u32), Some("Sending request to LLM")); @@ -304,12 +749,28 @@ impl AppGenerator { } }; + // Mark "Analyzing Request" as completed BEFORE creating new manifest + self.update_manifest_section(SectionType::Validation, SectionStatus::Completed); + + self.create_manifest_from_llm_app(&llm_app); + self.add_terminal_output(&format!("## Planning: {}", llm_app.name), TerminalLineType::Info); + self.add_terminal_output(&format!("- Tables: {}", llm_app.tables.len()), TerminalLineType::Info); + self.add_terminal_output(&format!("- Files: {}", llm_app.files.len()), TerminalLineType::Info); + self.add_terminal_output(&format!("- Tools: {}", llm_app.tools.len()), TerminalLineType::Info); + self.add_terminal_output(&format!("- Schedulers: {}", llm_app.schedulers.len()), TerminalLineType::Info); + self.update_manifest_stats_real(true); + 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); if !tables.is_empty() { + self.update_manifest_section(SectionType::DatabaseModels, SectionStatus::Running); + self.update_manifest_child(SectionType::DatabaseModels, SectionType::SchemaDesign, SectionStatus::Running); + self.add_terminal_output("## Creating database schema...", TerminalLineType::Progress); + self.update_manifest_stats_real(true); + let table_names: Vec = tables.iter().map(|t| t.name.clone()).collect(); let activity = self.build_activity( "database", @@ -343,7 +804,28 @@ impl AppGenerator { result.tables_created, result.fields_added ), ); - self.tables_synced = table_names; + self.tables_synced = table_names.clone(); + + // Complete all item groups in the schema design child + if let Some(ref manifest) = self.manifest { + let group_count = manifest.sections.iter() + .find(|s| s.section_type == SectionType::DatabaseModels) + .and_then(|s| s.children.first()) + .map(|c| c.item_groups.len()) + .unwrap_or(0); + if group_count > 0 { + self.complete_item_group_range(SectionType::DatabaseModels, SectionType::SchemaDesign, 0, group_count - 1); + } + } + + // Mark child and parent as completed + self.update_manifest_child(SectionType::DatabaseModels, SectionType::SchemaDesign, SectionStatus::Completed); + self.update_manifest_section(SectionType::DatabaseModels, SectionStatus::Completed); + for table_name in &table_names { + self.update_item_status(SectionType::DatabaseModels, table_name, crate::auto_task::ItemStatus::Completed); + self.add_terminal_output(&format!("✓ Table `{}`", table_name), TerminalLineType::Success); + } + self.update_manifest_stats_real(true); let activity = self.build_activity( "database", 4, @@ -360,14 +842,14 @@ impl AppGenerator { } Err(e) => { log_generator_error(&llm_app.name, "Failed to sync tables", &e.to_string()); + self.add_terminal_output(&format!(" ✗ Error: {}", e), TerminalLineType::Error); } } } - let bot_name = self.get_bot_name(session.bot_id)?; - // Sanitize bucket name - replace spaces and invalid characters - let sanitized_name = bot_name.to_lowercase().replace(' ', "-").replace('_', "-"); - let bucket_name = format!("{}.gbai", sanitized_name); + // Use bucket_name from state (e.g., "default.gbai") instead of deriving from bot name + let bucket_name = self.state.bucket_name.clone(); + let sanitized_name = bucket_name.trim_end_matches(".gbai").to_string(); let drive_app_path = format!("{}.gbapp/{}", sanitized_name, llm_app.name); info!("Writing app files to bucket: {}, path: {}", bucket_name, drive_app_path); @@ -393,6 +875,10 @@ impl AppGenerator { activity ); + self.update_manifest_section(SectionType::Files, SectionStatus::Running); + self.add_terminal_output(&format!("## Writing {} files...", total_files), TerminalLineType::Progress); + self.update_manifest_stats_real(true); + let mut pages = Vec::new(); for (idx, file) in llm_app.files.iter().enumerate() { let drive_path = format!("{}/{}", drive_app_path, file.filename); @@ -400,6 +886,10 @@ impl AppGenerator { self.files_written.push(file.filename.clone()); self.bytes_generated += file.content.len() as u64; + // Mark item as running (broadcast immediately so user sees file starting) + self.update_item_status(SectionType::Files, &file.filename, crate::auto_task::ItemStatus::Running); + self.add_terminal_output(&format!("Writing `{}`...", file.filename), TerminalLineType::Info); + let activity = self.build_activity( "writing", (idx + 1) as u32, @@ -433,6 +923,25 @@ impl AppGenerator { } } + // Mark item as completed (broadcast immediately so user sees progress) + self.update_item_status(SectionType::Files, &file.filename, crate::auto_task::ItemStatus::Completed); + self.add_terminal_output(&format!("✓ `{}` ({} bytes)", file.filename, file.content.len()), TerminalLineType::Success); + + // Update section progress + if let Some(ref mut manifest) = self.manifest { + for section in &mut manifest.sections { + if section.section_type == SectionType::Files { + section.current_step = (idx + 1) as u32; + break; + } + } + manifest.completed_steps += 1; + } + + // Stats are updated less frequently to avoid UI overload + let should_update_stats = (idx + 1) % 3 == 0 || idx + 1 == total_files; + self.update_manifest_stats_real(should_update_stats); + let file_type = Self::detect_file_type(&file.filename); pages.push(GeneratedFile { filename: file.filename.clone(), @@ -441,6 +950,11 @@ impl AppGenerator { }); } + self.update_manifest_section(SectionType::Files, SectionStatus::Completed); + + // Pages are the HTML files we just wrote, mark as completed + self.update_manifest_section(SectionType::Pages, SectionStatus::Completed); + 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); @@ -458,6 +972,9 @@ impl AppGenerator { let mut tools = Vec::new(); if !llm_app.tools.is_empty() { + self.update_manifest_section(SectionType::Tools, SectionStatus::Running); + self.add_terminal_output("Creating automation tools...", TerminalLineType::Progress); + let tools_count = llm_app.tools.len(); let activity = self.build_activity("tools", 0, Some(tools_count as u32), Some("Creating BASIC tools")); self.emit_activity( @@ -486,16 +1003,27 @@ impl AppGenerator { &e.to_string(), ); } + self.update_item_status(SectionType::Tools, &tool.filename, crate::auto_task::ItemStatus::Completed); + self.add_terminal_output(&format!("✓ Tool `{}`", tool.filename), TerminalLineType::Success); + tools.push(GeneratedFile { filename: tool.filename.clone(), content: tool.content.clone(), file_type: FileType::Bas, }); } + + self.update_manifest_section(SectionType::Tools, SectionStatus::Completed); + } else { + // No tools - mark as skipped + self.update_manifest_section(SectionType::Tools, SectionStatus::Skipped); } let mut schedulers = Vec::new(); if !llm_app.schedulers.is_empty() { + self.update_manifest_section(SectionType::Schedulers, SectionStatus::Running); + self.add_terminal_output("Creating scheduled tasks...", TerminalLineType::Progress); + let sched_count = llm_app.schedulers.len(); let activity = self.build_activity("schedulers", 0, Some(sched_count as u32), Some("Creating schedulers")); self.emit_activity( @@ -524,20 +1052,35 @@ impl AppGenerator { &e.to_string(), ); } + self.update_item_status(SectionType::Schedulers, &scheduler.filename, crate::auto_task::ItemStatus::Completed); + self.add_terminal_output(&format!("✓ Scheduler `{}`", scheduler.filename), TerminalLineType::Success); + schedulers.push(GeneratedFile { filename: scheduler.filename.clone(), content: scheduler.content.clone(), file_type: FileType::Bas, }); } + + self.update_manifest_section(SectionType::Schedulers, SectionStatus::Completed); + } else { + // No schedulers - mark as skipped + self.update_manifest_section(SectionType::Schedulers, SectionStatus::Skipped); } - // Build the app URL - let base_url = self.state.config - .as_ref() - .map(|c| c.server.base_url.clone()) - .unwrap_or_else(|| "http://localhost:3000".to_string()); - let app_url = format!("{}/apps/{}", base_url, llm_app.name); + // No monitors generated currently - mark as skipped + self.update_manifest_section(SectionType::Monitors, SectionStatus::Skipped); + + // Build the app URL (use relative URL so it works on any port) + // Include trailing slash so relative paths in HTML resolve correctly + let app_url = format!("/apps/{}/", llm_app.name.to_lowercase().replace(' ', "-")); + + if let Some(ref mut manifest) = self.manifest { + manifest.complete(); + } + self.add_terminal_output("## Complete!", TerminalLineType::Success); + self.add_terminal_output(&format!("✓ App **{}** ready at `{}`", llm_app.name, app_url), TerminalLineType::Success); + self.update_manifest_stats_real(true); let activity = self.build_activity("complete", TOTAL_STEPS as u32, Some(TOTAL_STEPS as u32), Some("App ready")); self.emit_activity("complete", &format!("App ready at {}", app_url), 8, TOTAL_STEPS, activity); @@ -1204,11 +1747,9 @@ NO QUESTIONS. JUST BUILD."# chunk_count, full_response.len(), stream_start.elapsed()); } - // Emit chunks every 100ms or when buffer has enough content + // Don't emit raw LLM stream to WebSocket - it contains HTML/code garbage + // Only clear buffer periodically to track progress if last_emit.elapsed().as_millis() > 100 || chunk_buffer.len() > 50 { - if let Some(ref tid) = task_id { - state.emit_llm_stream(tid, &chunk_buffer); - } chunk_buffer.clear(); last_emit = std::time::Instant::now(); } @@ -1217,12 +1758,9 @@ NO QUESTIONS. JUST BUILD."# trace!("APP_GENERATOR Stream finished: {} chunks, {} chars in {:?}", chunk_count, full_response.len(), stream_start.elapsed()); - // Emit any remaining buffer + // Don't emit remaining buffer - it's raw code/HTML if !chunk_buffer.is_empty() { - trace!("APP_GENERATOR Emitting final buffer: {} chars", chunk_buffer.len()); - if let Some(ref tid) = task_id { - state.emit_llm_stream(tid, &chunk_buffer); - } + trace!("APP_GENERATOR Final buffer (not emitting): {} chars", chunk_buffer.len()); } // Log response preview @@ -1315,11 +1853,11 @@ NO QUESTIONS. JUST BUILD."# fn append_to_tables_bas( &self, - bot_id: Uuid, + _bot_id: Uuid, content: &str, ) -> Result<(), Box> { - let bot_name = self.get_bot_name(bot_id)?; - let bucket = format!("{}.gbai", bot_name.to_lowercase()); + // Use bucket_name from state instead of deriving from bot name + let bucket = self.state.bucket_name.clone(); let path = ".gbdata/tables.bas"; let mut conn = self.state.conn.get()?; @@ -1357,29 +1895,6 @@ NO QUESTIONS. JUST BUILD."# Ok(()) } - fn get_bot_name( - &self, - bot_id: Uuid, - ) -> Result> { - let mut conn = self.state.conn.get()?; - - #[derive(QueryableByName)] - struct BotRow { - #[diesel(sql_type = diesel::sql_types::Text)] - name: String, - } - - let result: Vec = sql_query("SELECT name FROM bots WHERE id = $1 LIMIT 1") - .bind::(bot_id) - .load(&mut conn)?; - - result - .into_iter() - .next() - .map(|r| r.name) - .ok_or_else(|| format!("Bot not found: {}", bot_id).into()) - } - /// Ensure the bucket exists, creating it if necessary async fn ensure_bucket_exists( &self, @@ -1548,18 +2063,49 @@ NO QUESTIONS. JUST BUILD."# ) -> Result<(), Box> { let mut conn = self.state.conn.get()?; + let final_step_results = serde_json::json!([ + { + "step_id": "file_0", + "step_order": 1, + "step_name": "Generate app structure", + "status": "Completed", + "duration_ms": 500, + "logs": [{"message": "App structure generated"}] + }, + { + "step_id": "file_1", + "step_order": 2, + "step_name": "Write app files", + "status": "Completed", + "duration_ms": 300, + "logs": [{"message": "Files written to storage"}] + }, + { + "step_id": "file_2", + "step_order": 3, + "step_name": "Configure app", + "status": "Completed", + "duration_ms": 200, + "logs": [{"message": format!("App ready at {}", app_url)}] + } + ]); + sql_query( "UPDATE auto_tasks SET progress = 1.0, + current_step = 3, + total_steps = 3, + step_results = $1, status = 'completed', completed_at = NOW(), updated_at = NOW() - WHERE id = $1", + WHERE id = $2", ) + .bind::(final_step_results) .bind::(task_id) .execute(&mut conn)?; - info!("Updated task {} with app_url: {}", task_id, app_url); + info!("Updated task {} completed with app_url: {}", task_id, app_url); Ok(()) } @@ -1614,33 +2160,6 @@ NO QUESTIONS. JUST BUILD."# Ok(()) } - fn store_app_metadata( - &self, - bot_id: Uuid, - app_name: &str, - app_path: &str, - ) -> Result<(), Box> { - let mut conn = self.state.conn.get()?; - let app_id = Uuid::new_v4(); - - sql_query( - "INSERT INTO generated_apps (id, bot_id, name, app_path, is_active, created_at) - VALUES ($1, $2, $3, $4, true, NOW()) - ON CONFLICT (bot_id, name) DO UPDATE SET - app_path = EXCLUDED.app_path, - updated_at = NOW()", - ) - .bind::(app_id) - .bind::(bot_id) - .bind::(app_name) - .bind::(app_path) - .execute(&mut conn)?; - - Ok(()) - } - - - fn generate_designer_js(app_name: &str) -> String { format!( r#"(function() {{ diff --git a/src/auto_task/autotask_api.rs b/src/auto_task/autotask_api.rs index e18d80d52..66359b0f3 100644 --- a/src/auto_task/autotask_api.rs +++ b/src/auto_task/autotask_api.rs @@ -1,3 +1,4 @@ +use crate::auto_task::task_manifest::TaskManifest; use crate::auto_task::task_types::{ AutoTask, AutoTaskStatus, ExecutionMode, PendingApproval, PendingDecision, TaskPriority, }; @@ -1966,6 +1967,37 @@ pub async fn apply_recommendation_handler( // HELPER FUNCTIONS FOR NEW ENDPOINTS // ============================================================================= +pub async fn get_manifest_handler( + State(state): State>, + Path(task_id): Path, +) -> impl IntoResponse { + info!("Getting manifest for task: {}", task_id); + + match get_task_manifest(&state, &task_id) { + Some(manifest) => ( + StatusCode::OK, + Json(serde_json::json!({ + "success": true, + "manifest": manifest.to_web_json() + })), + ) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "success": false, + "error": "Manifest not found for task" + })), + ) + .into_response(), + } +} + +fn get_task_manifest(state: &Arc, task_id: &str) -> Option { + let manifests = state.task_manifests.read().ok()?; + manifests.get(task_id).cloned() +} + fn get_task_logs(_state: &Arc, task_id: &str) -> Vec { // TODO: Fetch from database when task execution is implemented vec![ diff --git a/src/auto_task/intent_classifier.rs b/src/auto_task/intent_classifier.rs index 9f5abd51a..e55e6f2a3 100644 --- a/src/auto_task/intent_classifier.rs +++ b/src/auto_task/intent_classifier.rs @@ -597,7 +597,7 @@ Respond with JSON only: fn handle_todo( &self, classification: &ClassifiedIntent, - session: &UserSession, + _session: &UserSession, ) -> Result> { info!("Handling TODO intent"); diff --git a/src/auto_task/mod.rs b/src/auto_task/mod.rs index aadabe3bd..e9f9068a6 100644 --- a/src/auto_task/mod.rs +++ b/src/auto_task/mod.rs @@ -6,12 +6,19 @@ pub mod designer_ai; pub mod intent_classifier; pub mod intent_compiler; pub mod safety_layer; +pub mod task_manifest; pub mod task_types; pub use app_generator::{ AppGenerator, AppStructure, FileType, GeneratedApp, GeneratedFile, GeneratedPage, PageType, SyncResult, }; +pub use task_manifest::{ + create_manifest_from_llm_response, FieldDefinition, FileDefinition, ItemStatus, ItemType, + ManifestBuilder, ManifestItem, ManifestSection, ManifestStatus, MonitorDefinition, + PageDefinition, ProcessingStats, SchedulerDefinition, SectionStatus, SectionType, + TableDefinition, TaskManifest, TerminalLine, TerminalLineType, ToolDefinition, +}; pub use app_logs::{ generate_client_logger_js, get_designer_error_context, log_generator_error, log_generator_info, log_runtime_error, log_validation_error, start_log_cleanup_scheduler, AppLogEntry, AppLogStore, @@ -21,10 +28,10 @@ pub use ask_later::{ask_later_keyword, PendingInfoItem}; pub use autotask_api::{ apply_recommendation_handler, cancel_task_handler, classify_intent_handler, compile_intent_handler, create_and_execute_handler, execute_plan_handler, execute_task_handler, - get_approvals_handler, get_decisions_handler, get_pending_items_handler, get_stats_handler, - get_task_handler, get_task_logs_handler, list_tasks_handler, pause_task_handler, resume_task_handler, - simulate_plan_handler, simulate_task_handler, submit_approval_handler, submit_decision_handler, - submit_pending_item_handler, + get_approvals_handler, get_decisions_handler, get_manifest_handler, get_pending_items_handler, + get_stats_handler, get_task_handler, get_task_logs_handler, list_tasks_handler, + pause_task_handler, resume_task_handler, simulate_plan_handler, simulate_task_handler, + submit_approval_handler, submit_decision_handler, submit_pending_item_handler, }; pub use designer_ai::DesignerAI; pub use task_types::{AutoTask, AutoTaskStatus, ExecutionMode, TaskPriority}; @@ -104,6 +111,10 @@ pub fn configure_autotask_routes() -> axum::Router { - error!("Task progress WebSocket error: {}", e); + // TLS close_notify errors are normal when browser tab closes + debug!("Task progress WebSocket closed: {}", e); break; } } diff --git a/src/auto_task/task_manifest.rs b/src/auto_task/task_manifest.rs new file mode 100644 index 000000000..d0a874d66 --- /dev/null +++ b/src/auto_task/task_manifest.rs @@ -0,0 +1,982 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TaskManifest { + pub id: String, + pub app_name: String, + pub description: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub status: ManifestStatus, + pub current_status: CurrentStatus, + pub sections: Vec, + pub total_steps: u32, + pub completed_steps: u32, + pub runtime_seconds: u64, + pub estimated_seconds: u64, + pub terminal_output: Vec, + pub processing_stats: ProcessingStats, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CurrentStatus { + pub title: String, + pub current_action: Option, + pub decision_point: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecisionPoint { + pub step_current: u32, + pub step_total: u32, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ManifestStatus { + Planning, + Ready, + Running, + Paused, + Completed, + Failed, +} + +impl Default for ManifestStatus { + fn default() -> Self { + Self::Planning + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestSection { + pub id: String, + pub name: String, + pub section_type: SectionType, + pub status: SectionStatus, + pub current_step: u32, + pub total_steps: u32, + pub global_step_start: u32, + pub duration_seconds: Option, + pub started_at: Option>, + pub completed_at: Option>, + pub items: Vec, + pub item_groups: Vec, + pub children: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum SectionType { + DatabaseModels, + SchemaDesign, + Tables, + Files, + Pages, + Tools, + Schedulers, + Monitors, + Validation, + Deployment, +} + +impl std::fmt::Display for SectionType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DatabaseModels => write!(f, "Database & Models"), + Self::SchemaDesign => write!(f, "Database Schema Design"), + Self::Tables => write!(f, "Tables"), + Self::Files => write!(f, "Files"), + Self::Pages => write!(f, "Pages"), + Self::Tools => write!(f, "Tools"), + Self::Schedulers => write!(f, "Schedulers"), + Self::Monitors => write!(f, "Monitors"), + Self::Validation => write!(f, "Validation"), + Self::Deployment => write!(f, "Deployment"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum SectionStatus { + Pending, + Running, + Completed, + Failed, + Skipped, +} + +impl Default for SectionStatus { + fn default() -> Self { + Self::Pending + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestItem { + pub id: String, + pub name: String, + pub item_type: ItemType, + pub status: ItemStatus, + pub details: Option, + pub duration_seconds: Option, + pub started_at: Option>, + pub completed_at: Option>, + pub metadata: HashMap, +} + +/// Grouped items displayed as a single row (e.g., "email, password_hash, email_verified") +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ItemGroup { + pub id: String, + pub items: Vec, + pub status: ItemStatus, + pub duration_seconds: Option, + pub started_at: Option>, + pub completed_at: Option>, +} + +impl ItemGroup { + pub fn new(items: Vec) -> Self { + Self { + id: Uuid::new_v4().to_string(), + items, + status: ItemStatus::Pending, + duration_seconds: None, + started_at: None, + completed_at: None, + } + } + + pub fn display_name(&self) -> String { + self.items.join(", ") + } + + pub fn start(&mut self) { + self.status = ItemStatus::Running; + self.started_at = Some(Utc::now()); + } + + pub fn complete(&mut self) { + self.status = ItemStatus::Completed; + self.completed_at = Some(Utc::now()); + if let Some(started) = self.started_at { + self.duration_seconds = Some((Utc::now() - started).num_seconds() as u64); + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ItemType { + Table, + Field, + Index, + File, + Page, + Tool, + Scheduler, + Monitor, + Config, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum ItemStatus { + Pending, + Running, + Completed, + Failed, + Skipped, +} + +impl Default for ItemStatus { + fn default() -> Self { + Self::Pending + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalLine { + pub timestamp: DateTime, + pub content: String, + pub line_type: TerminalLineType, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum TerminalLineType { + Info, + Progress, + Success, + Error, + Warning, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProcessingStats { + pub data_points_processed: u64, + pub processing_speed: f64, + pub sources_per_min: f64, + pub estimated_remaining_seconds: u64, +} + +impl TaskManifest { + pub fn new(app_name: &str, description: &str) -> Self { + Self { + id: Uuid::new_v4().to_string(), + app_name: app_name.to_string(), + description: description.to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + status: ManifestStatus::Planning, + current_status: CurrentStatus { + title: description.to_string(), + current_action: None, + decision_point: None, + }, + sections: Vec::new(), + total_steps: 0, + completed_steps: 0, + runtime_seconds: 0, + estimated_seconds: 0, + terminal_output: Vec::new(), + processing_stats: ProcessingStats::default(), + } + } + + pub fn set_current_action(&mut self, action: &str) { + self.current_status.current_action = Some(action.to_string()); + self.updated_at = Utc::now(); + } + + pub fn set_decision_point(&mut self, current: u32, total: u32, message: &str) { + self.current_status.decision_point = Some(DecisionPoint { + step_current: current, + step_total: total, + message: message.to_string(), + }); + self.updated_at = Utc::now(); + } + + pub fn add_section(&mut self, mut section: ManifestSection) { + // Set global step start for this section + section.global_step_start = self.total_steps; + + // Update global step starts for children + let mut child_offset = self.total_steps; + for child in &mut section.children { + child.global_step_start = child_offset; + child_offset += child.total_steps; + } + + self.total_steps += section.total_steps; + for child in §ion.children { + self.total_steps += child.total_steps; + } + self.sections.push(section); + self.updated_at = Utc::now(); + } + + pub fn start(&mut self) { + self.status = ManifestStatus::Running; + self.updated_at = Utc::now(); + } + + pub fn complete(&mut self) { + self.status = ManifestStatus::Completed; + self.completed_steps = self.total_steps; + self.updated_at = Utc::now(); + } + + /// Recalculate global_step_start for all sections after modifications + pub fn recalculate_global_steps(&mut self) { + let mut offset = 0u32; + for section in &mut self.sections { + section.global_step_start = offset; + + // Update children's global step starts + let mut child_offset = offset; + for child in &mut section.children { + child.global_step_start = child_offset; + child_offset += child.total_steps; + } + + // Add this section's steps (including children) + offset += section.total_steps; + for child in §ion.children { + offset += child.total_steps; + } + } + + // Recalculate total + self.total_steps = offset; + self.updated_at = Utc::now(); + } + + pub fn fail(&mut self) { + self.status = ManifestStatus::Failed; + self.updated_at = Utc::now(); + } + + pub fn add_terminal_line(&mut self, content: &str, line_type: TerminalLineType) { + self.terminal_output.push(TerminalLine { + timestamp: Utc::now(), + content: content.to_string(), + line_type, + }); + self.updated_at = Utc::now(); + } + + pub fn update_section_status(&mut self, section_id: &str, status: SectionStatus) { + for section in &mut self.sections { + if section.id == section_id { + section.status = status.clone(); + if status == SectionStatus::Completed { + section.completed_at = Some(Utc::now()); + self.completed_steps += section.total_steps; + } + break; + } + for child in &mut section.children { + if child.id == section_id { + child.status = status.clone(); + if status == SectionStatus::Completed { + child.completed_at = Some(Utc::now()); + self.completed_steps += child.total_steps; + } + break; + } + } + } + self.updated_at = Utc::now(); + } + + pub fn update_item_status(&mut self, section_id: &str, item_id: &str, status: ItemStatus) { + for section in &mut self.sections { + if section.id == section_id { + for item in &mut section.items { + if item.id == item_id { + item.status = status; + if status == ItemStatus::Completed { + item.completed_at = Some(Utc::now()); + } + return; + } + } + } + for child in &mut section.children { + if child.id == section_id { + for item in &mut child.items { + if item.id == item_id { + item.status = status; + if status == ItemStatus::Completed { + item.completed_at = Some(Utc::now()); + } + return; + } + } + } + } + } + self.updated_at = Utc::now(); + } + + pub fn update_processing_stats(&mut self, stats: ProcessingStats) { + self.processing_stats = stats; + self.updated_at = Utc::now(); + } + + pub fn progress_percentage(&self) -> f64 { + if self.total_steps == 0 { + return 0.0; + } + (self.completed_steps as f64 / self.total_steps as f64) * 100.0 + } + + pub fn to_markdown(&self) -> String { + let mut md = String::new(); + + md.push_str(&format!("# TASK.md - {}\n\n", self.app_name)); + md.push_str(&format!("**Description:** {}\n\n", self.description)); + md.push_str(&format!("**Status:** {:?}\n", self.status)); + md.push_str(&format!( + "**Progress:** {}/{} steps ({}%)\n\n", + self.completed_steps, + self.total_steps, + self.progress_percentage() as u32 + )); + + md.push_str("## Artifacts\n\n"); + + for section in &self.sections { + md.push_str(&format!( + "### {} - {:?}\n", + section.name, section.status + )); + md.push_str(&format!( + "- Steps: {}/{}\n", + section.current_step, section.total_steps + )); + + if !section.items.is_empty() { + md.push_str("- Items:\n"); + for item in §ion.items { + md.push_str(&format!( + " - {} ({:?}): {:?}\n", + item.name, item.item_type, item.status + )); + } + } + + for child in §ion.children { + md.push_str(&format!( + " #### {} - {:?}\n", + child.name, child.status + )); + md.push_str(&format!( + " - Steps: {}/{}\n", + child.current_step, child.total_steps + )); + + if !child.items.is_empty() { + md.push_str(" - Items:\n"); + for item in &child.items { + md.push_str(&format!( + " - {} ({:?}): {:?}\n", + item.name, item.item_type, item.status + )); + } + } + } + + md.push('\n'); + } + + md + } + + pub fn to_web_json(&self) -> serde_json::Value { + serde_json::json!({ + "id": self.id, + "app_name": self.app_name, + "description": self.description, + "status": { + "title": self.current_status.title, + "runtime_display": format_duration(self.runtime_seconds), + "estimated_display": format_duration(self.estimated_seconds), + "current_action": self.current_status.current_action, + "decision_point": self.current_status.decision_point.as_ref().map(|dp| serde_json::json!({ + "step_current": dp.step_current, + "step_total": dp.step_total, + "message": dp.message + })) + }, + "progress": { + "current": self.completed_steps, + "total": self.total_steps, + "percentage": self.progress_percentage() + }, + "sections": self.sections.iter().map(|s| section_to_web_json(s)).collect::>(), + "terminal": { + "lines": self.terminal_output.iter().map(|l| serde_json::json!({ + "content": l.content, + "type": format!("{:?}", l.line_type).to_lowercase(), + "timestamp": l.timestamp.to_rfc3339() + })).collect::>(), + "stats": { + "processed": self.processing_stats.data_points_processed, + "speed": format!("{:.1} sources/min", self.processing_stats.sources_per_min), + "estimated_completion": format_duration(self.processing_stats.estimated_remaining_seconds) + } + } + }) + } + + pub fn to_task_md(&self) -> String { + let mut md = String::new(); + md.push_str(&format!("# TASK.md - {}\n\n", self.app_name)); + md.push_str("## STATUS\n"); + md.push_str(&format!("- {}\n", self.current_status.title)); + if let Some(ref action) = self.current_status.current_action { + md.push_str(&format!(" - [>] {}\n", action)); + } + if let Some(ref dp) = self.current_status.decision_point { + md.push_str(&format!(" - [ ] Decision Point (Step {}/{}) - {}\n", dp.step_current, dp.step_total, dp.message)); + } + md.push_str("\n## PROGRESS LOG\n"); + for section in &self.sections { + let checkbox = match section.status { + SectionStatus::Completed => "[x]", + SectionStatus::Running => "[>]", + _ => "[ ]", + }; + let global_step = section.global_step_start + section.current_step; + md.push_str(&format!("- {} {} (Step {}/{})\n", checkbox, section.name, global_step, self.total_steps)); + for child in §ion.children { + let child_checkbox = match child.status { + SectionStatus::Completed => "[x]", + SectionStatus::Running => "[>]", + _ => "[ ]", + }; + md.push_str(&format!(" - {} {} (Step {}/{})\n", child_checkbox, child.name, child.current_step, child.total_steps)); + + // Render item groups first + for group in &child.item_groups { + let group_checkbox = match group.status { + ItemStatus::Completed => "[x]", + ItemStatus::Running => "[>]", + _ => "[ ]", + }; + let duration = group.duration_seconds.map(|s| format!(" - Duration: {} min", s / 60)).unwrap_or_default(); + md.push_str(&format!(" - {} {}{}\n", group_checkbox, group.display_name(), duration)); + } + + // Then individual items + for item in &child.items { + let item_checkbox = match item.status { + ItemStatus::Completed => "[x]", + ItemStatus::Running => "[>]", + _ => "[ ]", + }; + let duration = item.duration_seconds.map(|s| format!(" - Duration: {}s", s)).unwrap_or_default(); + md.push_str(&format!(" - {} {}{}\n", item_checkbox, item.name, duration)); + } + } + + // Render section-level item groups + for group in §ion.item_groups { + let group_checkbox = match group.status { + ItemStatus::Completed => "[x]", + ItemStatus::Running => "[>]", + _ => "[ ]", + }; + let duration = group.duration_seconds.map(|s| format!(" - Duration: {} min", s / 60)).unwrap_or_default(); + md.push_str(&format!(" - {} {}{}\n", group_checkbox, group.display_name(), duration)); + } + + for item in §ion.items { + let item_checkbox = match item.status { + ItemStatus::Completed => "[x]", + ItemStatus::Running => "[>]", + _ => "[ ]", + }; + md.push_str(&format!(" - {} {}\n", item_checkbox, item.name)); + } + } + md + } + +} + +impl ManifestSection { + pub fn new(name: &str, section_type: SectionType) -> Self { + Self { + id: Uuid::new_v4().to_string(), + name: name.to_string(), + section_type, + status: SectionStatus::Pending, + current_step: 0, + total_steps: 0, + global_step_start: 0, + duration_seconds: None, + started_at: None, + completed_at: None, + items: Vec::new(), + item_groups: Vec::new(), + children: Vec::new(), + } + } + + pub fn with_steps(mut self, total: u32) -> Self { + self.total_steps = total; + self + } + + pub fn add_item(&mut self, item: ManifestItem) { + self.total_steps += 1; + self.items.push(item); + } + + pub fn add_item_group(&mut self, group: ItemGroup) { + self.total_steps += 1; + self.item_groups.push(group); + } + + pub fn add_child(&mut self, child: ManifestSection) { + self.total_steps += child.total_steps; + self.children.push(child); + } + + pub fn start(&mut self) { + self.status = SectionStatus::Running; + self.started_at = Some(Utc::now()); + } + + pub fn complete(&mut self) { + self.status = SectionStatus::Completed; + self.completed_at = Some(Utc::now()); + self.current_step = self.total_steps; + if let Some(started) = self.started_at { + self.duration_seconds = Some((Utc::now() - started).num_seconds() as u64); + } + } + + pub fn increment_step(&mut self) { + self.current_step += 1; + } +} + +impl ManifestItem { + pub fn new(name: &str, item_type: ItemType) -> Self { + Self { + id: Uuid::new_v4().to_string(), + name: name.to_string(), + item_type, + status: ItemStatus::Pending, + details: None, + duration_seconds: None, + started_at: None, + completed_at: None, + metadata: HashMap::new(), + } + } + + pub fn with_details(mut self, details: &str) -> Self { + self.details = Some(details.to_string()); + self + } + + pub fn with_metadata(mut self, key: &str, value: serde_json::Value) -> Self { + self.metadata.insert(key.to_string(), value); + self + } + + pub fn start(&mut self) { + self.status = ItemStatus::Running; + self.started_at = Some(Utc::now()); + } + + pub fn complete(&mut self) { + self.status = ItemStatus::Completed; + self.completed_at = Some(Utc::now()); + if let Some(started) = self.started_at { + self.duration_seconds = Some((Utc::now() - started).num_seconds() as u64); + } + } +} + +fn section_to_web_json(section: &ManifestSection) -> serde_json::Value { + let checkbox = match section.status { + SectionStatus::Completed => "[x]", + SectionStatus::Running => "[>]", + _ => "[ ]", + }; + + // Calculate global step display (e.g., "Step 24/60") + let global_current = section.global_step_start + section.current_step; + + serde_json::json!({ + "id": section.id, + "name": section.name, + "checkbox": checkbox, + "type": format!("{:?}", section.section_type), + "status": format!("{:?}", section.status), + "progress": { + "current": section.current_step, + "total": section.total_steps, + "display": format!("Step {}/{}", section.current_step, section.total_steps), + "global_current": global_current, + "global_start": section.global_step_start + }, + "duration": section.duration_seconds.map(|d| format_duration(d)), + "duration_seconds": section.duration_seconds, + "items": section.items.iter().map(|i| { + let item_checkbox = match i.status { + ItemStatus::Completed => "[x]", + ItemStatus::Running => "[>]", + _ => "[ ]", + }; + serde_json::json!({ + "id": i.id, + "name": i.name, + "checkbox": item_checkbox, + "type": format!("{:?}", i.item_type), + "status": format!("{:?}", i.status), + "details": i.details, + "duration": i.duration_seconds.map(|d| format_duration(d)), + "duration_seconds": i.duration_seconds + }) + }).collect::>(), + "item_groups": section.item_groups.iter().map(|g| { + let group_checkbox = match g.status { + ItemStatus::Completed => "[x]", + ItemStatus::Running => "[>]", + _ => "[ ]", + }; + serde_json::json!({ + "id": g.id, + "name": g.display_name(), + "items": g.items, + "checkbox": group_checkbox, + "status": format!("{:?}", g.status), + "duration": g.duration_seconds.map(|d| format_duration(d)), + "duration_seconds": g.duration_seconds + }) + }).collect::>(), + "children": section.children.iter().map(|c| section_to_web_json(c)).collect::>() + }) +} + +fn format_duration(seconds: u64) -> String { + if seconds < 60 { + format!("{} sec", seconds) + } else if seconds < 3600 { + format!("{} min", seconds / 60) + } else { + let hours = seconds / 3600; + let mins = (seconds % 3600) / 60; + format!("{} hr {} min", hours, mins) + } +} + +pub struct ManifestBuilder { + manifest: TaskManifest, +} + +impl ManifestBuilder { + pub fn new(app_name: &str, description: &str) -> Self { + Self { + manifest: TaskManifest::new(app_name, description), + } + } + + pub fn with_tables(mut self, tables: Vec) -> Self { + if tables.is_empty() { + return self; + } + + let mut db_section = ManifestSection::new("Database & Models", SectionType::DatabaseModels); + + let mut schema_section = + ManifestSection::new("Database Schema Design", SectionType::SchemaDesign); + + // Each table becomes an item in the schema section + for table in &tables { + let field_count = table.fields.len(); + let field_names: Vec = table.fields.iter().take(4).map(|f| f.name.clone()).collect(); + let fields_preview = if field_count > 4 { + format!("{}, +{} more", field_names.join(", "), field_count - 4) + } else { + field_names.join(", ") + }; + + let mut item = ManifestItem::new(&table.name, ItemType::Table); + item.details = Some(format!("{} fields: {}", field_count, fields_preview)); + schema_section.add_item(item); + } + + db_section.add_child(schema_section); + self.manifest.add_section(db_section); + self + } + + pub fn with_files(mut self, files: Vec) -> Self { + if files.is_empty() { + return self; + } + + let mut files_section = ManifestSection::new("Files", SectionType::Files); + + // Group files by type for better organization + let html_files: Vec<_> = files.iter().filter(|f| f.filename.ends_with(".html")).collect(); + let css_files: Vec<_> = files.iter().filter(|f| f.filename.ends_with(".css")).collect(); + let js_files: Vec<_> = files.iter().filter(|f| f.filename.ends_with(".js")).collect(); + + // Create child section for HTML pages + if !html_files.is_empty() { + let mut pages_child = ManifestSection::new("HTML Pages", SectionType::Pages); + for file in &html_files { + let item = ManifestItem::new(&file.filename, ItemType::Page); + pages_child.add_item(item); + } + files_section.add_child(pages_child); + } + + // Create child section for styles + if !css_files.is_empty() { + let mut styles_child = ManifestSection::new("Stylesheets", SectionType::Files); + for file in &css_files { + let item = ManifestItem::new(&file.filename, ItemType::File); + styles_child.add_item(item); + } + files_section.add_child(styles_child); + } + + // Create child section for scripts + if !js_files.is_empty() { + let mut scripts_child = ManifestSection::new("Scripts", SectionType::Files); + for file in &js_files { + let item = ManifestItem::new(&file.filename, ItemType::File); + scripts_child.add_item(item); + } + files_section.add_child(scripts_child); + } + + self.manifest.add_section(files_section); + self + } + + pub fn with_pages(mut self, _pages: Vec) -> Self { + // Pages are now included in Files section as HTML Pages child + self + } + + pub fn with_tools(mut self, tools: Vec) -> Self { + if tools.is_empty() { + return self; + } + + let mut tools_section = ManifestSection::new("Tools & Automation", SectionType::Tools); + + let mut automation_child = ManifestSection::new("BASIC Scripts", SectionType::Tools); + for tool in tools { + let item = ManifestItem::new(&tool.name, ItemType::Tool) + .with_details(&tool.filename); + automation_child.add_item(item); + } + tools_section.add_child(automation_child); + + self.manifest.add_section(tools_section); + self + } + + pub fn with_schedulers(mut self, schedulers: Vec) -> Self { + if schedulers.is_empty() { + return self; + } + + let mut sched_section = ManifestSection::new("Scheduled Tasks", SectionType::Schedulers); + + let mut cron_child = ManifestSection::new("Cron Jobs", SectionType::Schedulers); + for scheduler in schedulers { + let item = ManifestItem::new(&scheduler.name, ItemType::Scheduler) + .with_details(&scheduler.schedule); + cron_child.add_item(item); + } + sched_section.add_child(cron_child); + + self.manifest.add_section(sched_section); + self + } + + pub fn with_monitors(mut self, monitors: Vec) -> Self { + if monitors.is_empty() { + return self; + } + + let mut mon_section = ManifestSection::new("Monitoring", SectionType::Monitors); + + let mut alerts_child = ManifestSection::new("Alert Rules", SectionType::Monitors); + for monitor in monitors { + let item = + ManifestItem::new(&monitor.name, ItemType::Monitor).with_details(&monitor.target); + alerts_child.add_item(item); + } + mon_section.add_child(alerts_child); + + self.manifest.add_section(mon_section); + self + } + + pub fn with_estimated_time(mut self, seconds: u64) -> Self { + self.manifest.estimated_seconds = seconds; + self + } + + pub fn build(mut self) -> TaskManifest { + self.manifest.status = ManifestStatus::Ready; + self.manifest + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TableDefinition { + pub name: String, + pub fields: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldDefinition { + pub name: String, + pub field_type: String, + pub nullable: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileDefinition { + pub filename: String, + pub size_estimate: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageDefinition { + pub filename: String, + pub page_type: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolDefinition { + pub name: String, + pub filename: String, + pub triggers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SchedulerDefinition { + pub name: String, + pub filename: String, + pub schedule: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitorDefinition { + pub name: String, + pub target: String, +} + +pub fn create_manifest_from_llm_response( + app_name: &str, + description: &str, + tables: Vec, + files: Vec, + pages: Vec, + tools: Vec, + schedulers: Vec, + monitors: Vec, +) -> TaskManifest { + let estimated_time = estimate_generation_time(&tables, &files, &tools, &schedulers); + + ManifestBuilder::new(app_name, description) + .with_tables(tables) + .with_files(files) + .with_pages(pages) + .with_tools(tools) + .with_schedulers(schedulers) + .with_monitors(monitors) + .with_estimated_time(estimated_time) + .build() +} + +fn estimate_generation_time( + tables: &[TableDefinition], + files: &[FileDefinition], + tools: &[ToolDefinition], + schedulers: &[SchedulerDefinition], +) -> u64 { + let table_time: u64 = tables.iter().map(|t| 5 + t.fields.len() as u64).sum(); + let file_time: u64 = files.len() as u64 * 3; + let tool_time: u64 = tools.len() as u64 * 10; + let sched_time: u64 = schedulers.len() as u64 * 5; + + table_time + file_time + tool_time + sched_time + 30 +} diff --git a/src/basic/keywords/app_server.rs b/src/basic/keywords/app_server.rs index 01ebb7c4e..12506cf08 100644 --- a/src/basic/keywords/app_server.rs +++ b/src/basic/keywords/app_server.rs @@ -1,4 +1,4 @@ -use crate::core::shared::{get_content_type, sanitize_path_component}; +use crate::core::shared::get_content_type; use crate::shared::state::AppState; use axum::{ body::Body, @@ -35,7 +35,14 @@ pub struct AppFilePath { pub async fn serve_app_index( State(state): State>, Path(params): Path, + original_uri: axum::extract::OriginalUri, ) -> impl IntoResponse { + // Redirect to trailing slash so relative paths resolve correctly + // /apps/calc-pro -> /apps/calc-pro/ + let path = original_uri.path(); + if !path.ends_with('/') { + return axum::response::Redirect::permanent(&format!("{}/", path)).into_response(); + } serve_app_file_internal(&state, ¶ms.app_name, "index.html").await } @@ -46,9 +53,31 @@ pub async fn serve_app_file( serve_app_file_internal(&state, ¶ms.app_name, ¶ms.file_path).await } +/// Sanitize app name - only alphanumeric, underscore, hyphen allowed +fn sanitize_app_name(name: &str) -> String { + name.chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-') + .collect::() +} + +/// Sanitize file path - preserve directory structure but remove dangerous characters +fn sanitize_file_path(path: &str) -> String { + path.split('/') + .filter(|segment| !segment.is_empty() && *segment != ".." && *segment != ".") + .map(|segment| { + segment + .chars() + .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-' || *c == '.') + .collect::() + }) + .filter(|s| !s.is_empty()) + .collect::>() + .join("/") +} + async fn serve_app_file_internal(state: &AppState, app_name: &str, file_path: &str) -> Response { - let sanitized_app_name = sanitize_path_component(app_name); - let sanitized_file_path = sanitize_path_component(file_path); + let sanitized_app_name = sanitize_app_name(app_name); + let sanitized_file_path = sanitize_file_path(file_path); if sanitized_app_name.is_empty() || sanitized_file_path.is_empty() { return (StatusCode::BAD_REQUEST, "Invalid path").into_response(); @@ -213,4 +242,22 @@ mod tests { assert_eq!(get_content_type("image.png"), "image/png"); assert_eq!(get_content_type("unknown.xyz"), "application/octet-stream"); } + + #[test] + fn test_sanitize_app_name() { + assert_eq!(sanitize_app_name("my-app"), "my-app"); + assert_eq!(sanitize_app_name("my_app_123"), "my_app_123"); + assert_eq!(sanitize_app_name("../hack"), "hack"); + assert_eq!(sanitize_app_name("app