Fix task progress: real-time updates, MIME types, WebSocket event types

- Fix MIME type for app files by preserving directory structure in sanitize_file_path()
- Add with_event_type() to TaskProgressEvent for correct WebSocket event types
- broadcast_manifest_update() now sends 'manifest_update' type correctly
- update_item_status() broadcasts automatically for real-time file progress
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-31 23:45:29 -03:00
parent bad6ebd501
commit 0385047c5c
13 changed files with 2286 additions and 351 deletions

View file

@ -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,

View file

@ -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<String>,
tables_synced: Vec<String>,
bytes_generated: u64,
manifest: Option<TaskManifest>,
}
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<ManifestTable> = 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<FileDefinition> = llm_app
.files
.iter()
.map(|f| FileDefinition {
filename: f.filename.clone(),
size_estimate: f.content.len() as u64,
})
.collect();
let pages: Vec<PageDefinition> = 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<ToolDefinition> = llm_app
.tools
.iter()
.map(|t| ToolDefinition {
name: t.filename.replace(".bas", ""),
filename: t.filename.clone(),
triggers: vec![],
})
.collect();
let schedulers: Vec<SchedulerDefinition> = llm_app
.schedulers
.iter()
.map(|s| SchedulerDefinition {
name: s.filename.replace(".bas", ""),
filename: s.filename.clone(),
schedule: "".to_string(),
})
.collect();
let monitors: Vec<MonitorDefinition> = 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::<Vec<_>>()
.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<String> = 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<dyn std::error::Error + Send + Sync>> {
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<String, Box<dyn std::error::Error + Send + Sync>> {
let mut conn = self.state.conn.get()?;
#[derive(QueryableByName)]
struct BotRow {
#[diesel(sql_type = diesel::sql_types::Text)]
name: String,
}
let result: Vec<BotRow> = sql_query("SELECT name FROM bots WHERE id = $1 LIMIT 1")
.bind::<diesel::sql_types::Uuid, _>(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<dyn std::error::Error + Send + Sync>> {
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::<diesel::sql_types::Jsonb, _>(final_step_results)
.bind::<diesel::sql_types::Uuid, _>(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<dyn std::error::Error + Send + Sync>> {
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::<diesel::sql_types::Uuid, _>(app_id)
.bind::<diesel::sql_types::Uuid, _>(bot_id)
.bind::<diesel::sql_types::Text, _>(app_name)
.bind::<diesel::sql_types::Text, _>(app_path)
.execute(&mut conn)?;
Ok(())
}
fn generate_designer_js(app_name: &str) -> String {
format!(
r#"(function() {{

View file

@ -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<Arc<AppState>>,
Path(task_id): Path<String>,
) -> 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<AppState>, task_id: &str) -> Option<TaskManifest> {
let manifests = state.task_manifests.read().ok()?;
manifests.get(task_id).cloned()
}
fn get_task_logs(_state: &Arc<AppState>, task_id: &str) -> Vec<serde_json::Value> {
// TODO: Fetch from database when task execution is implemented
vec![

View file

@ -597,7 +597,7 @@ Respond with JSON only:
fn handle_todo(
&self,
classification: &ClassifiedIntent,
session: &UserSession,
_session: &UserSession,
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
info!("Handling TODO intent");

View file

@ -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<std::sync::Arc<crate::shared:
&ApiUrls::AUTOTASK_LOGS.replace(":task_id", "{task_id}"),
get(get_task_logs_handler),
)
.route(
"/api/autotask/{task_id}/manifest",
get(get_manifest_handler),
)
.route(
&ApiUrls::AUTOTASK_RECOMMENDATIONS_APPLY.replace(":rec_id", "{rec_id}"),
post(apply_recommendation_handler),
@ -236,7 +247,8 @@ async fn handle_task_progress_websocket(
debug!("Received binary from task progress WebSocket (ignored)");
}
Err(e) => {
error!("Task progress WebSocket error: {}", e);
// TLS close_notify errors are normal when browser tab closes
debug!("Task progress WebSocket closed: {}", e);
break;
}
}

View file

@ -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<Utc>,
pub updated_at: DateTime<Utc>,
pub status: ManifestStatus,
pub current_status: CurrentStatus,
pub sections: Vec<ManifestSection>,
pub total_steps: u32,
pub completed_steps: u32,
pub runtime_seconds: u64,
pub estimated_seconds: u64,
pub terminal_output: Vec<TerminalLine>,
pub processing_stats: ProcessingStats,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CurrentStatus {
pub title: String,
pub current_action: Option<String>,
pub decision_point: Option<DecisionPoint>,
}
#[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<u64>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub items: Vec<ManifestItem>,
pub item_groups: Vec<ItemGroup>,
pub children: Vec<ManifestSection>,
}
#[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<String>,
pub duration_seconds: Option<u64>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub metadata: HashMap<String, serde_json::Value>,
}
/// 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<String>,
pub status: ItemStatus,
pub duration_seconds: Option<u64>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
impl ItemGroup {
pub fn new(items: Vec<String>) -> 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<Utc>,
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 &section.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 &section.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 &section.items {
md.push_str(&format!(
" - {} ({:?}): {:?}\n",
item.name, item.item_type, item.status
));
}
}
for child in &section.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::<Vec<_>>(),
"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::<Vec<_>>(),
"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 &section.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 &section.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 &section.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::<Vec<_>>(),
"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::<Vec<_>>(),
"children": section.children.iter().map(|c| section_to_web_json(c)).collect::<Vec<_>>()
})
}
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<TableDefinition>) -> 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<String> = 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<FileDefinition>) -> 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<PageDefinition>) -> Self {
// Pages are now included in Files section as HTML Pages child
self
}
pub fn with_tools(mut self, tools: Vec<ToolDefinition>) -> 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<SchedulerDefinition>) -> 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<MonitorDefinition>) -> 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<FieldDefinition>,
}
#[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<String>,
}
#[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<TableDefinition>,
files: Vec<FileDefinition>,
pages: Vec<PageDefinition>,
tools: Vec<ToolDefinition>,
schedulers: Vec<SchedulerDefinition>,
monitors: Vec<MonitorDefinition>,
) -> 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
}

View file

@ -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<Arc<AppState>>,
Path(params): Path<AppPath>,
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, &params.app_name, "index.html").await
}
@ -46,9 +53,31 @@ pub async fn serve_app_file(
serve_app_file_internal(&state, &params.app_name, &params.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::<String>()
}
/// 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::<String>()
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.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<script>"), "appscript");
}
#[test]
fn test_sanitize_file_path() {
assert_eq!(sanitize_file_path("styles.css"), "styles.css");
assert_eq!(sanitize_file_path("css/styles.css"), "css/styles.css");
assert_eq!(sanitize_file_path("assets/img/logo.png"), "assets/img/logo.png");
assert_eq!(sanitize_file_path("../../../etc/passwd"), "etc/passwd");
assert_eq!(sanitize_file_path("./styles.css"), "styles.css");
assert_eq!(sanitize_file_path("path//double//slash.js"), "path/double/slash.js");
}
}

View file

@ -1,3 +1,4 @@
use crate::auto_task::TaskManifest;
use crate::core::bot::channels::{ChannelAdapter, VoiceAdapter, WebChannelAdapter};
use crate::core::config::AppConfig;
use crate::core::kb::KnowledgeBaseManager;
@ -217,6 +218,12 @@ impl TaskProgressEvent {
self
}
#[must_use]
pub fn with_event_type(mut self, event_type: impl Into<String>) -> Self {
self.event_type = event_type.into();
self
}
#[must_use]
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.event_type = "task_error".to_string();
@ -337,6 +344,7 @@ pub struct AppState {
pub extensions: Extensions,
pub attendant_broadcast: Option<broadcast::Sender<AttendantNotification>>,
pub task_progress_broadcast: Option<broadcast::Sender<TaskProgressEvent>>,
pub task_manifests: Arc<std::sync::RwLock<HashMap<String, TaskManifest>>>,
}
impl Clone for AppState {
@ -367,6 +375,7 @@ impl Clone for AppState {
extensions: self.extensions.clone(),
attendant_broadcast: self.attendant_broadcast.clone(),
task_progress_broadcast: self.task_progress_broadcast.clone(),
task_manifests: Arc::clone(&self.task_manifests),
}
}
}
@ -550,6 +559,7 @@ impl Default for AppState {
extensions: Extensions::new(),
attendant_broadcast: Some(attendant_tx),
task_progress_broadcast: Some(task_progress_tx),
task_manifests: Arc::new(std::sync::RwLock::new(HashMap::new())),
}
}
}

View file

@ -186,6 +186,8 @@ impl TestAppStateBuilder {
let (attendant_tx, _) = broadcast::channel(100);
let (task_progress_tx, _) = broadcast::channel(100);
Ok(AppState {
#[cfg(feature = "drive")]
drive: None,
@ -211,6 +213,8 @@ impl TestAppStateBuilder {
task_engine: Arc::new(TaskEngine::new(pool)),
extensions: Extensions::new(),
attendant_broadcast: Some(attendant_tx),
task_progress_broadcast: Some(task_progress_tx),
task_manifests: Arc::new(std::sync::RwLock::new(HashMap::new())),
})
}
}

View file

@ -1154,35 +1154,31 @@ Respond with valid JSON only."#,
}
async fn call_designer_llm(
_state: &AppState,
state: &AppState,
prompt: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let llm_url = std::env::var("LLM_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let llm_model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "llama3.2".to_string());
use crate::core::config::ConfigManager;
let client = reqwest::Client::new();
let config_manager = ConfigManager::new(state.conn.clone());
let response = client
.post(format!("{}/api/generate", llm_url))
.json(&serde_json::json!({
"model": llm_model,
"prompt": prompt,
"stream": false,
"options": {
"temperature": 0.3,
"num_predict": 2000
}
}))
.send()
.await?;
// Get LLM configuration from bot config or use defaults
let model = config_manager
.get_config(&uuid::Uuid::nil(), "llm-model", Some("claude-sonnet-4-20250514"))
.unwrap_or_else(|_| "claude-sonnet-4-20250514".to_string());
if !response.status().is_success() {
let status = response.status();
return Err(format!("LLM request failed: {status}").into());
}
let api_key = config_manager
.get_config(&uuid::Uuid::nil(), "llm-key", None)
.unwrap_or_default();
let result: serde_json::Value = response.json().await?;
let response_text = result["response"].as_str().unwrap_or("{}").to_string();
let system_prompt = "You are a web designer AI. Respond only with valid JSON.";
let messages = serde_json::json!({
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
]
});
let response_text = state.llm_provider.generate(prompt, &messages, &model, &api_key).await?;
let json_text = if response_text.contains("```json") {
response_text
@ -1291,36 +1287,43 @@ async fn apply_file_change(
.select(name)
.first(&mut conn)?;
let bucket_name = format!("{}.gbai", bot_name_val.to_lowercase());
let file_path = format!(".gbdrive/apps/{app_name}/{file_name}");
let site_path = state
.config
.as_ref()
.map(|c| c.site_path.clone())
.unwrap_or_else(|| "./botserver-stack/sites".to_string());
let local_path = format!("{site_path}/{app_name}/{file_name}");
if let Some(parent) = std::path::Path::new(&local_path).parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&local_path, content)?;
log::info!("Designer updated local file: {local_path}");
if let Some(ref s3_client) = state.drive {
use aws_sdk_s3::primitives::ByteStream;
s3_client
let bucket_name = format!("{}.gbai", bot_name_val.to_lowercase());
// Use same path pattern as app_generator: bucket.gbapp/app_name/file
let sanitized_bucket = bucket_name.trim_end_matches(".gbai");
let file_path = format!("{}.gbapp/{app_name}/{file_name}", sanitized_bucket);
match s3_client
.put_object()
.bucket(&bucket_name)
.key(&file_path)
.body(ByteStream::from(content.as_bytes().to_vec()))
.content_type(get_content_type(file_name))
.send()
.await?;
log::info!("Designer updated file: s3://{bucket_name}/{file_path}");
let site_path = state
.config
.as_ref()
.map(|c| c.site_path.clone())
.unwrap_or_else(|| "./botserver-stack/sites".to_string());
let local_path = format!("{site_path}/{app_name}/{file_name}");
if let Some(parent) = std::path::Path::new(&local_path).parent() {
let _ = std::fs::create_dir_all(parent);
.await
{
Ok(_) => {
log::info!("Designer synced to S3: s3://{bucket_name}/{file_path}");
}
Err(e) => {
log::warn!("Designer failed to sync to S3 (local write succeeded): {e}");
}
}
std::fs::write(&local_path, content)?;
log::info!("Designer synced to local: {local_path}");
}
Ok(())

View file

@ -1,6 +1,6 @@
use async_trait::async_trait;
use futures_util::StreamExt;
use log::{error, info, trace, warn};
use log::{error, trace, warn};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;

View file

@ -869,6 +869,7 @@ async fn main() -> std::io::Result<()> {
},
attendant_broadcast: Some(attendant_tx),
task_progress_broadcast: Some(task_progress_tx),
task_manifests: Arc::new(std::sync::RwLock::new(HashMap::new())),
});
let task_scheduler = Arc::new(botserver::tasks::scheduler::TaskScheduler::new(

View file

@ -1,5 +1,6 @@
pub mod scheduler;
use crate::auto_task::TaskManifest;
use crate::core::urls::ApiUrls;
use axum::{
extract::{Path, Query, State},
@ -352,9 +353,17 @@ pub async fn handle_task_delete(
pub async fn handle_task_get(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
log::info!("[TASK_GET] *** Handler called for task: {} ***", id);
// Check if client wants JSON (for polling) vs HTML (for HTMX)
let wants_json = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.map(|v| v.contains("application/json"))
.unwrap_or(false);
let conn = state.conn.clone();
let task_id = id.clone();
@ -430,7 +439,31 @@ pub async fn handle_task_get(
match result {
Ok(Some(task)) => {
log::info!("[TASK_GET] Returning task: {} - {}", task.id, task.title);
log::info!("[TASK_GET] Returning task: {} - {} (wants_json={})", task.id, task.title, wants_json);
// Return JSON for API polling clients
if wants_json {
return (
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
serde_json::json!({
"id": task.id.to_string(),
"title": task.title,
"status": task.status,
"priority": task.priority,
"intent": task.intent,
"error": task.error,
"progress": (task.progress * 100.0) as u8,
"current_step": task.current_step,
"total_steps": task.total_steps,
"created_at": task.created_at.to_rfc3339(),
"started_at": task.started_at.map(|t| t.to_rfc3339()),
"completed_at": task.completed_at.map(|t| t.to_rfc3339())
}).to_string()
).into_response();
}
// Return HTML for HTMX
let status_class = match task.status.as_str() {
"completed" | "done" => "completed",
"running" | "pending" => "running",
@ -438,7 +471,6 @@ pub async fn handle_task_get(
_ => "pending"
};
let progress_percent = (task.progress * 100.0) as u8;
let created = task.created_at.format("%Y-%m-%d %H:%M").to_string();
// Calculate runtime
let runtime = if let Some(started) = task.started_at {
@ -464,9 +496,6 @@ pub async fn handle_task_get(
</div>"#, e
)).unwrap_or_default();
let current_step = task.current_step;
let total_steps = if task.total_steps > 0 { task.total_steps } else { 1 };
let status_label = match task.status.as_str() {
"completed" | "done" => "Completed",
"running" => "Running",
@ -477,116 +506,109 @@ pub async fn handle_task_get(
_ => &task.status
};
// Build progress log HTML from step_results
let progress_log_html = build_progress_log_html(&task.step_results, current_step, total_steps);
// Build terminal output from recent activity
let terminal_html = build_terminal_html(&task.step_results, &task.status);
let task_url = format!("/tasks/{}", task_id);
// Extract app_url from step_results if task is completed
let app_url = if task.status == "completed" || task.status == "done" {
extract_app_url_from_results(&task.step_results, &task.title)
} else {
None
};
let app_button_html = app_url.map(|url| format!(
r#"<a href="{}" target="_blank" class="btn-action-rich btn-open-app" rel="noopener noreferrer">
<span class="btn-icon">🚀</span> Open App
</a>"#,
url
)).unwrap_or_default();
let cancel_button_html = match task.status.as_str() {
"completed" | "done" | "failed" | "error" => String::new(),
_ => format!(
r#"<button class="btn-action-rich btn-cancel" onclick="cancelTask('{task_id}')">
<span class="btn-icon"></span> Cancel
</button>"#
),
};
let (status_html, progress_log_html) = build_taskmd_html(&state, &task_id, &task.title, &runtime);
let html = format!(r#"
<div class="task-detail-rich" data-task-id="{task_id}">
<!-- Header with title and status badge -->
<div class="detail-header-rich">
<h2 class="detail-title-rich">{title}</h2>
<span class="status-badge-rich status-{status_class}">{status_label}</span>
<!-- Header -->
<div class="taskmd-header">
<div class="taskmd-url">
<span class="url-icon">🔗</span>
<span class="url-path">{task_url}</span>
</div>
<h1 class="taskmd-title">{title}</h1>
<span class="taskmd-status-badge status-{status_class}">{status_label}</span>
</div>
<!-- Status Section -->
<div class="detail-section-box status-section">
<div class="section-label">STATUS</div>
<div class="status-content">
<div class="status-main">
<span class="status-dot status-{status_class}"></span>
<span class="status-text">{title}</span>
</div>
<div class="status-meta">
<span class="meta-runtime">Runtime: {runtime}</span>
<span class="meta-estimated">Step {current_step}/{total_steps}</span>
</div>
</div>
{error_html}
<div class="status-details">
<div class="status-row">
<span class="status-indicator {status_indicator}"></span>
<span class="status-step-name">{status_label} (Step {current_step}/{total_steps})</span>
<span class="status-step-note">{priority} priority</span>
</div>
{error_html}
<!-- STATUS Section -->
<div class="taskmd-section">
<div class="taskmd-section-header">STATUS</div>
<div class="taskmd-status-content">
{status_html}
</div>
</div>
<!-- Progress Bar -->
<div class="detail-progress-rich">
<div class="progress-bar-rich">
<div class="progress-fill-rich" style="width: {progress_percent}%"></div>
</div>
<div class="progress-info-rich">
<span class="progress-label-rich">Progress: {progress_percent}%</span>
</div>
</div>
<!-- Progress Log Section -->
<div class="detail-section-box progress-log-section">
<div class="section-label">PROGRESS LOG</div>
<div class="progress-log-content" id="progress-log-{task_id}">
<!-- PROGRESS LOG Section -->
<div class="taskmd-section">
<div class="taskmd-section-header">PROGRESS LOG</div>
<div class="taskmd-progress-content" id="progress-log-{task_id}">
{progress_log_html}
</div>
</div>
<!-- Terminal Section -->
<div class="detail-section-box terminal-section-rich">
<div class="section-header-rich">
<div class="section-label">
<span class="terminal-dot-rich {terminal_active}"></span>
TERMINAL (LIVE AGENT ACTIVITY)
<!-- TERMINAL Section -->
<div class="taskmd-section taskmd-terminal">
<div class="taskmd-terminal-header">
<div class="taskmd-terminal-title">
<span class="terminal-dot {terminal_active}"></span>
<span>TERMINAL (LIVE AGENT ACTIVITY)</span>
</div>
<div class="terminal-stats-rich">
<span>Step: <strong>{current_step}</strong> of <strong>{total_steps}</strong></span>
<div class="taskmd-terminal-stats">
<span>Processed: <strong id="terminal-processed-{task_id}">{processed_count}</strong> items</span>
<span class="stat-sep">|</span>
<span>Speed: <strong>{processing_speed}</strong></span>
<span class="stat-sep">|</span>
<span>ETA: <strong id="terminal-eta-{task_id}">{eta_display}</strong></span>
</div>
</div>
<div class="terminal-output-rich" id="terminal-output-{task_id}">
<div class="taskmd-terminal-output" id="terminal-output-{task_id}">
{terminal_html}
</div>
<div class="terminal-footer-rich">
<span class="terminal-eta">Started: <strong>{created}</strong></span>
</div>
</div>
<!-- Intent Section -->
<div class="detail-section-box intent-section">
<div class="section-label">INTENT</div>
<p class="intent-text-rich">{intent_text}</p>
</div>
<!-- Actions -->
<div class="detail-actions-rich">
<button class="btn-action-rich btn-pause" onclick="pauseTask('{task_id}')">
<span class="btn-icon"></span> Pause
</button>
<button class="btn-action-rich btn-cancel" onclick="cancelTask('{task_id}')">
<span class="btn-icon"></span> Cancel
</button>
<button class="btn-action-rich btn-detailed" onclick="showDetailedView('{task_id}')">
Detailed View
</button>
<div class="taskmd-actions">
{app_button_html}
{cancel_button_html}
</div>
</div>
"#,
task_id = task_id,
task_url = task_url,
title = task.title,
status_class = status_class,
status_label = status_label,
runtime = runtime,
current_step = current_step,
total_steps = total_steps,
error_html = error_html,
status_indicator = if task.status == "running" { "active" } else { "" },
priority = task.priority,
progress_percent = progress_percent,
status_html = status_html,
progress_log_html = progress_log_html,
terminal_active = if task.status == "running" { "active" } else { "" },
terminal_html = terminal_html,
created = created,
intent_text = intent_text,
app_button_html = app_button_html,
cancel_button_html = cancel_button_html,
processed_count = get_manifest_processed_count(&state, &task_id),
processing_speed = get_manifest_speed(&state, &task_id),
eta_display = get_manifest_eta(&state, &task_id),
);
(StatusCode::OK, axum::response::Html(html)).into_response()
}
@ -601,165 +623,385 @@ pub async fn handle_task_get(
}
}
/// Build HTML for the progress log section from step_results JSON
fn build_progress_log_html(step_results: &Option<serde_json::Value>, current_step: i32, total_steps: i32) -> String {
let mut html = String::new();
fn extract_app_url_from_results(step_results: &Option<serde_json::Value>, title: &str) -> Option<String> {
if let Some(serde_json::Value::Array(steps)) = step_results {
if steps.is_empty() {
// No steps yet - show current status
html.push_str(&format!(r#"
<div class="log-group">
<div class="log-group-header">
<span class="log-group-name">Task Execution</span>
<span class="log-step-badge">Step {}/{}</span>
<span class="log-status-badge running">In Progress</span>
</div>
<div class="log-group-items">
<div class="log-item">
<span class="log-dot running"></span>
<span class="log-item-name">Waiting for execution steps...</span>
</div>
</div>
</div>
"#, current_step, total_steps));
} else {
// Group steps and show real data
for (idx, step) in steps.iter().enumerate() {
let step_name = step.get("step_name")
.and_then(|v| v.as_str())
.unwrap_or("Step");
let step_status = step.get("status")
.and_then(|v| v.as_str())
.unwrap_or("pending");
let step_order = step.get("step_order")
.and_then(|v| v.as_i64())
.unwrap_or((idx + 1) as i64);
let duration_ms = step.get("duration_ms")
.and_then(|v| v.as_i64());
let status_class = match step_status {
"completed" | "Completed" => "completed",
"running" | "Running" => "running",
"failed" | "Failed" => "failed",
_ => "pending"
};
let duration_str = duration_ms.map(|ms| {
if ms > 60000 {
format!("{}m {}s", ms / 60000, (ms % 60000) / 1000)
} else if ms > 1000 {
format!("{}s", ms / 1000)
} else {
format!("{}ms", ms)
}
}).unwrap_or_else(|| "--".to_string());
html.push_str(&format!(r#"
<div class="log-item">
<span class="log-dot {status_class}"></span>
<span class="log-item-name">{step_name}</span>
<span class="log-item-badge">Step {step_order}/{total_steps}</span>
<span class="log-item-status">{step_status}</span>
<span class="log-duration">Duration: {duration_str}</span>
</div>
"#,
status_class = status_class,
step_name = step_name,
step_order = step_order,
total_steps = total_steps,
step_status = step_status,
duration_str = duration_str,
));
// Show logs if present
if let Some(serde_json::Value::Array(logs)) = step.get("logs") {
for log_entry in logs.iter().take(3) {
let msg = log_entry.get("message")
.and_then(|v| v.as_str())
.unwrap_or("");
if !msg.is_empty() {
html.push_str(&format!(r#"
<div class="log-subitem">
<span class="log-subdot {status_class}"></span>
<span class="log-subitem-name">{msg}</span>
</div>
"#, status_class = status_class, msg = msg));
for step in steps.iter() {
if let Some(logs) = step.get("logs").and_then(|v| v.as_array()) {
for log in logs.iter() {
if let Some(msg) = log.get("message").and_then(|v| v.as_str()) {
if msg.contains("/apps/") {
if let Some(start) = msg.find("/apps/") {
let rest = &msg[start..];
let end = rest.find(|c: char| c.is_whitespace() || c == '"' || c == '\'').unwrap_or(rest.len());
let url = rest[..end].to_string();
// Add trailing slash if not present
if url.ends_with('/') {
return Some(url);
} else {
return Some(format!("{}/", url));
}
}
}
}
}
}
}
}
let app_name = title
.to_lowercase()
.replace(' ', "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect::<String>();
if !app_name.is_empty() {
Some(format!("/apps/{}/", app_name))
} else {
// No step results - show placeholder based on current progress
None
}
}
// Helper functions to get real manifest stats
fn get_manifest_processed_count(state: &Arc<AppState>, task_id: &str) -> String {
if let Ok(manifests) = state.task_manifests.read() {
if let Some(manifest) = manifests.get(task_id) {
return manifest.processing_stats.data_points_processed.to_string();
}
}
"0".to_string()
}
fn get_manifest_speed(state: &Arc<AppState>, task_id: &str) -> String {
if let Ok(manifests) = state.task_manifests.read() {
if let Some(manifest) = manifests.get(task_id) {
let speed = manifest.processing_stats.sources_per_min;
if speed > 0.0 {
return format!("{:.1}/min", speed);
}
}
}
"calculating...".to_string()
}
fn get_manifest_eta(state: &Arc<AppState>, task_id: &str) -> String {
if let Ok(manifests) = state.task_manifests.read() {
if let Some(manifest) = manifests.get(task_id) {
let eta_secs = manifest.processing_stats.estimated_remaining_seconds;
if eta_secs > 0 {
if eta_secs >= 60 {
return format!("~{} min", eta_secs / 60);
} else {
return format!("~{} sec", eta_secs);
}
} else if manifest.status == crate::auto_task::ManifestStatus::Completed {
return "Done".to_string();
}
}
}
"calculating...".to_string()
}
fn build_taskmd_html(state: &Arc<AppState>, task_id: &str, title: &str, runtime: &str) -> (String, String) {
log::info!("[TASKMD_HTML] Building TASK.md view for task_id: {}", task_id);
if let Ok(manifests) = state.task_manifests.read() {
if let Some(manifest) = manifests.get(task_id) {
log::info!("[TASKMD_HTML] Found manifest for task: {} with {} sections", manifest.app_name, manifest.sections.len());
let status_html = build_status_section_html(manifest, title, runtime);
let progress_html = build_progress_log_html(manifest);
return (status_html, progress_html);
}
}
let default_status = format!(r#"
<div class="status-row">
<span class="status-title">{}</span>
<span class="status-time">Runtime: {}</span>
</div>
"#, title, runtime);
(default_status, r#"<div class="progress-empty">No steps executed yet</div>"#.to_string())
}
fn build_status_section_html(manifest: &TaskManifest, title: &str, runtime: &str) -> String {
let mut html = String::new();
let current_action = manifest.current_status.current_action.as_deref().unwrap_or("Processing...");
let estimated = format!("{}s", manifest.estimated_seconds);
html.push_str(&format!(r#"
<div class="status-row status-main">
<span class="status-title">{}</span>
<span class="status-time">Runtime: {}</span>
</div>
<div class="status-row status-current">
<span class="status-dot active"></span>
<span class="status-text">{}</span>
<span class="status-time">Estimated: {}</span>
</div>
"#, title, runtime, current_action, estimated));
if let Some(ref dp) = manifest.current_status.decision_point {
html.push_str(&format!(r#"
<div class="log-group">
<div class="log-group-header">
<span class="log-group-name">Task Progress</span>
<span class="log-step-badge">Step {}/{}</span>
<span class="log-status-badge pending">Pending</span>
</div>
<div class="log-group-items">
<div class="log-item">
<span class="log-dot pending"></span>
<span class="log-item-name">No execution steps recorded yet</span>
</div>
</div>
<div class="status-row status-decision">
<span class="status-dot pending"></span>
<span class="status-text">Decision Point Coming (Step {}/{})</span>
<span class="status-badge">{}</span>
</div>
"#, current_step, total_steps));
"#, dp.step_current, dp.step_total, dp.message));
}
html
}
/// Build HTML for terminal output from step results
fn build_progress_log_html(manifest: &TaskManifest) -> String {
let mut html = String::new();
html.push_str(r#"<div class="taskmd-tree">"#);
let total_steps = manifest.total_steps;
for section in &manifest.sections {
let section_class = match section.status {
crate::auto_task::SectionStatus::Completed => "completed",
crate::auto_task::SectionStatus::Running => "running",
crate::auto_task::SectionStatus::Failed => "failed",
crate::auto_task::SectionStatus::Skipped => "skipped",
_ => "pending",
};
let status_text = match section.status {
crate::auto_task::SectionStatus::Completed => "Completed",
crate::auto_task::SectionStatus::Running => "Running",
crate::auto_task::SectionStatus::Failed => "Failed",
crate::auto_task::SectionStatus::Skipped => "Skipped",
_ => "Pending",
};
// Use global step count (e.g., "Step 24/60")
let global_current = section.global_step_start + section.current_step;
// TASK.md style checkbox
let section_checkbox = match section.status {
crate::auto_task::SectionStatus::Completed => "[x]",
crate::auto_task::SectionStatus::Running => "[>]",
crate::auto_task::SectionStatus::Skipped => "[-]",
_ => "[ ]",
};
html.push_str(&format!(r#"
<div class="tree-section {}" data-section-id="{}">
<div class="tree-row tree-level-0" onclick="this.parentElement.classList.toggle('expanded')">
<span class="tree-checkbox">{}</span>
<span class="tree-name">{}</span>
<span class="tree-step-badge">Step {}/{}</span>
<span class="tree-status {}">{}</span>
</div>
<div class="tree-children">
"#, section_class, section.id, section_checkbox, section.name, global_current, total_steps, section_class, status_text));
for child in &section.children {
let child_class = match child.status {
crate::auto_task::SectionStatus::Completed => "completed",
crate::auto_task::SectionStatus::Running => "running",
crate::auto_task::SectionStatus::Failed => "failed",
crate::auto_task::SectionStatus::Skipped => "skipped",
_ => "pending",
};
let child_status = match child.status {
crate::auto_task::SectionStatus::Completed => "Completed",
crate::auto_task::SectionStatus::Running => "Running",
crate::auto_task::SectionStatus::Failed => "Failed",
crate::auto_task::SectionStatus::Skipped => "Skipped",
_ => "Pending",
};
// TASK.md style checkbox for child
let child_checkbox = match child.status {
crate::auto_task::SectionStatus::Completed => "[x]",
crate::auto_task::SectionStatus::Running => "[>]",
crate::auto_task::SectionStatus::Skipped => "[-]",
_ => "[ ]",
};
html.push_str(&format!(r#"
<div class="tree-child {}" onclick="this.classList.toggle('expanded')">
<div class="tree-row tree-level-1">
<span class="tree-indent"></span>
<span class="tree-checkbox">{}</span>
<span class="tree-name">{}</span>
<span class="tree-step-badge">Step {}/{}</span>
<span class="tree-status {}">{}</span>
</div>
<div class="tree-items">
"#, child_class, child_checkbox, child.name, child.current_step, child.total_steps, child_class, child_status));
// Render item groups first (grouped fields like "email, password_hash, email_verified")
for group in &child.item_groups {
let (group_class, group_checkbox) = match group.status {
crate::auto_task::ItemStatus::Completed => ("completed", "[x]"),
crate::auto_task::ItemStatus::Running => ("running", "[>]"),
_ => ("pending", "[ ]"),
};
let check_mark = if group.status == crate::auto_task::ItemStatus::Completed { "" } else { "" };
let group_duration = group.duration_seconds
.map(|s| if s >= 60 { format!("Duration: {} min", s / 60) } else { format!("Duration: {} sec", s) })
.unwrap_or_default();
let group_name = group.display_name();
html.push_str(&format!(r#"
<div class="tree-item {}">
<span class="tree-item-checkbox">{}</span>
<span class="tree-item-name">{}</span>
<span class="tree-item-duration">{}</span>
<span class="tree-item-check {}">{}</span>
</div>
"#, group_class, group_checkbox, group_name, group_duration, group_class, check_mark));
}
// Then individual items
for item in &child.items {
let (item_class, item_checkbox) = match item.status {
crate::auto_task::ItemStatus::Completed => ("completed", "[x]"),
crate::auto_task::ItemStatus::Running => ("running", "[>]"),
_ => ("pending", "[ ]"),
};
let check_mark = if item.status == crate::auto_task::ItemStatus::Completed { "" } else { "" };
let item_duration = item.duration_seconds
.map(|s| if s >= 60 { format!("Duration: {} min", s / 60) } else { format!("Duration: {} sec", s) })
.unwrap_or_default();
html.push_str(&format!(r#"
<div class="tree-item {}">
<span class="tree-item-checkbox">{}</span>
<span class="tree-item-name">{}</span>
<span class="tree-item-duration">{}</span>
<span class="tree-item-check {}">{}</span>
</div>
"#, item_class, item_checkbox, item.name, item_duration, item_class, check_mark));
}
html.push_str("</div></div>");
}
// Render section-level item groups
for group in &section.item_groups {
let (group_class, group_checkbox) = match group.status {
crate::auto_task::ItemStatus::Completed => ("completed", "[x]"),
crate::auto_task::ItemStatus::Running => ("running", "[>]"),
_ => ("pending", "[ ]"),
};
let check_mark = if group.status == crate::auto_task::ItemStatus::Completed { "" } else { "" };
let group_duration = group.duration_seconds
.map(|s| if s >= 60 { format!("Duration: {} min", s / 60) } else { format!("Duration: {} sec", s) })
.unwrap_or_default();
let group_name = group.display_name();
html.push_str(&format!(r#"
<div class="tree-item {}">
<span class="tree-item-checkbox">{}</span>
<span class="tree-item-name">{}</span>
<span class="tree-item-duration">{}</span>
<span class="tree-item-check {}">{}</span>
</div>
"#, group_class, group_checkbox, group_name, group_duration, group_class, check_mark));
}
// Render section-level items
for item in &section.items {
let (item_class, item_checkbox) = match item.status {
crate::auto_task::ItemStatus::Completed => ("completed", "[x]"),
crate::auto_task::ItemStatus::Running => ("running", "[>]"),
_ => ("pending", "[ ]"),
};
let check_mark = if item.status == crate::auto_task::ItemStatus::Completed { "" } else { "" };
let item_duration = item.duration_seconds
.map(|s| if s >= 60 { format!("Duration: {} min", s / 60) } else { format!("Duration: {} sec", s) })
.unwrap_or_default();
html.push_str(&format!(r#"
<div class="tree-item {}">
<span class="tree-item-checkbox">{}</span>
<span class="tree-item-name">{}</span>
<span class="tree-item-duration">{}</span>
<span class="tree-item-check {}">{}</span>
</div>
"#, item_class, item_checkbox, item.name, item_duration, item_class, check_mark));
}
html.push_str("</div></div>");
}
html.push_str("</div>");
if manifest.sections.is_empty() {
return r#"<div class="progress-empty">No steps executed yet</div>"#.to_string();
}
html
}
/// Build HTML for the progress log section from step_results JSON
fn build_terminal_html(step_results: &Option<serde_json::Value>, status: &str) -> String {
let mut html = String::new();
let mut lines: Vec<String> = Vec::new();
let mut output_lines: Vec<(String, bool)> = Vec::new();
if let Some(serde_json::Value::Array(steps)) = step_results {
for step in steps.iter() {
// Add step name as a line
if let Some(step_name) = step.get("step_name").and_then(|v| v.as_str()) {
let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or("");
let prefix = match step_status {
"completed" | "Completed" => "",
"running" | "Running" => "",
"failed" | "Failed" => "",
_ => ""
};
lines.push(format!("{} {}", prefix, step_name));
}
let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or("");
let is_current = step_status == "running" || step_status == "Running";
// Add log messages
if let Some(serde_json::Value::Array(logs)) = step.get("logs") {
for log_entry in logs.iter() {
if let Some(msg) = log_entry.get("message").and_then(|v| v.as_str()) {
lines.push(format!(" {}", msg));
if !msg.trim().is_empty() {
output_lines.push((msg.to_string(), is_current));
}
}
if let Some(code) = log_entry.get("code").and_then(|v| v.as_str()) {
if !code.trim().is_empty() {
for line in code.lines().take(20) {
output_lines.push((format!(" {}", line), is_current));
}
}
}
if let Some(output) = log_entry.get("output").and_then(|v| v.as_str()) {
if !output.trim().is_empty() {
for line in output.lines().take(10) {
output_lines.push((format!("{}", line), is_current));
}
}
}
}
}
}
}
if lines.is_empty() {
// Show default message based on status
let default_msg = match status {
"running" => "Task is running...",
if output_lines.is_empty() {
let msg = match status {
"running" => "Agent working...",
"pending" => "Waiting to start...",
"completed" | "done" => "Task completed successfully",
"failed" | "error" => "Task failed - check error details",
"paused" => "Task is paused",
"completed" | "done" => "Task completed",
"failed" | "error" => "Task failed",
"paused" => "Task paused",
_ => "Initializing..."
};
html.push_str(&format!(r#"<div class="terminal-line current">{}</div>"#, default_msg));
html.push_str(&format!(r#"<div class="terminal-line">{}</div>"#, msg));
} else {
// Show last 10 lines, with the last one marked as current
let start = if lines.len() > 10 { lines.len() - 10 } else { 0 };
for (idx, line) in lines[start..].iter().enumerate() {
let is_last = idx == lines[start..].len() - 1;
let class = if is_last && status == "running" { "terminal-line current" } else { "terminal-line" };
html.push_str(&format!(r#"<div class="{}">{}</div>"#, class, line));
let start = if output_lines.len() > 15 { output_lines.len() - 15 } else { 0 };
for (line, is_current) in output_lines[start..].iter() {
let class = if *is_current { "terminal-line current" } else { "terminal-line" };
let escaped = line.replace('<', "&lt;").replace('>', "&gt;");
html.push_str(&format!(r#"<div class="{}">{}</div>"#, class, escaped));
}
}
@ -1629,6 +1871,54 @@ pub fn configure_task_routes() -> Router<Arc<AppState>> {
.route("/api/tasks/:id/status", put(handle_task_status_update))
.route("/api/tasks/:id/priority", put(handle_task_priority_set))
.route("/api/tasks/:id/dependencies", put(handle_task_set_dependencies))
.route("/api/tasks/:id/cancel", post(handle_task_cancel))
}
pub async fn handle_task_cancel(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
log::info!("[TASK_CANCEL] Cancelling task: {}", id);
let conn = state.conn.clone();
let task_id = id.clone();
let result = tokio::task::spawn_blocking(move || {
let mut db_conn = conn
.get()
.map_err(|e| format!("DB connection error: {}", e))?;
let parsed_uuid = Uuid::parse_str(&task_id)
.map_err(|e| format!("Invalid task ID: {}", e))?;
diesel::sql_query(
"UPDATE auto_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1"
)
.bind::<diesel::sql_types::Uuid, _>(parsed_uuid)
.execute(&mut db_conn)
.map_err(|e| format!("Failed to cancel task: {}", e))?;
Ok::<_, String>(())
})
.await
.unwrap_or_else(|e| Err(format!("Task execution error: {}", e)));
match result {
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({
"success": true,
"message": "Task cancelled"
})),
).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"success": false,
"error": e
})),
).into_response(),
}
}
pub fn configure(router: Router<Arc<TaskEngine>>) -> Router<Arc<TaskEngine>> {
@ -1714,39 +2004,74 @@ pub async fn handle_task_list_htmx(
_ => "status-pending"
};
let is_app_task = task.title.to_lowercase().contains("create") ||
task.title.to_lowercase().contains("app") ||
task.title.to_lowercase().contains("crm") ||
task.title.to_lowercase().contains("calculator");
let task_icon = if is_app_task {
r#"<svg class="task-type-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/><path d="M14 9h3"/><path d="M14 14h3"/></svg>"#
} else {
r#"<svg class="task-type-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>"#
};
let app_url = if (task.status == "completed" || task.status == "done") && is_app_task {
let app_name = task.title
.to_lowercase()
.replace("create ", "")
.replace("a ", "")
.replace("an ", "")
.split_whitespace()
.collect::<Vec<_>>()
.join("-");
Some(format!("/apps/{}/", app_name))
} else {
None
};
let open_app_btn = app_url.as_ref().map(|url| format!(
r#"<a href="{}" target="_blank" class="btn-open-app" onclick="event.stopPropagation()" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open App
</a>"#,
url
)).unwrap_or_default();
let _ = write!(
html,
r#"
<div class="task-card {completed_class} {status_class}" data-task-id="{}" onclick="selectTask('{}')">
<div class="task-card {completed_class} {status_class}" data-task-id="{task_id}" onclick="selectTask('{task_id}')">
<div class="task-card-header">
<span class="task-card-title">{}</span>
<span class="task-card-status {}">{}</span>
{task_icon}
<span class="task-card-title">{title}</span>
<span class="task-card-status {status_class}">{status}</span>
</div>
<div class="task-card-body">
<div class="task-card-priority">
<span class="priority-badge priority-{}">{}</span>
<span class="priority-badge priority-{priority}">{priority}</span>
</div>
{due_date_html}
{open_app_btn}
</div>
<div class="task-card-footer">
<button class="task-action-btn" data-action="priority" data-task-id="{}" onclick="event.stopPropagation()">
<button class="task-action-btn" data-action="priority" data-task-id="{task_id}" onclick="event.stopPropagation()">
</button>
<button class="task-action-btn" data-action="delete" data-task-id="{}" onclick="event.stopPropagation()">
<button class="task-action-btn" data-action="delete" data-task-id="{task_id}" onclick="event.stopPropagation()">
🗑
</button>
</div>
</div>
"#,
task.id,
task.id,
task.title,
status_class,
task.status,
task.priority,
task.priority,
task.id,
task.id
task_id = task.id,
task_icon = task_icon,
title = task.title,
status_class = status_class,
status = task.status,
priority = task.priority,
due_date_html = due_date_html,
open_app_btn = open_app_btn,
completed_class = completed_class,
);
}