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:
parent
bad6ebd501
commit
0385047c5c
13 changed files with 2286 additions and 351 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {{
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
982
src/auto_task/task_manifest.rs
Normal file
982
src/auto_task/task_manifest.rs
Normal 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 §ion.children {
|
||||
self.total_steps += child.total_steps;
|
||||
}
|
||||
self.sections.push(section);
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
self.status = ManifestStatus::Running;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn complete(&mut self) {
|
||||
self.status = ManifestStatus::Completed;
|
||||
self.completed_steps = self.total_steps;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Recalculate global_step_start for all sections after modifications
|
||||
pub fn recalculate_global_steps(&mut self) {
|
||||
let mut offset = 0u32;
|
||||
for section in &mut self.sections {
|
||||
section.global_step_start = offset;
|
||||
|
||||
// Update children's global step starts
|
||||
let mut child_offset = offset;
|
||||
for child in &mut section.children {
|
||||
child.global_step_start = child_offset;
|
||||
child_offset += child.total_steps;
|
||||
}
|
||||
|
||||
// Add this section's steps (including children)
|
||||
offset += section.total_steps;
|
||||
for child in §ion.children {
|
||||
offset += child.total_steps;
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate total
|
||||
self.total_steps = offset;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn fail(&mut self) {
|
||||
self.status = ManifestStatus::Failed;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn add_terminal_line(&mut self, content: &str, line_type: TerminalLineType) {
|
||||
self.terminal_output.push(TerminalLine {
|
||||
timestamp: Utc::now(),
|
||||
content: content.to_string(),
|
||||
line_type,
|
||||
});
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn update_section_status(&mut self, section_id: &str, status: SectionStatus) {
|
||||
for section in &mut self.sections {
|
||||
if section.id == section_id {
|
||||
section.status = status.clone();
|
||||
if status == SectionStatus::Completed {
|
||||
section.completed_at = Some(Utc::now());
|
||||
self.completed_steps += section.total_steps;
|
||||
}
|
||||
break;
|
||||
}
|
||||
for child in &mut section.children {
|
||||
if child.id == section_id {
|
||||
child.status = status.clone();
|
||||
if status == SectionStatus::Completed {
|
||||
child.completed_at = Some(Utc::now());
|
||||
self.completed_steps += child.total_steps;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn update_item_status(&mut self, section_id: &str, item_id: &str, status: ItemStatus) {
|
||||
for section in &mut self.sections {
|
||||
if section.id == section_id {
|
||||
for item in &mut section.items {
|
||||
if item.id == item_id {
|
||||
item.status = status;
|
||||
if status == ItemStatus::Completed {
|
||||
item.completed_at = Some(Utc::now());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for child in &mut section.children {
|
||||
if child.id == section_id {
|
||||
for item in &mut child.items {
|
||||
if item.id == item_id {
|
||||
item.status = status;
|
||||
if status == ItemStatus::Completed {
|
||||
item.completed_at = Some(Utc::now());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn update_processing_stats(&mut self, stats: ProcessingStats) {
|
||||
self.processing_stats = stats;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn progress_percentage(&self) -> f64 {
|
||||
if self.total_steps == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
(self.completed_steps as f64 / self.total_steps as f64) * 100.0
|
||||
}
|
||||
|
||||
pub fn to_markdown(&self) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
md.push_str(&format!("# TASK.md - {}\n\n", self.app_name));
|
||||
md.push_str(&format!("**Description:** {}\n\n", self.description));
|
||||
md.push_str(&format!("**Status:** {:?}\n", self.status));
|
||||
md.push_str(&format!(
|
||||
"**Progress:** {}/{} steps ({}%)\n\n",
|
||||
self.completed_steps,
|
||||
self.total_steps,
|
||||
self.progress_percentage() as u32
|
||||
));
|
||||
|
||||
md.push_str("## Artifacts\n\n");
|
||||
|
||||
for section in &self.sections {
|
||||
md.push_str(&format!(
|
||||
"### {} - {:?}\n",
|
||||
section.name, section.status
|
||||
));
|
||||
md.push_str(&format!(
|
||||
"- Steps: {}/{}\n",
|
||||
section.current_step, section.total_steps
|
||||
));
|
||||
|
||||
if !section.items.is_empty() {
|
||||
md.push_str("- Items:\n");
|
||||
for item in §ion.items {
|
||||
md.push_str(&format!(
|
||||
" - {} ({:?}): {:?}\n",
|
||||
item.name, item.item_type, item.status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for child in §ion.children {
|
||||
md.push_str(&format!(
|
||||
" #### {} - {:?}\n",
|
||||
child.name, child.status
|
||||
));
|
||||
md.push_str(&format!(
|
||||
" - Steps: {}/{}\n",
|
||||
child.current_step, child.total_steps
|
||||
));
|
||||
|
||||
if !child.items.is_empty() {
|
||||
md.push_str(" - Items:\n");
|
||||
for item in &child.items {
|
||||
md.push_str(&format!(
|
||||
" - {} ({:?}): {:?}\n",
|
||||
item.name, item.item_type, item.status
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
md.push('\n');
|
||||
}
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
pub fn to_web_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": self.id,
|
||||
"app_name": self.app_name,
|
||||
"description": self.description,
|
||||
"status": {
|
||||
"title": self.current_status.title,
|
||||
"runtime_display": format_duration(self.runtime_seconds),
|
||||
"estimated_display": format_duration(self.estimated_seconds),
|
||||
"current_action": self.current_status.current_action,
|
||||
"decision_point": self.current_status.decision_point.as_ref().map(|dp| serde_json::json!({
|
||||
"step_current": dp.step_current,
|
||||
"step_total": dp.step_total,
|
||||
"message": dp.message
|
||||
}))
|
||||
},
|
||||
"progress": {
|
||||
"current": self.completed_steps,
|
||||
"total": self.total_steps,
|
||||
"percentage": self.progress_percentage()
|
||||
},
|
||||
"sections": self.sections.iter().map(|s| section_to_web_json(s)).collect::<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 §ion.children {
|
||||
let child_checkbox = match child.status {
|
||||
SectionStatus::Completed => "[x]",
|
||||
SectionStatus::Running => "[>]",
|
||||
_ => "[ ]",
|
||||
};
|
||||
md.push_str(&format!(" - {} {} (Step {}/{})\n", child_checkbox, child.name, child.current_step, child.total_steps));
|
||||
|
||||
// Render item groups first
|
||||
for group in &child.item_groups {
|
||||
let group_checkbox = match group.status {
|
||||
ItemStatus::Completed => "[x]",
|
||||
ItemStatus::Running => "[>]",
|
||||
_ => "[ ]",
|
||||
};
|
||||
let duration = group.duration_seconds.map(|s| format!(" - Duration: {} min", s / 60)).unwrap_or_default();
|
||||
md.push_str(&format!(" - {} {}{}\n", group_checkbox, group.display_name(), duration));
|
||||
}
|
||||
|
||||
// Then individual items
|
||||
for item in &child.items {
|
||||
let item_checkbox = match item.status {
|
||||
ItemStatus::Completed => "[x]",
|
||||
ItemStatus::Running => "[>]",
|
||||
_ => "[ ]",
|
||||
};
|
||||
let duration = item.duration_seconds.map(|s| format!(" - Duration: {}s", s)).unwrap_or_default();
|
||||
md.push_str(&format!(" - {} {}{}\n", item_checkbox, item.name, duration));
|
||||
}
|
||||
}
|
||||
|
||||
// Render section-level item groups
|
||||
for group in §ion.item_groups {
|
||||
let group_checkbox = match group.status {
|
||||
ItemStatus::Completed => "[x]",
|
||||
ItemStatus::Running => "[>]",
|
||||
_ => "[ ]",
|
||||
};
|
||||
let duration = group.duration_seconds.map(|s| format!(" - Duration: {} min", s / 60)).unwrap_or_default();
|
||||
md.push_str(&format!(" - {} {}{}\n", group_checkbox, group.display_name(), duration));
|
||||
}
|
||||
|
||||
for item in §ion.items {
|
||||
let item_checkbox = match item.status {
|
||||
ItemStatus::Completed => "[x]",
|
||||
ItemStatus::Running => "[>]",
|
||||
_ => "[ ]",
|
||||
};
|
||||
md.push_str(&format!(" - {} {}\n", item_checkbox, item.name));
|
||||
}
|
||||
}
|
||||
md
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl ManifestSection {
|
||||
pub fn new(name: &str, section_type: SectionType) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: name.to_string(),
|
||||
section_type,
|
||||
status: SectionStatus::Pending,
|
||||
current_step: 0,
|
||||
total_steps: 0,
|
||||
global_step_start: 0,
|
||||
duration_seconds: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
items: Vec::new(),
|
||||
item_groups: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_steps(mut self, total: u32) -> Self {
|
||||
self.total_steps = total;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_item(&mut self, item: ManifestItem) {
|
||||
self.total_steps += 1;
|
||||
self.items.push(item);
|
||||
}
|
||||
|
||||
pub fn add_item_group(&mut self, group: ItemGroup) {
|
||||
self.total_steps += 1;
|
||||
self.item_groups.push(group);
|
||||
}
|
||||
|
||||
pub fn add_child(&mut self, child: ManifestSection) {
|
||||
self.total_steps += child.total_steps;
|
||||
self.children.push(child);
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
self.status = SectionStatus::Running;
|
||||
self.started_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
pub fn complete(&mut self) {
|
||||
self.status = SectionStatus::Completed;
|
||||
self.completed_at = Some(Utc::now());
|
||||
self.current_step = self.total_steps;
|
||||
if let Some(started) = self.started_at {
|
||||
self.duration_seconds = Some((Utc::now() - started).num_seconds() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn increment_step(&mut self) {
|
||||
self.current_step += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl ManifestItem {
|
||||
pub fn new(name: &str, item_type: ItemType) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: name.to_string(),
|
||||
item_type,
|
||||
status: ItemStatus::Pending,
|
||||
details: None,
|
||||
duration_seconds: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
metadata: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_details(mut self, details: &str) -> Self {
|
||||
self.details = Some(details.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_metadata(mut self, key: &str, value: serde_json::Value) -> Self {
|
||||
self.metadata.insert(key.to_string(), value);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
self.status = ItemStatus::Running;
|
||||
self.started_at = Some(Utc::now());
|
||||
}
|
||||
|
||||
pub fn complete(&mut self) {
|
||||
self.status = ItemStatus::Completed;
|
||||
self.completed_at = Some(Utc::now());
|
||||
if let Some(started) = self.started_at {
|
||||
self.duration_seconds = Some((Utc::now() - started).num_seconds() as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn section_to_web_json(section: &ManifestSection) -> serde_json::Value {
|
||||
let checkbox = match section.status {
|
||||
SectionStatus::Completed => "[x]",
|
||||
SectionStatus::Running => "[>]",
|
||||
_ => "[ ]",
|
||||
};
|
||||
|
||||
// Calculate global step display (e.g., "Step 24/60")
|
||||
let global_current = section.global_step_start + section.current_step;
|
||||
|
||||
serde_json::json!({
|
||||
"id": section.id,
|
||||
"name": section.name,
|
||||
"checkbox": checkbox,
|
||||
"type": format!("{:?}", section.section_type),
|
||||
"status": format!("{:?}", section.status),
|
||||
"progress": {
|
||||
"current": section.current_step,
|
||||
"total": section.total_steps,
|
||||
"display": format!("Step {}/{}", section.current_step, section.total_steps),
|
||||
"global_current": global_current,
|
||||
"global_start": section.global_step_start
|
||||
},
|
||||
"duration": section.duration_seconds.map(|d| format_duration(d)),
|
||||
"duration_seconds": section.duration_seconds,
|
||||
"items": section.items.iter().map(|i| {
|
||||
let item_checkbox = match i.status {
|
||||
ItemStatus::Completed => "[x]",
|
||||
ItemStatus::Running => "[>]",
|
||||
_ => "[ ]",
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": i.id,
|
||||
"name": i.name,
|
||||
"checkbox": item_checkbox,
|
||||
"type": format!("{:?}", i.item_type),
|
||||
"status": format!("{:?}", i.status),
|
||||
"details": i.details,
|
||||
"duration": i.duration_seconds.map(|d| format_duration(d)),
|
||||
"duration_seconds": i.duration_seconds
|
||||
})
|
||||
}).collect::<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
|
||||
}
|
||||
|
|
@ -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, ¶ms.app_name, "index.html").await
|
||||
}
|
||||
|
||||
|
|
@ -46,9 +53,31 @@ pub async fn serve_app_file(
|
|||
serve_app_file_internal(&state, ¶ms.app_name, ¶ms.file_path).await
|
||||
}
|
||||
|
||||
/// Sanitize app name - only alphanumeric, underscore, hyphen allowed
|
||||
fn sanitize_app_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect::<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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
771
src/tasks/mod.rs
771
src/tasks/mod.rs
|
|
@ -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 §ion.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 §ion.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 §ion.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('<', "<").replace('>', ">");
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue