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 (
|
VALUES (
|
||||||
v_bot_id,
|
v_bot_id,
|
||||||
v_org_id,
|
v_org_id,
|
||||||
'Default Bot',
|
'default',
|
||||||
'Default bot for the default organization',
|
'Default bot for the default organization',
|
||||||
'openai',
|
'openai',
|
||||||
'{"model": "gpt-4", "temperature": 0.7}'::jsonb,
|
'{"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::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::{
|
use crate::basic::keywords::table_definition::{
|
||||||
generate_create_table_sql, FieldDefinition, TableDefinition,
|
generate_create_table_sql, FieldDefinition, TableDefinition,
|
||||||
};
|
};
|
||||||
|
|
@ -157,6 +163,7 @@ pub struct AppGenerator {
|
||||||
files_written: Vec<String>,
|
files_written: Vec<String>,
|
||||||
tables_synced: Vec<String>,
|
tables_synced: Vec<String>,
|
||||||
bytes_generated: u64,
|
bytes_generated: u64,
|
||||||
|
manifest: Option<TaskManifest>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppGenerator {
|
impl AppGenerator {
|
||||||
|
|
@ -168,6 +175,7 @@ impl AppGenerator {
|
||||||
files_written: Vec::new(),
|
files_written: Vec::new(),
|
||||||
tables_synced: Vec::new(),
|
tables_synced: Vec::new(),
|
||||||
bytes_generated: 0,
|
bytes_generated: 0,
|
||||||
|
manifest: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,9 +187,445 @@ impl AppGenerator {
|
||||||
files_written: Vec::new(),
|
files_written: Vec::new(),
|
||||||
tables_synced: Vec::new(),
|
tables_synced: Vec::new(),
|
||||||
bytes_generated: 0,
|
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) {
|
fn emit_activity(&self, step: &str, message: &str, current: u8, total: u8, activity: AgentActivity) {
|
||||||
if let Some(ref task_id) = self.task_id {
|
if let Some(ref task_id) = self.task_id {
|
||||||
self.state.emit_activity(task_id, step, message, current, total, activity);
|
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 {
|
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.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"));
|
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)));
|
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);
|
self.emit_activity("parse_structure", &format!("Parsing {} structure...", llm_app.name), 3, TOTAL_STEPS, activity);
|
||||||
|
|
||||||
let tables = Self::convert_llm_tables(&llm_app.tables);
|
let tables = Self::convert_llm_tables(&llm_app.tables);
|
||||||
|
|
||||||
if !tables.is_empty() {
|
if !tables.is_empty() {
|
||||||
|
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 table_names: Vec<String> = tables.iter().map(|t| t.name.clone()).collect();
|
||||||
let activity = self.build_activity(
|
let activity = self.build_activity(
|
||||||
"database",
|
"database",
|
||||||
|
|
@ -343,7 +804,28 @@ impl AppGenerator {
|
||||||
result.tables_created, result.fields_added
|
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(
|
let activity = self.build_activity(
|
||||||
"database",
|
"database",
|
||||||
4,
|
4,
|
||||||
|
|
@ -360,14 +842,14 @@ impl AppGenerator {
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_generator_error(&llm_app.name, "Failed to sync tables", &e.to_string());
|
log_generator_error(&llm_app.name, "Failed to sync tables", &e.to_string());
|
||||||
|
self.add_terminal_output(&format!(" ✗ Error: {}", e), TerminalLineType::Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bot_name = self.get_bot_name(session.bot_id)?;
|
// Use bucket_name from state (e.g., "default.gbai") instead of deriving from bot name
|
||||||
// Sanitize bucket name - replace spaces and invalid characters
|
let bucket_name = self.state.bucket_name.clone();
|
||||||
let sanitized_name = bot_name.to_lowercase().replace(' ', "-").replace('_', "-");
|
let sanitized_name = bucket_name.trim_end_matches(".gbai").to_string();
|
||||||
let bucket_name = format!("{}.gbai", sanitized_name);
|
|
||||||
let drive_app_path = format!("{}.gbapp/{}", sanitized_name, llm_app.name);
|
let drive_app_path = format!("{}.gbapp/{}", sanitized_name, llm_app.name);
|
||||||
|
|
||||||
info!("Writing app files to bucket: {}, path: {}", bucket_name, drive_app_path);
|
info!("Writing app files to bucket: {}, path: {}", bucket_name, drive_app_path);
|
||||||
|
|
@ -393,6 +875,10 @@ impl AppGenerator {
|
||||||
activity
|
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();
|
let mut pages = Vec::new();
|
||||||
for (idx, file) in llm_app.files.iter().enumerate() {
|
for (idx, file) in llm_app.files.iter().enumerate() {
|
||||||
let drive_path = format!("{}/{}", drive_app_path, file.filename);
|
let drive_path = format!("{}/{}", drive_app_path, file.filename);
|
||||||
|
|
@ -400,6 +886,10 @@ impl AppGenerator {
|
||||||
self.files_written.push(file.filename.clone());
|
self.files_written.push(file.filename.clone());
|
||||||
self.bytes_generated += file.content.len() as u64;
|
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(
|
let activity = self.build_activity(
|
||||||
"writing",
|
"writing",
|
||||||
(idx + 1) as u32,
|
(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);
|
let file_type = Self::detect_file_type(&file.filename);
|
||||||
pages.push(GeneratedFile {
|
pages.push(GeneratedFile {
|
||||||
filename: file.filename.clone(),
|
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());
|
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"));
|
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);
|
self.emit_activity("write_designer", "Creating designer configuration...", 6, TOTAL_STEPS, activity);
|
||||||
|
|
@ -458,6 +972,9 @@ impl AppGenerator {
|
||||||
|
|
||||||
let mut tools = Vec::new();
|
let mut tools = Vec::new();
|
||||||
if !llm_app.tools.is_empty() {
|
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 tools_count = llm_app.tools.len();
|
||||||
let activity = self.build_activity("tools", 0, Some(tools_count as u32), Some("Creating BASIC tools"));
|
let activity = self.build_activity("tools", 0, Some(tools_count as u32), Some("Creating BASIC tools"));
|
||||||
self.emit_activity(
|
self.emit_activity(
|
||||||
|
|
@ -486,16 +1003,27 @@ impl AppGenerator {
|
||||||
&e.to_string(),
|
&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 {
|
tools.push(GeneratedFile {
|
||||||
filename: tool.filename.clone(),
|
filename: tool.filename.clone(),
|
||||||
content: tool.content.clone(),
|
content: tool.content.clone(),
|
||||||
file_type: FileType::Bas,
|
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();
|
let mut schedulers = Vec::new();
|
||||||
if !llm_app.schedulers.is_empty() {
|
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 sched_count = llm_app.schedulers.len();
|
||||||
let activity = self.build_activity("schedulers", 0, Some(sched_count as u32), Some("Creating schedulers"));
|
let activity = self.build_activity("schedulers", 0, Some(sched_count as u32), Some("Creating schedulers"));
|
||||||
self.emit_activity(
|
self.emit_activity(
|
||||||
|
|
@ -524,20 +1052,35 @@ impl AppGenerator {
|
||||||
&e.to_string(),
|
&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 {
|
schedulers.push(GeneratedFile {
|
||||||
filename: scheduler.filename.clone(),
|
filename: scheduler.filename.clone(),
|
||||||
content: scheduler.content.clone(),
|
content: scheduler.content.clone(),
|
||||||
file_type: FileType::Bas,
|
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
|
// No monitors generated currently - mark as skipped
|
||||||
let base_url = self.state.config
|
self.update_manifest_section(SectionType::Monitors, SectionStatus::Skipped);
|
||||||
.as_ref()
|
|
||||||
.map(|c| c.server.base_url.clone())
|
// Build the app URL (use relative URL so it works on any port)
|
||||||
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
// Include trailing slash so relative paths in HTML resolve correctly
|
||||||
let app_url = format!("{}/apps/{}", base_url, llm_app.name);
|
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"));
|
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);
|
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());
|
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 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();
|
chunk_buffer.clear();
|
||||||
last_emit = std::time::Instant::now();
|
last_emit = std::time::Instant::now();
|
||||||
}
|
}
|
||||||
|
|
@ -1217,12 +1758,9 @@ NO QUESTIONS. JUST BUILD."#
|
||||||
trace!("APP_GENERATOR Stream finished: {} chunks, {} chars in {:?}",
|
trace!("APP_GENERATOR Stream finished: {} chunks, {} chars in {:?}",
|
||||||
chunk_count, full_response.len(), stream_start.elapsed());
|
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() {
|
if !chunk_buffer.is_empty() {
|
||||||
trace!("APP_GENERATOR Emitting final buffer: {} chars", chunk_buffer.len());
|
trace!("APP_GENERATOR Final buffer (not emitting): {} chars", chunk_buffer.len());
|
||||||
if let Some(ref tid) = task_id {
|
|
||||||
state.emit_llm_stream(tid, &chunk_buffer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log response preview
|
// Log response preview
|
||||||
|
|
@ -1315,11 +1853,11 @@ NO QUESTIONS. JUST BUILD."#
|
||||||
|
|
||||||
fn append_to_tables_bas(
|
fn append_to_tables_bas(
|
||||||
&self,
|
&self,
|
||||||
bot_id: Uuid,
|
_bot_id: Uuid,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let bot_name = self.get_bot_name(bot_id)?;
|
// Use bucket_name from state instead of deriving from bot name
|
||||||
let bucket = format!("{}.gbai", bot_name.to_lowercase());
|
let bucket = self.state.bucket_name.clone();
|
||||||
let path = ".gbdata/tables.bas";
|
let path = ".gbdata/tables.bas";
|
||||||
|
|
||||||
let mut conn = self.state.conn.get()?;
|
let mut conn = self.state.conn.get()?;
|
||||||
|
|
@ -1357,29 +1895,6 @@ NO QUESTIONS. JUST BUILD."#
|
||||||
Ok(())
|
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
|
/// Ensure the bucket exists, creating it if necessary
|
||||||
async fn ensure_bucket_exists(
|
async fn ensure_bucket_exists(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -1548,18 +2063,49 @@ NO QUESTIONS. JUST BUILD."#
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let mut conn = self.state.conn.get()?;
|
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(
|
sql_query(
|
||||||
"UPDATE auto_tasks SET
|
"UPDATE auto_tasks SET
|
||||||
progress = 1.0,
|
progress = 1.0,
|
||||||
|
current_step = 3,
|
||||||
|
total_steps = 3,
|
||||||
|
step_results = $1,
|
||||||
status = 'completed',
|
status = 'completed',
|
||||||
completed_at = NOW(),
|
completed_at = NOW(),
|
||||||
updated_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)
|
.bind::<diesel::sql_types::Uuid, _>(task_id)
|
||||||
.execute(&mut conn)?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1614,33 +2160,6 @@ NO QUESTIONS. JUST BUILD."#
|
||||||
Ok(())
|
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 {
|
fn generate_designer_js(app_name: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
r#"(function() {{
|
r#"(function() {{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use crate::auto_task::task_manifest::TaskManifest;
|
||||||
use crate::auto_task::task_types::{
|
use crate::auto_task::task_types::{
|
||||||
AutoTask, AutoTaskStatus, ExecutionMode, PendingApproval, PendingDecision, TaskPriority,
|
AutoTask, AutoTaskStatus, ExecutionMode, PendingApproval, PendingDecision, TaskPriority,
|
||||||
};
|
};
|
||||||
|
|
@ -1966,6 +1967,37 @@ pub async fn apply_recommendation_handler(
|
||||||
// HELPER FUNCTIONS FOR NEW ENDPOINTS
|
// 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> {
|
fn get_task_logs(_state: &Arc<AppState>, task_id: &str) -> Vec<serde_json::Value> {
|
||||||
// TODO: Fetch from database when task execution is implemented
|
// TODO: Fetch from database when task execution is implemented
|
||||||
vec![
|
vec![
|
||||||
|
|
|
||||||
|
|
@ -597,7 +597,7 @@ Respond with JSON only:
|
||||||
fn handle_todo(
|
fn handle_todo(
|
||||||
&self,
|
&self,
|
||||||
classification: &ClassifiedIntent,
|
classification: &ClassifiedIntent,
|
||||||
session: &UserSession,
|
_session: &UserSession,
|
||||||
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<IntentResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
info!("Handling TODO intent");
|
info!("Handling TODO intent");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,19 @@ pub mod designer_ai;
|
||||||
pub mod intent_classifier;
|
pub mod intent_classifier;
|
||||||
pub mod intent_compiler;
|
pub mod intent_compiler;
|
||||||
pub mod safety_layer;
|
pub mod safety_layer;
|
||||||
|
pub mod task_manifest;
|
||||||
pub mod task_types;
|
pub mod task_types;
|
||||||
|
|
||||||
pub use app_generator::{
|
pub use app_generator::{
|
||||||
AppGenerator, AppStructure, FileType, GeneratedApp, GeneratedFile, GeneratedPage, PageType,
|
AppGenerator, AppStructure, FileType, GeneratedApp, GeneratedFile, GeneratedPage, PageType,
|
||||||
SyncResult,
|
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::{
|
pub use app_logs::{
|
||||||
generate_client_logger_js, get_designer_error_context, log_generator_error, log_generator_info,
|
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,
|
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::{
|
pub use autotask_api::{
|
||||||
apply_recommendation_handler, cancel_task_handler, classify_intent_handler,
|
apply_recommendation_handler, cancel_task_handler, classify_intent_handler,
|
||||||
compile_intent_handler, create_and_execute_handler, execute_plan_handler, execute_task_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_approvals_handler, get_decisions_handler, get_manifest_handler, get_pending_items_handler,
|
||||||
get_task_handler, get_task_logs_handler, list_tasks_handler, pause_task_handler, resume_task_handler,
|
get_stats_handler, get_task_handler, get_task_logs_handler, list_tasks_handler,
|
||||||
simulate_plan_handler, simulate_task_handler, submit_approval_handler, submit_decision_handler,
|
pause_task_handler, resume_task_handler, simulate_plan_handler, simulate_task_handler,
|
||||||
submit_pending_item_handler,
|
submit_approval_handler, submit_decision_handler, submit_pending_item_handler,
|
||||||
};
|
};
|
||||||
pub use designer_ai::DesignerAI;
|
pub use designer_ai::DesignerAI;
|
||||||
pub use task_types::{AutoTask, AutoTaskStatus, ExecutionMode, TaskPriority};
|
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}"),
|
&ApiUrls::AUTOTASK_LOGS.replace(":task_id", "{task_id}"),
|
||||||
get(get_task_logs_handler),
|
get(get_task_logs_handler),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/autotask/{task_id}/manifest",
|
||||||
|
get(get_manifest_handler),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
&ApiUrls::AUTOTASK_RECOMMENDATIONS_APPLY.replace(":rec_id", "{rec_id}"),
|
&ApiUrls::AUTOTASK_RECOMMENDATIONS_APPLY.replace(":rec_id", "{rec_id}"),
|
||||||
post(apply_recommendation_handler),
|
post(apply_recommendation_handler),
|
||||||
|
|
@ -236,7 +247,8 @@ async fn handle_task_progress_websocket(
|
||||||
debug!("Received binary from task progress WebSocket (ignored)");
|
debug!("Received binary from task progress WebSocket (ignored)");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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;
|
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 crate::shared::state::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
|
|
@ -35,7 +35,14 @@ pub struct AppFilePath {
|
||||||
pub async fn serve_app_index(
|
pub async fn serve_app_index(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(params): Path<AppPath>,
|
Path(params): Path<AppPath>,
|
||||||
|
original_uri: axum::extract::OriginalUri,
|
||||||
) -> impl IntoResponse {
|
) -> 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
|
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
|
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 {
|
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_app_name = sanitize_app_name(app_name);
|
||||||
let sanitized_file_path = sanitize_path_component(file_path);
|
let sanitized_file_path = sanitize_file_path(file_path);
|
||||||
|
|
||||||
if sanitized_app_name.is_empty() || sanitized_file_path.is_empty() {
|
if sanitized_app_name.is_empty() || sanitized_file_path.is_empty() {
|
||||||
return (StatusCode::BAD_REQUEST, "Invalid path").into_response();
|
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("image.png"), "image/png");
|
||||||
assert_eq!(get_content_type("unknown.xyz"), "application/octet-stream");
|
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::bot::channels::{ChannelAdapter, VoiceAdapter, WebChannelAdapter};
|
||||||
use crate::core::config::AppConfig;
|
use crate::core::config::AppConfig;
|
||||||
use crate::core::kb::KnowledgeBaseManager;
|
use crate::core::kb::KnowledgeBaseManager;
|
||||||
|
|
@ -217,6 +218,12 @@ impl TaskProgressEvent {
|
||||||
self
|
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]
|
#[must_use]
|
||||||
pub fn with_error(mut self, error: impl Into<String>) -> Self {
|
pub fn with_error(mut self, error: impl Into<String>) -> Self {
|
||||||
self.event_type = "task_error".to_string();
|
self.event_type = "task_error".to_string();
|
||||||
|
|
@ -337,6 +344,7 @@ pub struct AppState {
|
||||||
pub extensions: Extensions,
|
pub extensions: Extensions,
|
||||||
pub attendant_broadcast: Option<broadcast::Sender<AttendantNotification>>,
|
pub attendant_broadcast: Option<broadcast::Sender<AttendantNotification>>,
|
||||||
pub task_progress_broadcast: Option<broadcast::Sender<TaskProgressEvent>>,
|
pub task_progress_broadcast: Option<broadcast::Sender<TaskProgressEvent>>,
|
||||||
|
pub task_manifests: Arc<std::sync::RwLock<HashMap<String, TaskManifest>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clone for AppState {
|
impl Clone for AppState {
|
||||||
|
|
@ -367,6 +375,7 @@ impl Clone for AppState {
|
||||||
extensions: self.extensions.clone(),
|
extensions: self.extensions.clone(),
|
||||||
attendant_broadcast: self.attendant_broadcast.clone(),
|
attendant_broadcast: self.attendant_broadcast.clone(),
|
||||||
task_progress_broadcast: self.task_progress_broadcast.clone(),
|
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(),
|
extensions: Extensions::new(),
|
||||||
attendant_broadcast: Some(attendant_tx),
|
attendant_broadcast: Some(attendant_tx),
|
||||||
task_progress_broadcast: Some(task_progress_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 (attendant_tx, _) = broadcast::channel(100);
|
||||||
|
|
||||||
|
let (task_progress_tx, _) = broadcast::channel(100);
|
||||||
|
|
||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
#[cfg(feature = "drive")]
|
#[cfg(feature = "drive")]
|
||||||
drive: None,
|
drive: None,
|
||||||
|
|
@ -211,6 +213,8 @@ impl TestAppStateBuilder {
|
||||||
task_engine: Arc::new(TaskEngine::new(pool)),
|
task_engine: Arc::new(TaskEngine::new(pool)),
|
||||||
extensions: Extensions::new(),
|
extensions: Extensions::new(),
|
||||||
attendant_broadcast: Some(attendant_tx),
|
attendant_broadcast: Some(attendant_tx),
|
||||||
|
task_progress_broadcast: Some(task_progress_tx),
|
||||||
|
task_manifests: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1154,35 +1154,31 @@ Respond with valid JSON only."#,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_designer_llm(
|
async fn call_designer_llm(
|
||||||
_state: &AppState,
|
state: &AppState,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
) -> 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());
|
use crate::core::config::ConfigManager;
|
||||||
let llm_model = std::env::var("LLM_MODEL").unwrap_or_else(|_| "llama3.2".to_string());
|
|
||||||
|
|
||||||
let client = reqwest::Client::new();
|
let config_manager = ConfigManager::new(state.conn.clone());
|
||||||
|
|
||||||
let response = client
|
// Get LLM configuration from bot config or use defaults
|
||||||
.post(format!("{}/api/generate", llm_url))
|
let model = config_manager
|
||||||
.json(&serde_json::json!({
|
.get_config(&uuid::Uuid::nil(), "llm-model", Some("claude-sonnet-4-20250514"))
|
||||||
"model": llm_model,
|
.unwrap_or_else(|_| "claude-sonnet-4-20250514".to_string());
|
||||||
"prompt": prompt,
|
|
||||||
"stream": false,
|
|
||||||
"options": {
|
|
||||||
"temperature": 0.3,
|
|
||||||
"num_predict": 2000
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
let api_key = config_manager
|
||||||
let status = response.status();
|
.get_config(&uuid::Uuid::nil(), "llm-key", None)
|
||||||
return Err(format!("LLM request failed: {status}").into());
|
.unwrap_or_default();
|
||||||
}
|
|
||||||
|
|
||||||
let result: serde_json::Value = response.json().await?;
|
let system_prompt = "You are a web designer AI. Respond only with valid JSON.";
|
||||||
let response_text = result["response"].as_str().unwrap_or("{}").to_string();
|
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") {
|
let json_text = if response_text.contains("```json") {
|
||||||
response_text
|
response_text
|
||||||
|
|
@ -1291,36 +1287,43 @@ async fn apply_file_change(
|
||||||
.select(name)
|
.select(name)
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let bucket_name = format!("{}.gbai", bot_name_val.to_lowercase());
|
let site_path = state
|
||||||
let file_path = format!(".gbdrive/apps/{app_name}/{file_name}");
|
.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 {
|
if let Some(ref s3_client) = state.drive {
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
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()
|
.put_object()
|
||||||
.bucket(&bucket_name)
|
.bucket(&bucket_name)
|
||||||
.key(&file_path)
|
.key(&file_path)
|
||||||
.body(ByteStream::from(content.as_bytes().to_vec()))
|
.body(ByteStream::from(content.as_bytes().to_vec()))
|
||||||
.content_type(get_content_type(file_name))
|
.content_type(get_content_type(file_name))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await
|
||||||
|
{
|
||||||
log::info!("Designer updated file: s3://{bucket_name}/{file_path}");
|
Ok(_) => {
|
||||||
|
log::info!("Designer synced to S3: s3://{bucket_name}/{file_path}");
|
||||||
let site_path = state
|
}
|
||||||
.config
|
Err(e) => {
|
||||||
.as_ref()
|
log::warn!("Designer failed to sync to S3 (local write succeeded): {e}");
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
std::fs::write(&local_path, content)?;
|
|
||||||
|
|
||||||
log::info!("Designer synced to local: {local_path}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use log::{error, info, trace, warn};
|
use log::{error, trace, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
|
||||||
|
|
@ -869,6 +869,7 @@ async fn main() -> std::io::Result<()> {
|
||||||
},
|
},
|
||||||
attendant_broadcast: Some(attendant_tx),
|
attendant_broadcast: Some(attendant_tx),
|
||||||
task_progress_broadcast: Some(task_progress_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(
|
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;
|
pub mod scheduler;
|
||||||
|
|
||||||
|
use crate::auto_task::TaskManifest;
|
||||||
use crate::core::urls::ApiUrls;
|
use crate::core::urls::ApiUrls;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
|
|
@ -352,9 +353,17 @@ pub async fn handle_task_delete(
|
||||||
pub async fn handle_task_get(
|
pub async fn handle_task_get(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
log::info!("[TASK_GET] *** Handler called for task: {} ***", id);
|
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 conn = state.conn.clone();
|
||||||
let task_id = id.clone();
|
let task_id = id.clone();
|
||||||
|
|
||||||
|
|
@ -430,7 +439,31 @@ pub async fn handle_task_get(
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(Some(task)) => {
|
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() {
|
let status_class = match task.status.as_str() {
|
||||||
"completed" | "done" => "completed",
|
"completed" | "done" => "completed",
|
||||||
"running" | "pending" => "running",
|
"running" | "pending" => "running",
|
||||||
|
|
@ -438,7 +471,6 @@ pub async fn handle_task_get(
|
||||||
_ => "pending"
|
_ => "pending"
|
||||||
};
|
};
|
||||||
let progress_percent = (task.progress * 100.0) as u8;
|
let progress_percent = (task.progress * 100.0) as u8;
|
||||||
let created = task.created_at.format("%Y-%m-%d %H:%M").to_string();
|
|
||||||
|
|
||||||
// Calculate runtime
|
// Calculate runtime
|
||||||
let runtime = if let Some(started) = task.started_at {
|
let runtime = if let Some(started) = task.started_at {
|
||||||
|
|
@ -464,9 +496,6 @@ pub async fn handle_task_get(
|
||||||
</div>"#, e
|
</div>"#, e
|
||||||
)).unwrap_or_default();
|
)).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() {
|
let status_label = match task.status.as_str() {
|
||||||
"completed" | "done" => "Completed",
|
"completed" | "done" => "Completed",
|
||||||
"running" => "Running",
|
"running" => "Running",
|
||||||
|
|
@ -477,116 +506,109 @@ pub async fn handle_task_get(
|
||||||
_ => &task.status
|
_ => &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
|
// Build terminal output from recent activity
|
||||||
let terminal_html = build_terminal_html(&task.step_results, &task.status);
|
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#"
|
let html = format!(r#"
|
||||||
<div class="task-detail-rich" data-task-id="{task_id}">
|
<div class="task-detail-rich" data-task-id="{task_id}">
|
||||||
<!-- Header with title and status badge -->
|
<!-- Header -->
|
||||||
<div class="detail-header-rich">
|
<div class="taskmd-header">
|
||||||
<h2 class="detail-title-rich">{title}</h2>
|
<div class="taskmd-url">
|
||||||
<span class="status-badge-rich status-{status_class}">{status_label}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Status Section -->
|
{error_html}
|
||||||
<div class="detail-section-box status-section">
|
|
||||||
<div class="section-label">STATUS</div>
|
<!-- STATUS Section -->
|
||||||
<div class="status-content">
|
<div class="taskmd-section">
|
||||||
<div class="status-main">
|
<div class="taskmd-section-header">STATUS</div>
|
||||||
<span class="status-dot status-{status_class}"></span>
|
<div class="taskmd-status-content">
|
||||||
<span class="status-text">{title}</span>
|
{status_html}
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress Bar -->
|
<!-- PROGRESS LOG Section -->
|
||||||
<div class="detail-progress-rich">
|
<div class="taskmd-section">
|
||||||
<div class="progress-bar-rich">
|
<div class="taskmd-section-header">PROGRESS LOG</div>
|
||||||
<div class="progress-fill-rich" style="width: {progress_percent}%"></div>
|
<div class="taskmd-progress-content" id="progress-log-{task_id}">
|
||||||
</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_html}
|
{progress_log_html}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal Section -->
|
<!-- TERMINAL Section -->
|
||||||
<div class="detail-section-box terminal-section-rich">
|
<div class="taskmd-section taskmd-terminal">
|
||||||
<div class="section-header-rich">
|
<div class="taskmd-terminal-header">
|
||||||
<div class="section-label">
|
<div class="taskmd-terminal-title">
|
||||||
<span class="terminal-dot-rich {terminal_active}"></span>
|
<span class="terminal-dot {terminal_active}"></span>
|
||||||
TERMINAL (LIVE AGENT ACTIVITY)
|
<span>TERMINAL (LIVE AGENT ACTIVITY)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="terminal-stats-rich">
|
<div class="taskmd-terminal-stats">
|
||||||
<span>Step: <strong>{current_step}</strong> of <strong>{total_steps}</strong></span>
|
<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>
|
</div>
|
||||||
<div class="terminal-output-rich" id="terminal-output-{task_id}">
|
<div class="taskmd-terminal-output" id="terminal-output-{task_id}">
|
||||||
{terminal_html}
|
{terminal_html}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="detail-actions-rich">
|
<div class="taskmd-actions">
|
||||||
<button class="btn-action-rich btn-pause" onclick="pauseTask('{task_id}')">
|
{app_button_html}
|
||||||
<span class="btn-icon">⏸</span> Pause
|
{cancel_button_html}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"#,
|
"#,
|
||||||
task_id = task_id,
|
task_id = task_id,
|
||||||
|
task_url = task_url,
|
||||||
title = task.title,
|
title = task.title,
|
||||||
status_class = status_class,
|
status_class = status_class,
|
||||||
status_label = status_label,
|
status_label = status_label,
|
||||||
runtime = runtime,
|
|
||||||
current_step = current_step,
|
|
||||||
total_steps = total_steps,
|
|
||||||
error_html = error_html,
|
error_html = error_html,
|
||||||
status_indicator = if task.status == "running" { "active" } else { "" },
|
status_html = status_html,
|
||||||
priority = task.priority,
|
|
||||||
progress_percent = progress_percent,
|
|
||||||
progress_log_html = progress_log_html,
|
progress_log_html = progress_log_html,
|
||||||
terminal_active = if task.status == "running" { "active" } else { "" },
|
terminal_active = if task.status == "running" { "active" } else { "" },
|
||||||
terminal_html = terminal_html,
|
terminal_html = terminal_html,
|
||||||
created = created,
|
app_button_html = app_button_html,
|
||||||
intent_text = intent_text,
|
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()
|
(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 extract_app_url_from_results(step_results: &Option<serde_json::Value>, title: &str) -> Option<String> {
|
||||||
fn build_progress_log_html(step_results: &Option<serde_json::Value>, current_step: i32, total_steps: i32) -> String {
|
|
||||||
let mut html = String::new();
|
|
||||||
|
|
||||||
if let Some(serde_json::Value::Array(steps)) = step_results {
|
if let Some(serde_json::Value::Array(steps)) = step_results {
|
||||||
if steps.is_empty() {
|
for step in steps.iter() {
|
||||||
// No steps yet - show current status
|
if let Some(logs) = step.get("logs").and_then(|v| v.as_array()) {
|
||||||
html.push_str(&format!(r#"
|
for log in logs.iter() {
|
||||||
<div class="log-group">
|
if let Some(msg) = log.get("message").and_then(|v| v.as_str()) {
|
||||||
<div class="log-group-header">
|
if msg.contains("/apps/") {
|
||||||
<span class="log-group-name">Task Execution</span>
|
if let Some(start) = msg.find("/apps/") {
|
||||||
<span class="log-step-badge">Step {}/{}</span>
|
let rest = &msg[start..];
|
||||||
<span class="log-status-badge running">In Progress</span>
|
let end = rest.find(|c: char| c.is_whitespace() || c == '"' || c == '\'').unwrap_or(rest.len());
|
||||||
</div>
|
let url = rest[..end].to_string();
|
||||||
<div class="log-group-items">
|
// Add trailing slash if not present
|
||||||
<div class="log-item">
|
if url.ends_with('/') {
|
||||||
<span class="log-dot running"></span>
|
return Some(url);
|
||||||
<span class="log-item-name">Waiting for execution steps...</span>
|
} else {
|
||||||
</div>
|
return Some(format!("{}/", url));
|
||||||
</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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
} 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#"
|
html.push_str(&format!(r#"
|
||||||
<div class="log-group">
|
<div class="status-row status-decision">
|
||||||
<div class="log-group-header">
|
<span class="status-dot pending"></span>
|
||||||
<span class="log-group-name">Task Progress</span>
|
<span class="status-text">Decision Point Coming (Step {}/{})</span>
|
||||||
<span class="log-step-badge">Step {}/{}</span>
|
<span class="status-badge">{}</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>
|
</div>
|
||||||
"#, current_step, total_steps));
|
"#, dp.step_current, dp.step_total, dp.message));
|
||||||
}
|
}
|
||||||
|
|
||||||
html
|
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 {
|
fn build_terminal_html(step_results: &Option<serde_json::Value>, status: &str) -> String {
|
||||||
let mut html = String::new();
|
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 {
|
if let Some(serde_json::Value::Array(steps)) = step_results {
|
||||||
for step in steps.iter() {
|
for step in steps.iter() {
|
||||||
// Add step name as a line
|
let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
if let Some(step_name) = step.get("step_name").and_then(|v| v.as_str()) {
|
let is_current = step_status == "running" || step_status == "Running";
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add log messages
|
|
||||||
if let Some(serde_json::Value::Array(logs)) = step.get("logs") {
|
if let Some(serde_json::Value::Array(logs)) = step.get("logs") {
|
||||||
for log_entry in logs.iter() {
|
for log_entry in logs.iter() {
|
||||||
if let Some(msg) = log_entry.get("message").and_then(|v| v.as_str()) {
|
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() {
|
if output_lines.is_empty() {
|
||||||
// Show default message based on status
|
let msg = match status {
|
||||||
let default_msg = match status {
|
"running" => "Agent working...",
|
||||||
"running" => "Task is running...",
|
|
||||||
"pending" => "Waiting to start...",
|
"pending" => "Waiting to start...",
|
||||||
"completed" | "done" => "Task completed successfully",
|
"completed" | "done" => "✓ Task completed",
|
||||||
"failed" | "error" => "Task failed - check error details",
|
"failed" | "error" => "✗ Task failed",
|
||||||
"paused" => "Task is paused",
|
"paused" => "Task paused",
|
||||||
_ => "Initializing..."
|
_ => "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 {
|
} else {
|
||||||
// Show last 10 lines, with the last one marked as current
|
let start = if output_lines.len() > 15 { output_lines.len() - 15 } else { 0 };
|
||||||
let start = if lines.len() > 10 { lines.len() - 10 } else { 0 };
|
for (line, is_current) in output_lines[start..].iter() {
|
||||||
for (idx, line) in lines[start..].iter().enumerate() {
|
let class = if *is_current { "terminal-line current" } else { "terminal-line" };
|
||||||
let is_last = idx == lines[start..].len() - 1;
|
let escaped = line.replace('<', "<").replace('>', ">");
|
||||||
let class = if is_last && status == "running" { "terminal-line current" } else { "terminal-line" };
|
html.push_str(&format!(r#"<div class="{}">{}</div>"#, class, escaped));
|
||||||
html.push_str(&format!(r#"<div class="{}">{}</div>"#, class, line));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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/status", put(handle_task_status_update))
|
||||||
.route("/api/tasks/:id/priority", put(handle_task_priority_set))
|
.route("/api/tasks/:id/priority", put(handle_task_priority_set))
|
||||||
.route("/api/tasks/:id/dependencies", put(handle_task_set_dependencies))
|
.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>> {
|
pub fn configure(router: Router<Arc<TaskEngine>>) -> Router<Arc<TaskEngine>> {
|
||||||
|
|
@ -1714,39 +2004,74 @@ pub async fn handle_task_list_htmx(
|
||||||
_ => "status-pending"
|
_ => "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!(
|
let _ = write!(
|
||||||
html,
|
html,
|
||||||
r#"
|
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">
|
<div class="task-card-header">
|
||||||
<span class="task-card-title">{}</span>
|
{task_icon}
|
||||||
<span class="task-card-status {}">{}</span>
|
<span class="task-card-title">{title}</span>
|
||||||
|
<span class="task-card-status {status_class}">{status}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-card-body">
|
<div class="task-card-body">
|
||||||
<div class="task-card-priority">
|
<div class="task-card-priority">
|
||||||
<span class="priority-badge priority-{}">{}</span>
|
<span class="priority-badge priority-{priority}">{priority}</span>
|
||||||
</div>
|
</div>
|
||||||
{due_date_html}
|
{due_date_html}
|
||||||
|
{open_app_btn}
|
||||||
</div>
|
</div>
|
||||||
<div class="task-card-footer">
|
<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>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"#,
|
"#,
|
||||||
task.id,
|
task_id = task.id,
|
||||||
task.id,
|
task_icon = task_icon,
|
||||||
task.title,
|
title = task.title,
|
||||||
status_class,
|
status_class = status_class,
|
||||||
task.status,
|
status = task.status,
|
||||||
task.priority,
|
priority = task.priority,
|
||||||
task.priority,
|
due_date_html = due_date_html,
|
||||||
task.id,
|
open_app_btn = open_app_btn,
|
||||||
task.id
|
completed_class = completed_class,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue