use std::sync::Arc; use axum::{ extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, Json, Router, }; use serde::Deserialize; use crate::AppState; use crate::agent::{AgentEvent, PlanStepInfo}; use crate::db::{Workflow, ExecutionLogEntry, Comment, LlmCallLogEntry}; use crate::state::AgentState; use crate::template; use super::{ApiResult, db_err}; #[derive(serde::Serialize)] struct ReportResponse { project_id: String, report: String, } #[derive(Deserialize)] pub struct CreateWorkflow { pub requirement: String, #[serde(default)] pub template_id: Option, #[serde(default)] pub worker: Option, } #[derive(Deserialize)] pub struct CreateComment { pub content: String, } pub fn router(state: Arc) -> Router { Router::new() .route("/projects/{id}/workflows", get(list_workflows).post(create_workflow)) .route("/workflows/{id}/steps", get(list_steps)) .route("/workflows/{id}/comments", get(list_comments).post(create_comment)) .route("/workflows/{id}/report", get(get_report)) .route("/workflows/{id}/plan", get(get_plan)) .route("/workflows/{id}/llm-calls", get(list_llm_calls)) .route("/templates", get(list_templates)) .with_state(state) } async fn list_workflows( State(state): State>, Path(project_id): Path, ) -> ApiResult> { sqlx::query_as::<_, Workflow>( "SELECT * FROM workflows WHERE project_id = ? ORDER BY created_at DESC" ) .bind(&project_id) .fetch_all(&state.db.pool) .await .map(Json) .map_err(db_err) } async fn create_workflow( State(state): State>, Path(project_id): Path, Json(input): Json, ) -> ApiResult { let id = uuid::Uuid::new_v4().to_string(); let workflow = sqlx::query_as::<_, Workflow>( "INSERT INTO workflows (id, project_id, requirement) VALUES (?, ?, ?) RETURNING *" ) .bind(&id) .bind(&project_id) .bind(&input.requirement) .fetch_one(&state.db.pool) .await .map_err(db_err)?; state.agent_mgr.send_event(&project_id, AgentEvent::NewRequirement { workflow_id: workflow.id.clone(), requirement: workflow.requirement.clone(), template_id: input.template_id, worker: input.worker, }).await; Ok(Json(workflow)) } /// Convert a JSON string to a simple YAML-like representation for display. /// Falls back to the original string if it's not a JSON object. fn json_to_yaml(input: &str) -> String { let obj: serde_json::Map = match serde_json::from_str(input) { Ok(v) => v, Err(_) => return input.to_string(), }; let mut lines = Vec::new(); for (k, v) in &obj { match v { serde_json::Value::String(s) if s.contains('\n') => { lines.push(format!("{}: |", k)); for line in s.lines() { lines.push(format!(" {}", line)); } } serde_json::Value::String(s) => lines.push(format!("{}: {}", k, s)), other => lines.push(format!("{}: {}", k, other)), } } lines.join("\n") } async fn list_steps( State(state): State>, Path(workflow_id): Path, ) -> ApiResult> { sqlx::query_as::<_, ExecutionLogEntry>( "SELECT * FROM execution_log WHERE workflow_id = ? ORDER BY created_at" ) .bind(&workflow_id) .fetch_all(&state.db.pool) .await .map(|entries| Json(entries.into_iter().map(|mut e| { e.tool_input = json_to_yaml(&e.tool_input); e }).collect())) .map_err(db_err) } async fn list_comments( State(state): State>, Path(workflow_id): Path, ) -> ApiResult> { sqlx::query_as::<_, Comment>( "SELECT * FROM comments WHERE workflow_id = ? ORDER BY created_at" ) .bind(&workflow_id) .fetch_all(&state.db.pool) .await .map(Json) .map_err(db_err) } async fn create_comment( State(state): State>, Path(workflow_id): Path, Json(input): Json, ) -> ApiResult { let id = uuid::Uuid::new_v4().to_string(); let comment = sqlx::query_as::<_, Comment>( "INSERT INTO comments (id, workflow_id, content) VALUES (?, ?, ?) RETURNING *" ) .bind(&id) .bind(&workflow_id) .bind(&input.content) .fetch_one(&state.db.pool) .await .map_err(db_err)?; // Notify agent about the comment if let Ok(Some(wf)) = sqlx::query_as::<_, Workflow>( "SELECT * FROM workflows WHERE id = ?" ) .bind(&workflow_id) .fetch_optional(&state.db.pool) .await { state.agent_mgr.send_event(&wf.project_id, AgentEvent::Comment { workflow_id: workflow_id.clone(), content: input.content, }).await; } Ok(Json(comment)) } async fn get_report( State(state): State>, Path(workflow_id): Path, ) -> Result, Response> { let wf = sqlx::query_as::<_, Workflow>( "SELECT * FROM workflows WHERE id = ?" ) .bind(&workflow_id) .fetch_optional(&state.db.pool) .await .map_err(db_err)?; let wf = match wf { Some(w) => w, None => return Err((StatusCode::NOT_FOUND, "Workflow not found").into_response()), }; // Read report.md from workspace let workspace_root = if std::path::Path::new("/app/data/workspaces").is_dir() { "/app/data/workspaces" } else { "data/workspaces" }; let report_path = format!("{}/{}/report.md", workspace_root, wf.project_id); let report = tokio::fs::read_to_string(&report_path).await.unwrap_or_default(); if report.is_empty() { return Err((StatusCode::NOT_FOUND, "report.md not found").into_response()); } Ok(Json(ReportResponse { project_id: wf.project_id, report })) } async fn get_plan( State(state): State>, Path(workflow_id): Path, ) -> ApiResult> { let snapshot_json: Option = sqlx::query_scalar( "SELECT state_json FROM agent_state_snapshots WHERE workflow_id = ? ORDER BY created_at DESC LIMIT 1" ) .bind(&workflow_id) .fetch_optional(&state.db.pool) .await .map_err(db_err)?; if let Some(json) = snapshot_json { if let Ok(agent_state) = serde_json::from_str::(&json) { return Ok(Json(crate::agent::plan_infos_from_state(&agent_state))); } } Ok(Json(vec![])) } async fn list_llm_calls( State(state): State>, Path(workflow_id): Path, ) -> ApiResult> { sqlx::query_as::<_, LlmCallLogEntry>( "SELECT * FROM llm_call_log WHERE workflow_id = ? ORDER BY created_at" ) .bind(&workflow_id) .fetch_all(&state.db.pool) .await .map(Json) .map_err(db_err) } async fn list_templates( State(state): State>, ) -> Json> { Json(template::list_all_templates(state.config.template_repo.as_ref()).await) }