diff options
Diffstat (limited to 'makima/src/server/handlers/contract_chat.rs')
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 3183 |
1 files changed, 0 insertions, 3183 deletions
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs deleted file mode 100644 index 5d8ab3e..0000000 --- a/makima/src/server/handlers/contract_chat.rs +++ /dev/null @@ -1,3183 +0,0 @@ -//! Chat endpoint for LLM-powered contract management. -//! -//! This handler provides an agentic loop for managing contracts: creating tasks, -//! adding files, managing repositories, and handling phase transitions. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{ - models::{ - ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest, - }, - repository, -}; -use crate::llm::{ - analyze_task_output, body_to_markdown, format_checklist_markdown, - format_parsed_tasks, parse_tasks_from_breakdown, - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - parse_contract_tool_call, ContractToolRequest, - LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS, - format_transcript_for_analysis, calculate_speaker_stats, - build_analysis_prompt, parse_analysis_response, -}; -use crate::server::auth::Authenticated; -use crate::server::state::{DaemonCommand, SharedState}; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 30; - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatHistoryMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatRequest { - /// The user's message/instruction - pub message: String, - /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq" - #[serde(default)] - pub model: Option<String>, - /// Optional conversation history for context continuity - #[serde(default)] - pub history: Option<Vec<ContractChatHistoryMessage>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatResponse { - /// The LLM's response message - pub response: String, - /// Tool calls that were executed - pub tool_calls: Vec<ContractToolCallInfo>, - /// Questions pending user answers (pauses conversation) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Enum to hold LLM clients -enum LlmClient { - Groq(GroqClient), - Claude(ClaudeClient), -} - -/// Unified result from LLM call -struct LlmResult { - content: Option<String>, - tool_calls: Vec<ToolCall>, - raw_tool_calls: Vec<ToolCallResponse>, - finish_reason: String, -} - -/// Helper to get contract with all relations -async fn get_contract_with_relations( - pool: &sqlx::PgPool, - contract_id: Uuid, - owner_id: Uuid, -) -> Result<Option<ContractWithRelations>, sqlx::Error> { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await? { - Some(c) => c, - None => return Ok(None), - }; - - let repositories = repository::list_contract_repositories(pool, contract_id) - .await - .unwrap_or_default(); - - let files = repository::list_files_in_contract(pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - let tasks = repository::list_tasks_in_contract(pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - Ok(Some(ContractWithRelations { - contract, - repositories, - files, - tasks, - })) -} - -/// Chat with a contract using LLM tool calling for management -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/chat", - request_body = ContractChatRequest, - responses( - (status = 200, description = "Chat completed successfully", body = ContractChatResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn contract_chat_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, - Json(request): Json<ContractChatRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Get the contract (scoped by owner) - let contract = match get_contract_with_relations(pool, contract_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or(LlmModel::ClaudeSonnet); - - tracing::info!("Contract chat using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::GroqKimi => match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "GROQ_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Groq client error: {}", e) })), - ) - .into_response(); - } - }, - }; - - // Build contract context - let contract_context = build_contract_context(&contract); - - // Build system prompt for contract management - let system_prompt = format!( - r#"You are an intelligent contract management agent. You guide users through the contract lifecycle from research to completion, helping them organize work, create documentation, set up repositories, and execute tasks. - -## Your Capabilities -You have access to tools for: -- **Query**: get_contract_status, list_contract_files, list_contract_tasks, list_contract_repositories, read_file -- **File Management**: create_file_from_template, create_empty_file, list_available_templates -- **Task Management**: create_contract_task, delegate_content_generation, start_task -- **Phase Management**: get_phase_info, suggest_phase_transition, advance_phase -- **Repository Management**: list_daemon_directories, add_repository, set_primary_repository -- **Interactive**: ask_user - -## Content Generation Deferral -When asked to write substantial content, fill templates, or generate documentation: -- **Use delegate_content_generation** to create a task for the content generation -- This delegates the work to a task agent that can do more thorough research and writing - -**Use delegation for:** -- Filling in template content with real data -- Writing documentation based on requirements -- Generating user stories or specifications -- Creating detailed design documents -- Any substantial writing that requires research or analysis - -**Direct actions (no delegation needed):** -- Listing files/tasks/repos -- Reading files -- Phase transitions -- Creating empty files or templates -- Simple queries and status checks -- Asking user questions - -## Contract Lifecycle Phases - -### 1. RESEARCH Phase -**Purpose**: Gather information and understand the problem space -**Key Activities**: -- Conduct user research and interviews -- Analyze competitors and existing solutions -- Document findings and insights -- Identify opportunities and constraints -**Suggested Actions**: -- Create a "Research Notes" document to capture findings -- Create a "Competitor Analysis" document -- When research is complete, suggest transitioning to Specify phase - -### 2. SPECIFY Phase -**Purpose**: Define what needs to be built -**Key Activities**: -- Write clear requirements -- Create user stories with acceptance criteria -- Define scope and constraints -- Document technical constraints -**Suggested Actions**: -- Create a "Requirements" document -- Create "User Stories" with acceptance criteria -- When specifications are clear, suggest transitioning to Plan phase - -### 3. PLAN Phase -**Purpose**: Design the solution and break down the work -**Key Activities**: -- Design system architecture -- Create technical specifications -- Break work into implementable tasks -- Set up repositories for development -**Suggested Actions**: -- Create an "Architecture" document -- Create a "Task Breakdown" document -- **IMPORTANT**: Help set up a repository if not already configured -- When planning is complete and a repository is set, suggest transitioning to Execute phase - -### 4. EXECUTE Phase -**Purpose**: Implement the solution -**Key Activities**: -- Create and run tasks to implement features -- Write and run tests -- Track progress -- Document implementation decisions -**Suggested Actions**: -- Create tasks based on the task breakdown -- Monitor task progress and help resolve blockers -- When all tasks are complete, suggest transitioning to Review phase - -### 5. REVIEW Phase -**Purpose**: Validate and document the completed work -**Key Activities**: -- Review completed work -- Create release notes -- Conduct retrospective -- Document learnings -**Suggested Actions**: -- Create a "Release Notes" document -- Create a "Retrospective" document -- Help mark the contract as complete when review is done - -## Current Contract -{contract_context} - -## Proactive Guidance - -### Repository Setup (Critical for Plan/Execute phases) -When the user wants to add a local repository or set up for execution: -1. **First call list_daemon_directories** to get available paths from connected agents -2. Present the suggested directories to the user -3. Ask which path they want to use, or let them specify a custom path -4. Then call add_repository with the chosen path - -Example flow: -``` -User: "Set up a repository for this contract" -You: Call list_daemon_directories first -You: "I found these directories from your connected agent: - - /Users/alice/projects (Working Directory) - - /Users/alice/.makima/home (Makima Home) - Which would you like to use, or provide a custom path?" -``` - -### Phase Transitions -- Phases progress in order: research -> specify -> plan -> execute -> review -- You can ONLY advance forward one step at a time to the NEXT phase -- ALWAYS use suggest_phase_transition FIRST to get the exact nextPhase value -- Then use advance_phase with that exact nextPhase value -- Example: If currentPhase is "specify", nextPhase will be "plan" - use advance_phase with new_phase="plan" -- NEVER suggest advancing to the same phase the contract is already in - -### New Users -When a new contract is created or the user seems unsure: -1. Explain the current phase and what should be done -2. Suggest creating appropriate documents -3. Guide them toward the next milestone - -## Agentic Behavior Guidelines - -### 1. Understand Before Acting -- For complex requests, first gather information about the contract's current state -- Use get_contract_status or list_contract_files to understand what exists -- Consider the current phase when suggesting actions - -### 2. Phase-Appropriate Suggestions -- Suggest templates and actions appropriate for the current phase -- When creating files, prefer templates that match the contract's phase -- Advise when the contract might be ready for the next phase - -### 3. Help Plan Work -- When asked to plan work, read existing files to understand context -- Suggest creating tasks based on requirements or plans in files -- Offer to create task breakdowns from design documents - -### 4. Repository Management -- When adding local repositories, ALWAYS use list_daemon_directories first to get suggestions -- This provides the user with valid paths from their connected agents -- Don't ask users to manually type paths when suggestions are available - -### 5. Task Creation and Execution -- When creating tasks, derive plans from existing contract files when possible -- Use the contract's primary repository for tasks by default -- Create clear, actionable task plans -- After creating a task, you can use **start_task** to immediately begin execution -- A daemon must be connected for start_task to work - -### 6. Be Proactive but Efficient -- Guide users through the contract flow -- Don't over-analyze simple requests -- Use the minimum number of tool calls needed -- Provide clear summaries of actions taken - -## Important Notes -- This contract's ID is: {contract_id} -- All operations are scoped to this contract -- When creating tasks or files, they are automatically associated with this contract"#, - contract_context = contract_context, - contract_id = contract_id - ); - - // Run the agentic loop - run_contract_agentic_loop( - pool, - &state, - &llm_client, - system_prompt, - &request, - contract_id, - auth.owner_id, - ) - .await -} - -fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -> String { - let c = &contract.contract; - let mut context = format!( - "Name: {}\nID: {}\nPhase: {}\nStatus: {}\nContract Type: {}\nAutonomous Loop: {}\n", - c.name, c.id, c.phase, c.status, c.contract_type, c.autonomous_loop - ); - - if let Some(ref desc) = c.description { - context.push_str(&format!("Description: {}\n", desc)); - } - - // Get completed deliverables for the current phase - let completed_deliverables = c.get_completed_deliverables(&c.phase); - - // Build task infos for checklist - let task_infos: Vec<TaskInfo> = contract.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !contract.repositories.is_empty(); - let phase_checklist = crate::llm::get_phase_checklist_for_type(&c.phase, &completed_deliverables, &task_infos, has_repository, &c.contract_type); - - // Add phase checklist to context - context.push_str("\n"); - context.push_str(&format_checklist_markdown(&phase_checklist)); - - // Add deliverable check result for phase transition readiness - let deliverable_check = crate::llm::check_deliverables_met( - &c.phase, - &c.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Add deliverable prompt guidance - context.push_str(&crate::llm::generate_deliverable_prompt_guidance( - &c.phase, - &c.contract_type, - &deliverable_check, - )); - - // Files summary - context.push_str(&format!("\n### Files ({} total)\n", contract.files.len())); - if !contract.files.is_empty() { - for file in contract.files.iter().take(5) { - let phase_label = file.contract_phase.as_deref().unwrap_or("none"); - context.push_str(&format!("- {} [{}] (ID: {})\n", file.name, phase_label, file.id)); - } - if contract.files.len() > 5 { - context.push_str(&format!("... and {} more\n", contract.files.len() - 5)); - } - } - - // Tasks summary - context.push_str(&format!("\n### Tasks ({} total)\n", contract.tasks.len())); - if !contract.tasks.is_empty() { - let pending = contract.tasks.iter().filter(|t| t.status == "pending").count(); - let running = contract.tasks.iter().filter(|t| t.status == "running").count(); - let done = contract.tasks.iter().filter(|t| t.status == "done").count(); - context.push_str(&format!("{} pending, {} running, {} done\n", pending, running, done)); - for task in contract.tasks.iter().take(5) { - context.push_str(&format!("- {} ({}) - ID: {}\n", task.name, task.status, task.id)); - } - if contract.tasks.len() > 5 { - context.push_str(&format!("... and {} more\n", contract.tasks.len() - 5)); - } - } - - // Repositories summary - context.push_str(&format!("\n### Repositories ({} total)\n", contract.repositories.len())); - if !contract.repositories.is_empty() { - for repo in &contract.repositories { - let primary = if repo.is_primary { " (primary)" } else { "" }; - let url_or_path = repo.repository_url.as_deref() - .or(repo.local_path.as_deref()) - .unwrap_or("managed"); - context.push_str(&format!("- {}: {}{}\n", repo.name, url_or_path, primary)); - } - } - - context -} - -/// Summarize older conversation history to reduce token usage -async fn summarize_conversation_history( - llm_client: &LlmClient, - messages: &[&crate::db::models::ContractChatMessageRecord], -) -> String { - // Build conversation text for summarization - let mut conversation_text = String::new(); - for msg in messages { - let role_label = if msg.role == "user" { "User" } else { "Assistant" }; - // Limit each message to avoid overwhelming the summarizer - let content = if msg.content.len() > 500 { - format!("{}...", &msg.content[..500]) - } else { - msg.content.clone() - }; - conversation_text.push_str(&format!("{}: {}\n", role_label, content)); - } - - // Limit total text to summarize - if conversation_text.len() > 8000 { - conversation_text = format!("{}...", &conversation_text[..8000]); - } - - let summary_prompt = format!( - "Summarize this conversation history in 2-3 sentences, focusing on key decisions, actions taken, and current state:\n\n{}", - conversation_text - ); - - // Use a simple chat call without tools for summarization - let summary = match llm_client { - LlmClient::Claude(client) => { - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(summary_prompt.clone()), - }]; - match client.chat_with_tools(claude_messages, &[]).await { - Ok(response) => response.content.unwrap_or_default(), - Err(e) => { - tracing::warn!("Failed to summarize conversation: {}", e); - "Previous conversation covered contract management tasks.".to_string() - } - } - } - LlmClient::Groq(client) => { - let groq_messages = vec![Message { - role: "user".to_string(), - content: Some(summary_prompt.clone()), - tool_calls: None, - tool_call_id: None, - }]; - match client.chat_with_tools(groq_messages, &[]).await { - Ok(response) => response.content.unwrap_or_default(), - Err(e) => { - tracing::warn!("Failed to summarize conversation: {}", e); - "Previous conversation covered contract management tasks.".to_string() - } - } - } - }; - - // Limit summary length - if summary.len() > 500 { - format!("{}...", &summary[..500]) - } else { - summary - } -} - -/// Run the agentic loop for contract chat -async fn run_contract_agentic_loop( - pool: &sqlx::PgPool, - state: &SharedState, - llm_client: &LlmClient, - system_prompt: String, - request: &ContractChatRequest, - contract_id: Uuid, - owner_id: Uuid, -) -> axum::response::Response { - // Get or create the conversation for persistent history - let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, owner_id).await { - Ok(conv) => conv, - Err(e) => { - tracing::error!("Failed to get/create contract conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })), - ) - .into_response(); - } - }; - - // Load ALL existing messages from database - let saved_messages = match repository::list_contract_chat_messages(pool, conversation.id, None).await { - Ok(msgs) => msgs, - Err(e) => { - tracing::warn!("Failed to load contract chat history: {}", e); - Vec::new() - } - }; - - // Build initial messages - let mut messages = vec![Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }]; - - // Add saved conversation history, summarizing older messages if needed - // to stay under rate limits (~25k chars ≈ ~6k tokens for history) - const MAX_HISTORY_CHARS: usize = 25000; - const RECENT_MESSAGES_TO_KEEP: usize = 6; // Keep last 3 turns intact - - // Filter to user/assistant messages only - let history_messages: Vec<_> = saved_messages - .iter() - .filter(|m| m.role == "user" || m.role == "assistant") - .collect(); - - // Calculate total character count - let total_chars: usize = history_messages.iter().map(|m| m.content.len()).sum(); - - if total_chars > MAX_HISTORY_CHARS && history_messages.len() > RECENT_MESSAGES_TO_KEEP { - // Need to summarize older messages - let split_point = history_messages.len().saturating_sub(RECENT_MESSAGES_TO_KEEP); - let older_messages = &history_messages[..split_point]; - let recent_messages = &history_messages[split_point..]; - - // Generate summary of older conversation - let summary = summarize_conversation_history(&llm_client, older_messages).await; - - // Add summary as context - messages.push(Message { - role: "user".to_string(), - content: Some(format!("[Previous conversation summary: {}]", summary)), - tool_calls: None, - tool_call_id: None, - }); - messages.push(Message { - role: "assistant".to_string(), - content: Some("I understand the previous context. Let's continue.".to_string()), - tool_calls: None, - tool_call_id: None, - }); - - // Add recent messages in full - for saved_msg in recent_messages { - messages.push(Message { - role: saved_msg.role.clone(), - content: Some(saved_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - - tracing::info!( - total_messages = history_messages.len(), - summarized = older_messages.len(), - kept_recent = recent_messages.len(), - "Summarized older conversation history" - ); - } else { - // Add all messages directly - for saved_msg in history_messages { - messages.push(Message { - role: saved_msg.role.clone(), - content: Some(saved_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - } - - // Add current user message - messages.push(Message { - role: "user".to_string(), - content: Some(request.message.clone()), - tool_calls: None, - tool_call_id: None, - }); - - // Save the user message to database - if let Err(e) = repository::add_contract_chat_message( - pool, - conversation.id, - "user", - &request.message, - None, - None, - ).await { - tracing::warn!("Failed to save user message to contract chat history: {}", e); - } - - // State for tracking - let mut all_tool_call_infos: Vec<ContractToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - let mut consecutive_failures = 0; - const MAX_CONSECUTIVE_FAILURES: usize = 3; - let mut pending_questions: Option<Vec<UserQuestion>> = None; - - // Multi-turn agentic tool calling loop - for round in 0..MAX_TOOL_ROUNDS { - tracing::info!( - round = round, - total_tool_calls = all_tool_call_infos.len(), - "Contract agentic loop iteration" - ); - - // Check consecutive failures - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - tracing::warn!( - "Breaking contract loop due to {} consecutive failures", - consecutive_failures - ); - final_response = Some( - "I encountered multiple consecutive errors and stopped. \ - Please check the contract state and try again." - .to_string(), - ); - break; - } - - // Call the appropriate LLM API - let result = match llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &CONTRACT_TOOLS).await { - Ok(r) => LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls: r.raw_tool_calls, - finish_reason: r.finish_reason, - }, - Err(e) => { - tracing::error!("Groq API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - LlmClient::Claude(claude_client) => { - let claude_messages = claude::groq_messages_to_claude(&messages); - match claude_client - .chat_with_tools(claude_messages, &CONTRACT_TOOLS) - .await - { - Ok(r) => { - let raw_tool_calls: Vec<ToolCallResponse> = r - .tool_calls - .iter() - .map(|tc| ToolCallResponse { - id: tc.id.clone(), - call_type: "function".to_string(), - function: crate::llm::groq::FunctionCall { - name: tc.name.clone(), - arguments: tc.arguments.to_string(), - }, - }) - .collect(); - - LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls, - finish_reason: r.stop_reason, - } - } - Err(e) => { - tracing::error!("Claude API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - }; - - // Check if there are tool calls to execute - if result.tool_calls.is_empty() { - final_response = result.content; - break; - } - - // Add assistant message with tool calls to conversation - messages.push(Message { - role: "assistant".to_string(), - content: result.content.clone(), - tool_calls: Some(result.raw_tool_calls.clone()), - tool_call_id: None, - }); - - // Execute each tool call - for (i, tool_call) in result.tool_calls.iter().enumerate() { - tracing::info!(tool = %tool_call.name, round = round, "Executing contract tool call"); - - // Parse the tool call - let mut execution_result = parse_contract_tool_call(tool_call); - - // Handle async contract tool requests - if let Some(contract_request) = execution_result.request.take() { - let async_result = - handle_contract_request(pool, &state.daemon_connections, contract_request, contract_id, owner_id).await; - execution_result.success = async_result.success; - execution_result.message = async_result.message; - execution_result.data = async_result.data; - } - - // Track consecutive failures - if execution_result.success { - consecutive_failures = 0; - } else { - consecutive_failures += 1; - tracing::warn!( - tool = %tool_call.name, - consecutive_failures = consecutive_failures, - "Contract tool call failed" - ); - } - - // Check for pending user questions - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "Contract LLM requesting user input" - ); - pending_questions = Some(questions); - all_tool_call_infos.push(ContractToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message.clone(), - }, - }); - break; - } - - // Build tool result message - let result_content = if let Some(data) = &execution_result.data { - json!({ - "success": execution_result.success, - "message": execution_result.message, - "data": data - }) - .to_string() - } else { - json!({ - "success": execution_result.success, - "message": execution_result.message - }) - .to_string() - }; - - // Add tool result message - let tool_call_id = match llm_client { - LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), - LlmClient::Claude(_) => tool_call.id.clone(), - }; - - messages.push(Message { - role: "tool".to_string(), - content: Some(result_content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - }); - - // Track for response - all_tool_call_infos.push(ContractToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message, - }, - }); - } - - // If user questions are pending, pause - if pending_questions.is_some() { - final_response = result.content; - break; - } - - // If finish reason indicates completion, exit loop - let finish_lower = result.finish_reason.to_lowercase(); - if finish_lower == "stop" || finish_lower == "end_turn" { - final_response = result.content; - break; - } - } - - // Build response - let response_text = final_response.unwrap_or_else(|| { - if all_tool_call_infos.is_empty() { - "I couldn't understand your request. Please try rephrasing.".to_string() - } else { - format!( - "Done! Executed {} tool{}.", - all_tool_call_infos.len(), - if all_tool_call_infos.len() == 1 { "" } else { "s" } - ) - } - }); - - // Save assistant response to database - let tool_calls_json = if all_tool_call_infos.is_empty() { - None - } else { - serde_json::to_value(&all_tool_call_infos).ok() - }; - - let pending_questions_json = pending_questions.as_ref().and_then(|q| serde_json::to_value(q).ok()); - - if let Err(e) = repository::add_contract_chat_message( - pool, - conversation.id, - "assistant", - &response_text, - tool_calls_json, - pending_questions_json, - ).await { - tracing::warn!("Failed to save assistant response to contract chat history: {}", e); - } - - ( - StatusCode::OK, - Json(ContractChatResponse { - response: response_text, - tool_calls: all_tool_call_infos, - pending_questions, - }), - ) - .into_response() -} - -/// Result from handling an async contract tool request -struct ContractRequestResult { - success: bool, - message: String, - data: Option<serde_json::Value>, -} - -/// Handle async contract tool requests that require database access -async fn handle_contract_request( - pool: &sqlx::PgPool, - daemon_connections: &dashmap::DashMap<String, crate::server::state::DaemonConnectionInfo>, - request: ContractToolRequest, - contract_id: Uuid, - owner_id: Uuid, -) -> ContractRequestResult { - match request { - ContractToolRequest::ListDaemonDirectories => { - let mut directories = Vec::new(); - - // Iterate over connected daemons belonging to this owner - for entry in daemon_connections.iter() { - let daemon = entry.value(); - - // Only include daemons belonging to this owner - if daemon.owner_id != owner_id { - continue; - } - - // Add working directory if available - if let Some(ref working_dir) = daemon.working_directory { - directories.push(json!({ - "path": working_dir, - "label": "Working Directory", - "type": "working", - "hostname": daemon.hostname, - })); - } - - // Add home directory if available - if let Some(ref home_dir) = daemon.home_directory { - directories.push(json!({ - "path": home_dir, - "label": "Makima Home", - "type": "home", - "hostname": daemon.hostname, - })); - } - } - - if directories.is_empty() { - ContractRequestResult { - success: true, - message: "No daemon directories available. Connect a daemon to get directory suggestions.".to_string(), - data: Some(json!({ "directories": [] })), - } - } else { - ContractRequestResult { - success: true, - message: format!("Found {} suggested directories from connected daemons", directories.len()), - data: Some(json!({ "directories": directories })), - } - } - } - - ContractToolRequest::GetContractStatus => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let c = &cwr.contract; - ContractRequestResult { - success: true, - message: format!( - "Contract '{}' is in '{}' phase with status '{}'", - c.name, c.phase, c.status - ), - data: Some(json!({ - "name": c.name, - "phase": c.phase, - "status": c.status, - "description": c.description, - "fileCount": cwr.files.len(), - "taskCount": cwr.tasks.len(), - "repositoryCount": cwr.repositories.len(), - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractFiles => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let files: Vec<serde_json::Value> = cwr - .files - .iter() - .map(|f| { - json!({ - "fileId": f.id, - "name": f.name, - "description": f.description, - "phase": f.contract_phase, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} files", files.len()), - data: Some(json!({ "files": files })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractTasks => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let tasks: Vec<serde_json::Value> = cwr - .tasks - .iter() - .map(|t| { - json!({ - "taskId": t.id, - "name": t.name, - "status": t.status, - "priority": t.priority, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} tasks", tasks.len()), - data: Some(json!({ "tasks": tasks })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractRepositories => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let repos: Vec<serde_json::Value> = cwr - .repositories - .iter() - .map(|r| { - json!({ - "repositoryId": r.id, - "name": r.name, - "repositoryUrl": r.repository_url, - "localPath": r.local_path, - "isPrimary": r.is_primary, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} repositories", repos.len()), - data: Some(json!({ "repositories": repos })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ReadFile { file_id } => { - match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(file)) => { - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Convert body to markdown for LLM consumption - let markdown = body_to_markdown(&file.body); - - ContractRequestResult { - success: true, - message: format!("Read file '{}'", file.name), - data: Some(json!({ - "fileId": file.id, - "name": file.name, - "description": file.description, - "summary": file.summary, - "plainText": markdown, - "phase": file.contract_phase, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateEmptyFile { name, description } => { - // Verify contract exists and get current phase - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Create the file with current contract phase - let create_req = crate::db::models::CreateFileRequest { - contract_id, - name: Some(name.clone()), - description, - body: Vec::new(), - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some(contract.phase.clone()), - }; - - match repository::create_file_for_owner(pool, owner_id, create_req).await { - Ok(file) => ContractRequestResult { - success: true, - message: format!("Created empty file '{}'", name), - data: Some(json!({ - "fileId": file.id, - "name": file.name, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create file: {}", e), - data: None, - }, - } - } - - ContractToolRequest::MarkDeliverableComplete { - deliverable_id, - phase, - } => { - // Get the contract to determine current phase and contract type - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Use specified phase or default to current contract phase - let target_phase = phase.unwrap_or_else(|| contract.phase.clone()); - - // Validate the deliverable ID exists for this phase/contract type - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&target_phase, &contract.contract_type); - let deliverable_exists = phase_deliverables.deliverables.iter().any(|d| d.id == deliverable_id); - - if !deliverable_exists { - let valid_ids: Vec<&str> = phase_deliverables.deliverables.iter().map(|d| d.id.as_str()).collect(); - return ContractRequestResult { - success: false, - message: format!( - "Invalid deliverable_id '{}' for {} phase. Valid IDs: {:?}", - deliverable_id, target_phase, valid_ids - ), - data: None, - }; - } - - // Check if already completed - if contract.is_deliverable_complete(&target_phase, &deliverable_id) { - return ContractRequestResult { - success: true, - message: format!("Deliverable '{}' is already marked complete for {} phase", deliverable_id, target_phase), - data: Some(json!({ - "deliverableId": deliverable_id, - "phase": target_phase, - "alreadyComplete": true, - })), - }; - } - - // Mark the deliverable as complete - match repository::mark_deliverable_complete(pool, contract_id, &target_phase, &deliverable_id).await { - Ok(updated_contract) => { - let completed = updated_contract.get_completed_deliverables(&target_phase); - ContractRequestResult { - success: true, - message: format!("Marked deliverable '{}' as complete for {} phase", deliverable_id, target_phase), - data: Some(json!({ - "deliverableId": deliverable_id, - "phase": target_phase, - "completedDeliverables": completed, - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to mark deliverable complete: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateContractTask { - name, - plan, - repository_url, - base_branch, - } => { - // Get primary repository if not specified - let repo_url = if repository_url.is_some() { - repository_url - } else { - // Find primary repository - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => { - contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) - } - _ => None, - } - }; - - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: name.clone(), - description: None, - plan, - parent_task_id: None, - repository_url: repo_url, - base_branch, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => ContractRequestResult { - success: true, - message: format!("Created task '{}' in contract", name), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create task: {}", e), - data: None, - }, - } - } - - ContractToolRequest::DelegateContentGeneration { - file_id, - instruction, - context, - } => { - // Build a task plan that includes the content generation instruction - let mut plan = format!( - "Content Generation Task\n\n\ - ## Instruction\n{}\n\n", - instruction - ); - - if let Some(ctx) = context { - plan.push_str(&format!("## Context\n{}\n\n", ctx)); - } - - // If file_id is provided, get file details and include them - let (file_name, file_info) = if let Some(fid) = file_id { - match repository::get_file_for_owner(pool, fid, owner_id).await { - Ok(Some(file)) => { - let info = format!( - "## Target File\n\ - - File ID: {}\n\ - - Name: {}\n\ - - Description: {}\n\n\ - The generated content should be structured to update this file.\n", - fid, - file.name, - file.description.as_deref().unwrap_or("(no description)") - ); - (Some(file.name.clone()), Some(info)) - } - _ => (None, None), - } - } else { - (None, None) - }; - - if let Some(info) = file_info { - plan.push_str(&info); - } - - // Get primary repository - let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())), - _ => None, - }; - - let task_name = format!( - "Generate content{}", - file_name.map(|n| format!(": {}", n)).unwrap_or_default() - ); - - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: task_name.clone(), - description: Some(instruction.clone()), - plan, - parent_task_id: None, - repository_url: repo_url, - base_branch: None, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => ContractRequestResult { - success: true, - message: format!( - "Created content generation task '{}'. Start the task to generate the content.", - task_name - ), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - "targetFileId": file_id, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create content generation task: {}", e), - data: None, - }, - } - } - - ContractToolRequest::StartTask { task_id } => { - // Get the task - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to get task: {}", e), - data: None, - } - } - }; - - // Check if task can be started - let startable_statuses = ["pending", "failed", "interrupted", "done", "merged"]; - if !startable_statuses.contains(&task.status.as_str()) { - return ContractRequestResult { - success: false, - message: format!("Task cannot be started from status: {}", task.status), - data: None, - }; - } - - // Find a connected daemon for this owner - let daemon_entry = daemon_connections - .iter() - .find(|d| d.value().owner_id == owner_id); - - let (target_daemon_id, command_sender) = match daemon_entry { - Some(entry) => { - let daemon = entry.value(); - (daemon.id, daemon.command_sender.clone()) - } - None => { - return ContractRequestResult { - success: false, - message: "No daemon connected. Start a daemon to run tasks.".to_string(), - data: None, - }; - } - }; - - // Check if this is an orchestrator - let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await { - Ok(subtasks) => subtasks.len(), - Err(_) => 0, - }; - let is_orchestrator = task.depth == 0 && subtask_count > 0; - - // Update task status to 'starting' and assign daemon_id - let update_req = crate::db::models::UpdateTaskRequest { - status: Some("starting".to_string()), - daemon_id: Some(target_daemon_id), - version: Some(task.version), - ..Default::default() - }; - - let _updated_task = match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to update task: {}", e), - data: None, - }; - } - }; - - // Get local_only and auto_merge_local from contract if task has one - let (local_only, auto_merge_local) = if let Some(contract_id) = task.contract_id { - match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local), - _ => (false, false), - } - } else { - (false, false) - }; - - // Send SpawnTask command to daemon - let command = DaemonCommand::SpawnTask { - task_id, - task_name: task.name.clone(), - plan: task.plan.clone(), - repo_url: task.repository_url.clone(), - base_branch: task.base_branch.clone(), - target_branch: task.target_branch.clone(), - parent_task_id: task.parent_task_id, - depth: task.depth, - is_orchestrator, - target_repo_path: task.target_repo_path.clone(), - completion_action: task.completion_action.clone(), - continue_from_task_id: task.continue_from_task_id, - copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), - contract_id: task.contract_id, - is_supervisor: task.is_supervisor, - autonomous_loop: false, - resume_session: false, - conversation_history: None, - patch_data: None, - patch_base_sha: None, - local_only, - auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: task.directive_id, - }; - - if let Err(e) = command_sender.send(command).await { - // Rollback: reset status since command failed - let rollback_req = crate::db::models::UpdateTaskRequest { - status: Some("pending".to_string()), - clear_daemon_id: true, - ..Default::default() - }; - let _ = repository::update_task_for_owner(pool, task_id, owner_id, rollback_req).await; - return ContractRequestResult { - success: false, - message: format!("Failed to send task to daemon: {}", e), - data: None, - }; - } - - // Note: TaskUpdateNotification broadcast is handled by the mesh handler when daemon reports status - ContractRequestResult { - success: true, - message: format!("Started task '{}'. The task is now running on a connected daemon.", task.name), - data: Some(json!({ - "taskId": task_id, - "name": task.name, - "status": "starting", - })), - } - } - - ContractToolRequest::GetPhaseInfo => { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let phase_info = get_phase_description(&contract.phase); - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - let deliverable_names: Vec<String> = phase_deliverables.deliverables.iter().map(|d| d.name.clone()).collect(); - - ContractRequestResult { - success: true, - message: format!("Contract is in '{}' phase", contract.phase), - data: Some(json!({ - "phase": contract.phase, - "description": phase_info.0, - "activities": phase_info.1, - "deliverables": deliverable_names, - "guidance": phase_deliverables.guidance, - "nextPhase": get_next_phase(&contract.phase), - })), - } - } - - ContractToolRequest::SuggestPhaseTransition => { - let contract = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let analysis = analyze_phase_readiness(&contract); - - ContractRequestResult { - success: true, - message: analysis.summary.clone(), - data: Some(json!({ - "currentPhase": contract.contract.phase, - "nextPhase": get_next_phase(&contract.contract.phase), - "ready": analysis.ready, - "summary": analysis.summary, - "reasons": analysis.reasons, - "suggestions": analysis.suggestions, - })), - } - } - - ContractToolRequest::AdvancePhase { new_phase, confirmed, feedback } => { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Validate phase transition - let current_phase = &contract.phase; - let valid_next = get_next_phase(current_phase); - - if valid_next.as_deref() != Some(&new_phase) { - return ContractRequestResult { - success: false, - message: format!( - "Cannot transition from '{}' to '{}'. Next valid phase is: {:?}", - current_phase, new_phase, valid_next - ), - data: None, - }; - } - - // Check if deliverables are met before allowing transition - let cwr = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) | Err(_) => { - // Fall through - we'll just skip the deliverables check - return ContractRequestResult { - success: false, - message: "Failed to load contract for deliverables check".to_string(), - data: None, - }; - } - }; - - // Get completed deliverables for the current phase - let completed_deliverables = cwr.contract.get_completed_deliverables(current_phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - - let check_result = crate::llm::check_deliverables_met( - current_phase, - &contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Block transition if deliverables are not met - if !check_result.deliverables_met { - return ContractRequestResult { - success: false, - message: format!( - "Cannot advance to '{}' phase: deliverables not met. {}", - new_phase, check_result.summary - ), - data: Some(json!({ - "status": "deliverables_not_met", - "currentPhase": current_phase, - "requestedPhase": new_phase, - "deliverablesMet": false, - "requiredDeliverables": check_result.required_deliverables, - "missing": check_result.missing, - "action": "Complete the missing deliverables before advancing to the next phase" - })), - }; - } - - // Check if phase_guard is enabled - if contract.phase_guard { - // If user provided feedback, return it for the task to address - if let Some(ref user_feedback) = feedback { - return ContractRequestResult { - success: true, - message: format!( - "Phase transition to '{}' requires changes. User feedback: {}", - new_phase, user_feedback - ), - data: Some(json!({ - "status": "changes_requested", - "currentPhase": current_phase, - "requestedPhase": new_phase, - "feedback": user_feedback, - "action": "Address the user feedback and try again when ready" - })), - }; - } - - // If not confirmed, return requires_confirmation with phase deliverables - // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level - if !confirmed { - // Get files created in this phase - let phase_files = match repository::list_files_in_contract(pool, contract_id, owner_id).await { - Ok(files) => files - .into_iter() - .filter(|f| f.contract_phase.as_deref() == Some(current_phase)) - .map(|f| json!({ - "id": f.id, - "name": f.name, - "description": f.description - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get tasks completed in this contract - let phase_tasks = match repository::list_tasks_in_contract(pool, contract_id, owner_id).await { - Ok(tasks) => tasks - .into_iter() - .filter(|t| t.status == "done" || t.status == "completed") - .map(|t| json!({ - "id": t.id, - "name": t.name, - "status": t.status - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get phase deliverables with completion status - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(current_phase, &contract.contract_type); - let completed_deliverables = contract.get_completed_deliverables(current_phase); - - let deliverables: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| json!({ - "id": d.id, - "name": d.name, - "completed": completed_deliverables.contains(&d.id) - })) - .collect(); - - // Build deliverables summary - let deliverables_summary = format!( - "Phase '{}' deliverables: {} files created, {} tasks completed.", - current_phase, - phase_files.len(), - phase_tasks.len() - ); - - let transition_id = uuid::Uuid::new_v4().to_string(); - - return ContractRequestResult { - success: true, - message: format!( - "Phase transition to '{}' requires user confirmation. Review the deliverables and call advance_phase again with confirmed=true to proceed, or provide feedback to request changes.", - new_phase - ), - data: Some(json!({ - "status": "requires_confirmation", - "transitionId": transition_id, - "currentPhase": current_phase, - "nextPhase": new_phase, - "deliverablesSummary": deliverables_summary, - "deliverables": deliverables, - "phaseFiles": phase_files, - "phaseTasks": phase_tasks, - "requiresConfirmation": true, - "message": "Phase guard is enabled. User confirmation required.", - "instructions": "To proceed: call advance_phase with confirmed=true. To request changes: call advance_phase with feedback='your feedback here'" - })), - }; - } - } - - // Update phase (either phase_guard is disabled, or user confirmed) - match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await { - Ok(Some(updated)) => { - // Get deliverables for the new phase (using contract type) - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&new_phase, &contract.contract_type); - - // Build deliverables list - let deliverables_list: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| json!({ - "id": d.id, - "name": d.name, - "priority": format!("{:?}", d.priority).to_lowercase(), - "description": d.description, - })) - .collect(); - - ContractRequestResult { - success: true, - message: format!( - "Advanced contract from '{}' to '{}' phase. {}", - current_phase, new_phase, phase_deliverables.guidance - ), - data: Some(json!({ - "status": "advanced", - "previousPhase": current_phase, - "newPhase": updated.phase, - "phaseGuidance": phase_deliverables.guidance, - "deliverables": deliverables_list, - "requiresRepository": phase_deliverables.requires_repository, - "requiresTasks": phase_deliverables.requires_tasks, - })), - } - }, - Ok(None) => ContractRequestResult { - success: false, - message: "Failed to update phase".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to update phase: {}", e), - data: None, - }, - } - } - - ContractToolRequest::AddRepository { - repo_type, - name, - url, - is_primary, - } => { - let add_result = match repo_type.as_str() { - "remote" => { - let url = url.unwrap_or_default(); - repository::add_remote_repository( - pool, - contract_id, - &name, - &url, - is_primary, - ) - .await - } - "local" => { - let path = url.unwrap_or_default(); - repository::add_local_repository( - pool, - contract_id, - &name, - &path, - is_primary, - ) - .await - } - "managed" => { - repository::create_managed_repository(pool, contract_id, &name, is_primary) - .await - } - _ => { - return ContractRequestResult { - success: false, - message: format!("Invalid repository type: {}", repo_type), - data: None, - } - } - }; - - match add_result { - Ok(repo) => ContractRequestResult { - success: true, - message: format!("Added {} repository '{}'", repo_type, name), - data: Some(json!({ - "repositoryId": repo.id, - "name": repo.name, - "isPrimary": repo.is_primary, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to add repository: {}", e), - data: None, - }, - } - } - - ContractToolRequest::SetPrimaryRepository { repository_id } => { - match repository::set_repository_primary(pool, repository_id, contract_id).await { - Ok(true) => ContractRequestResult { - success: true, - message: "Set repository as primary".to_string(), - data: Some(json!({ - "repositoryId": repository_id, - })), - }, - Ok(false) => ContractRequestResult { - success: false, - message: "Repository not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to set primary repository: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Phase Guidance Handlers - // ============================================================================= - - ContractToolRequest::GetPhaseChecklist => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - let checklist = crate::llm::get_phase_checklist_for_type(&cwr.contract.phase, &completed_deliverables, &task_infos, has_repository, &cwr.contract.contract_type); - - ContractRequestResult { - success: true, - message: checklist.summary.clone(), - data: Some(json!({ - "phase": checklist.phase, - "completionPercentage": checklist.completion_percentage, - "deliverables": checklist.deliverables, - "hasRepository": checklist.has_repository, - "repositoryRequired": checklist.repository_required, - "taskStats": checklist.task_stats, - "suggestions": checklist.suggestions, - "summary": checklist.summary, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CheckDeliverablesMet => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - - let check_result = crate::llm::check_deliverables_met( - &cwr.contract.phase, - &cwr.contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Check if we should auto-progress - let auto_progress = crate::llm::should_auto_progress( - &cwr.contract.phase, - &cwr.contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - cwr.contract.autonomous_loop, - ); - - ContractRequestResult { - success: true, - message: check_result.summary.clone(), - data: Some(json!({ - "deliverablesMet": check_result.deliverables_met, - "readyToAdvance": check_result.ready_to_advance, - "phase": check_result.phase, - "nextPhase": check_result.next_phase, - "requiredDeliverables": check_result.required_deliverables, - "missing": check_result.missing, - "summary": check_result.summary, - "autoProgressRecommended": check_result.auto_progress_recommended, - "autoProgress": { - "shouldProgress": auto_progress.should_progress, - "nextPhase": auto_progress.next_phase, - "reason": auto_progress.reason, - "action": format!("{:?}", auto_progress.action), - }, - "autonomousLoop": cwr.contract.autonomous_loop, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Task Derivation Handlers - // ============================================================================= - - ContractToolRequest::DeriveTasksFromFile { file_id } => { - // First get the file - match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(file)) => { - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Convert body to markdown for task parsing - let markdown = body_to_markdown(&file.body); - - // Parse tasks from the content - let parse_result = parse_tasks_from_breakdown(&markdown); - - ContractRequestResult { - success: true, - message: format!("Found {} tasks in file '{}'", parse_result.total, file.name), - data: Some(json!({ - "fileId": file_id, - "fileName": file.name, - "tasks": parse_result.tasks, - "groups": parse_result.groups, - "total": parse_result.total, - "warnings": parse_result.warnings, - "formatted": format_parsed_tasks(&parse_result), - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateChainedTasks { tasks } => { - // Get primary repository for tasks - let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => { - contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) - } - _ => None, - }; - - let mut created_tasks = Vec::new(); - let mut previous_task_id: Option<Uuid> = None; - - for task_def in &tasks { - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: task_def.name.clone(), - description: None, - plan: task_def.plan.clone(), - parent_task_id: None, - repository_url: repo_url.clone(), - base_branch: None, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: previous_task_id, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => { - created_tasks.push(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - "chainedFrom": previous_task_id, - })); - previous_task_id = Some(task.id); - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create task '{}': {}", task_def.name, e), - data: Some(json!({ - "createdSoFar": created_tasks, - })), - }; - } - } - } - - ContractRequestResult { - success: true, - message: format!("Created {} chained tasks", created_tasks.len()), - data: Some(json!({ - "tasks": created_tasks, - "total": created_tasks.len(), - })), - } - } - - // ============================================================================= - // Task Completion Processing Handlers - // ============================================================================= - - ContractToolRequest::ProcessTaskCompletion { task_id } => { - // Get the task - match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(task)) => { - // Verify task belongs to this contract - if task.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "Task does not belong to this contract".to_string(), - data: None, - }; - } - - // Get contract for context - let contract = get_contract_with_relations(pool, contract_id, owner_id).await.ok().flatten(); - - let total_tasks = contract.as_ref().map(|c| c.tasks.len()).unwrap_or(0); - let completed_tasks = contract.as_ref() - .map(|c| c.tasks.iter().filter(|t| t.status == "done").count()) - .unwrap_or(0); - - // Note: Finding next chained task would require querying full Task objects - // Since TaskSummary doesn't have continue_from_task_id, we skip this for now - let next_task: Option<(Uuid, String)> = None; - - // Find Dev Notes file if exists - let dev_notes = if let Some(ref c) = contract { - c.files.iter() - .find(|f| f.name.to_lowercase().contains("dev") && f.name.to_lowercase().contains("notes")) - .map(|f| (f.id, f.name.clone())) - } else { - None - }; - - let contract_phase = contract.as_ref() - .map(|c| c.contract.phase.clone()) - .unwrap_or_else(|| "execute".to_string()); - - // Analyze the task output - let analysis = analyze_task_output( - task_id, - &task.name, - task.last_output.as_deref(), - task.progress_summary.as_deref(), - &contract_phase, - total_tasks, - completed_tasks, - next_task, - dev_notes, - ); - - ContractRequestResult { - success: true, - message: format!("Analyzed completion of task '{}'", task.name), - data: Some(json!({ - "taskId": task_id, - "taskName": task.name, - "taskStatus": task.status, - "summary": analysis.summary, - "filesAffected": analysis.files_affected, - "nextSteps": analysis.next_steps, - "phaseImpact": analysis.phase_impact, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::UpdateFileFromTask { file_id, task_id, section_title } => { - // Get the task - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - // Get the file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Build the section to add - let title = section_title.unwrap_or_else(|| format!("Task: {}", task.name)); - let result_text = task.last_output.as_deref().unwrap_or("Task completed"); - - // Create new body elements to append - let mut new_body = file.body.clone(); - new_body.push(crate::db::models::BodyElement::Heading { - level: 2, - text: title, - }); - new_body.push(crate::db::models::BodyElement::Paragraph { - text: format!("Status: {}", task.status), - }); - new_body.push(crate::db::models::BodyElement::Paragraph { - text: result_text.to_string(), - }); - - // Update the file using UpdateFileRequest - let update_req = UpdateFileRequest { - name: None, - description: None, - transcript: None, - summary: None, - body: Some(new_body), - version: None, // Don't require version for this update - repo_file_path: None, - }; - - match repository::update_file_for_owner(pool, file_id, owner_id, update_req).await { - Ok(Some(updated_file)) => { - ContractRequestResult { - success: true, - message: format!("Updated file '{}' with task summary", file.name), - data: Some(json!({ - "fileId": file_id, - "fileName": updated_file.name, - "taskId": task_id, - "taskName": task.name, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Failed to update file".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Transcript Analysis Handlers - // ============================================================================= - - ContractToolRequest::AnalyzeTranscript { file_id } => { - // Get the file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - if file.transcript.is_empty() { - return ContractRequestResult { - success: false, - message: "File has no transcript to analyze".to_string(), - data: None, - }; - } - - // Format and analyze - let transcript_text = format_transcript_for_analysis(&file.transcript); - let speaker_stats = calculate_speaker_stats(&file.transcript); - let prompt = build_analysis_prompt(&transcript_text); - - // Call Claude for analysis - let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create Claude client: {}", e), - data: None, - }; - } - }; - - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(prompt), - }]; - - match client.chat_with_tools(claude_messages, &[]).await { - Ok(result) => { - let response_content = result.content.unwrap_or_default(); - match parse_analysis_response(&response_content, speaker_stats) { - Ok(analysis) => { - ContractRequestResult { - success: true, - message: format!( - "Analysis complete: {} requirements, {} decisions, {} action items", - analysis.requirements.len(), - analysis.decisions.len(), - analysis.action_items.len() - ), - data: Some(json!({ - "analysis": analysis - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to parse analysis: {}", e), - data: None, - } - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Claude API error: {}", e), - data: None, - } - } - } - - ContractToolRequest::CreateContractFromTranscript { - file_id, name, description, include_requirements, include_decisions, include_action_items - } => { - // Get file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - if file.transcript.is_empty() { - return ContractRequestResult { - success: false, - message: "File has no transcript".to_string(), - data: None, - }; - } - - // Analyze transcript - let transcript_text = format_transcript_for_analysis(&file.transcript); - let speaker_stats = calculate_speaker_stats(&file.transcript); - let prompt = build_analysis_prompt(&transcript_text); - - let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create Claude client: {}", e), - data: None, - }; - } - }; - - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(prompt), - }]; - - let analysis = match client.chat_with_tools(claude_messages, &[]).await { - Ok(result) => { - let response_content = result.content.unwrap_or_default(); - match parse_analysis_response(&response_content, speaker_stats) { - Ok(a) => a, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to parse analysis: {}", e), - data: None, - }; - } - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Claude API error: {}", e), - data: None, - }; - } - }; - - // Create contract - let contract_name = name - .or(analysis.suggested_contract_name.clone()) - .unwrap_or_else(|| format!("Contract from {}", file.name)); - let contract_description = description.or(analysis.suggested_description.clone()); - - let contract_req = crate::db::models::CreateContractRequest { - name: contract_name.clone(), - description: contract_description, - contract_type: Some("specification".to_string()), - initial_phase: Some("research".to_string()), - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - template_id: None, - }; - - let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create contract: {}", e), - data: None, - }; - } - }; - - let mut files_created = 0; - let mut tasks_created = 0; - - // Create requirements file if requested and there are requirements - if include_requirements && !analysis.requirements.is_empty() { - let requirements_items: Vec<String> = analysis.requirements - .iter() - .map(|req| format!("[{}] {}", req.speaker, req.text)) - .collect(); - - let body: Vec<crate::db::models::BodyElement> = vec![ - crate::db::models::BodyElement::Heading { - level: 1, - text: "Requirements".to_string(), - }, - crate::db::models::BodyElement::Paragraph { - text: format!("Extracted {} requirements from transcript analysis.", analysis.requirements.len()), - }, - crate::db::models::BodyElement::Heading { - level: 2, - text: "Extracted Requirements".to_string(), - }, - crate::db::models::BodyElement::List { - ordered: false, - items: requirements_items, - }, - ]; - - let create_req = crate::db::models::CreateFileRequest { - contract_id: contract.id, - name: Some("Requirements".to_string()), - description: Some("Requirements extracted from transcript analysis".to_string()), - body, - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some("specify".to_string()), - }; - - if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { - files_created += 1; - } - } - - // Create decisions file if requested and there are decisions - if include_decisions && !analysis.decisions.is_empty() { - let decisions_items: Vec<String> = analysis.decisions - .iter() - .map(|dec| format!("[{}] {}", dec.speaker, dec.text)) - .collect(); - - let body: Vec<crate::db::models::BodyElement> = vec![ - crate::db::models::BodyElement::Heading { - level: 1, - text: "Decisions".to_string(), - }, - crate::db::models::BodyElement::Paragraph { - text: format!("Extracted {} decisions from transcript analysis.", analysis.decisions.len()), - }, - crate::db::models::BodyElement::Heading { - level: 2, - text: "Recorded Decisions".to_string(), - }, - crate::db::models::BodyElement::List { - ordered: false, - items: decisions_items, - }, - ]; - - let create_req = crate::db::models::CreateFileRequest { - contract_id: contract.id, - name: Some("Decisions".to_string()), - description: Some("Decisions extracted from transcript analysis".to_string()), - body, - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { - files_created += 1; - } - } - - // Create tasks from action items if requested - if include_action_items && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = CreateTaskRequest { - contract_id: Some(contract.id), - name: item.text.chars().take(100).collect(), - description: Some(format!("Action item from: {}", item.speaker)), - plan: item.text.clone(), - parent_task_id: None, - repository_url: None, - base_branch: None, - target_branch: None, - merge_mode: None, - priority: match item.priority.as_deref() { - Some("high") => 10, - Some("medium") => 5, - _ => 0, - }, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if repository::create_task_for_owner(pool, owner_id, task_req).await.is_ok() { - tasks_created += 1; - } - } - } - - ContractRequestResult { - success: true, - message: format!( - "Created contract '{}' with {} files and {} tasks from transcript analysis", - contract_name, files_created, tasks_created - ), - data: Some(json!({ - "contractId": contract.id, - "contractName": contract_name, - "filesCreated": files_created, - "tasksCreated": tasks_created, - "analysis": { - "requirementsCount": analysis.requirements.len(), - "decisionsCount": analysis.decisions.len(), - "actionItemsCount": analysis.action_items.len() - } - })), - } - } - - - } -} - -/// Get description and activities for a phase -fn get_phase_description(phase: &str) -> (String, Vec<String>) { - match phase { - "research" => ( - "Gather information, analyze competitors, and understand user needs".to_string(), - vec![ - "Conduct user research".to_string(), - "Analyze competitors".to_string(), - "Document findings".to_string(), - "Identify opportunities".to_string(), - ], - ), - "specify" => ( - "Define requirements, user stories, and acceptance criteria".to_string(), - vec![ - "Write requirements".to_string(), - "Create user stories".to_string(), - "Define acceptance criteria".to_string(), - "Document constraints".to_string(), - ], - ), - "plan" => ( - "Design architecture, create task breakdowns, and technical designs".to_string(), - vec![ - "Design system architecture".to_string(), - "Create technical specifications".to_string(), - "Break down into tasks".to_string(), - "Plan implementation order".to_string(), - ], - ), - "execute" => ( - "Implement features, write code, and run tasks".to_string(), - vec![ - "Implement features".to_string(), - "Write tests".to_string(), - "Track progress".to_string(), - "Document implementation details".to_string(), - ], - ), - "review" => ( - "Review work, create release notes, and conduct retrospectives".to_string(), - vec![ - "Review code and features".to_string(), - "Create release notes".to_string(), - "Conduct retrospective".to_string(), - "Document learnings".to_string(), - ], - ), - _ => ( - "Unknown phase".to_string(), - vec![], - ), - } -} - -/// Get the next phase in the lifecycle -fn get_next_phase(current: &str) -> Option<String> { - match current { - "research" => Some("specify".to_string()), - "specify" => Some("plan".to_string()), - "plan" => Some("execute".to_string()), - "execute" => Some("review".to_string()), - "review" => None, // Final phase - _ => None, - } -} - -/// Phase readiness analysis result -struct PhaseReadinessAnalysis { - ready: bool, - summary: String, - reasons: Vec<String>, - suggestions: Vec<String>, -} - -/// Analyze if the contract is ready to transition to the next phase -fn analyze_phase_readiness(contract: &crate::db::models::ContractWithRelations) -> PhaseReadinessAnalysis { - let mut reasons = Vec::new(); - let mut suggestions = Vec::new(); - - match contract.contract.phase.as_str() { - "research" => { - // Check for research files - let research_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("research")) - .count(); - - if research_files == 0 { - reasons.push("No research documents created yet".to_string()); - suggestions.push("Create research notes or competitor analysis documents".to_string()); - } else { - reasons.push(format!("{} research document(s) created", research_files)); - } - - let ready = research_files > 0; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Research phase has documentation. Consider transitioning to Specify phase.".to_string() - } else { - "Research phase needs more documentation before transitioning.".to_string() - }, - reasons, - suggestions, - } - } - "specify" => { - let spec_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("specify")) - .count(); - - if spec_files == 0 { - reasons.push("No specification documents created yet".to_string()); - suggestions.push("Create requirements or user stories documents".to_string()); - } else { - reasons.push(format!("{} specification document(s) created", spec_files)); - } - - let ready = spec_files > 0; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Specification phase has documentation. Consider transitioning to Plan phase.".to_string() - } else { - "Specification phase needs requirements or user stories.".to_string() - }, - reasons, - suggestions, - } - } - "plan" => { - let plan_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("plan")) - .count(); - - let has_repos = !contract.repositories.is_empty(); - - if plan_files == 0 { - reasons.push("No planning documents created yet".to_string()); - suggestions.push("Create architecture or task breakdown documents".to_string()); - } else { - reasons.push(format!("{} planning document(s) created", plan_files)); - } - - if !has_repos { - reasons.push("No repositories configured".to_string()); - suggestions.push("Add a repository for task execution".to_string()); - } else { - reasons.push(format!("{} repository(ies) configured", contract.repositories.len())); - } - - let ready = plan_files > 0 && has_repos; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Planning phase complete with documents and repositories. Ready for Execute phase.".to_string() - } else { - "Planning phase needs documentation and/or repository configuration.".to_string() - }, - reasons, - suggestions, - } - } - "execute" => { - let total_tasks = contract.tasks.len(); - let done_tasks = contract.tasks.iter().filter(|t| t.status == "done").count(); - let running_tasks = contract.tasks.iter().filter(|t| t.status == "running").count(); - - if total_tasks == 0 { - reasons.push("No tasks created yet".to_string()); - suggestions.push("Create tasks to implement the planned work".to_string()); - } else { - reasons.push(format!("{} of {} tasks completed", done_tasks, total_tasks)); - } - - if running_tasks > 0 { - reasons.push(format!("{} task(s) still running", running_tasks)); - suggestions.push("Wait for running tasks to complete".to_string()); - } - - let ready = total_tasks > 0 && done_tasks == total_tasks; - - // For simple contracts, execute is the terminal phase - suggest completion - if ready && contract.contract.contract_type == "simple" { - suggestions.push("Mark the contract as completed".to_string()); - } - - PhaseReadinessAnalysis { - ready, - summary: if ready { - if contract.contract.contract_type == "simple" { - "All tasks completed. Contract can be marked as completed.".to_string() - } else { - "All tasks completed. Ready for Review phase.".to_string() - } - } else if total_tasks == 0 { - "No tasks created yet. Create and complete tasks before reviewing.".to_string() - } else { - format!("{}/{} tasks complete. Finish remaining tasks before review.", done_tasks, total_tasks) - }, - reasons, - suggestions, - } - } - "review" => { - let review_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("review")) - .count(); - - if review_files == 0 { - suggestions.push("Create review checklist or release notes".to_string()); - } else { - // Review documentation exists - suggest completion - suggestions.push("Mark the contract as completed".to_string()); - } - - PhaseReadinessAnalysis { - ready: review_files > 0, - summary: if review_files > 0 { - "Review documentation complete. Contract can be marked as completed.".to_string() - } else { - "Review phase needs documentation before completion.".to_string() - }, - reasons: vec!["Review is the final phase".to_string()], - suggestions, - } - } - _ => PhaseReadinessAnalysis { - ready: false, - summary: "Unknown phase".to_string(), - reasons: vec!["Phase not recognized".to_string()], - suggestions: vec![], - }, - } -} - -// ============================================================================= -// Contract Chat History Endpoints -// ============================================================================= - -/// Get contract chat history -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/chat/history", - responses( - (status = 200, description = "Chat history retrieved successfully", body = ContractChatHistoryResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_contract_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - } - - // Get or create conversation - let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, auth.owner_id).await { - Ok(conv) => conv, - Err(e) => { - tracing::error!("Failed to get contract conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to get conversation: {}", e) })), - ) - .into_response(); - } - }; - - // Get messages - let messages = match repository::list_contract_chat_messages(pool, conversation.id, Some(100)).await { - Ok(msgs) => msgs, - Err(e) => { - tracing::error!("Failed to list contract chat messages: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to list messages: {}", e) })), - ) - .into_response(); - } - }; - - ( - StatusCode::OK, - Json(ContractChatHistoryResponse { - contract_id, - conversation_id: conversation.id, - messages, - }), - ) - .into_response() -} - -/// Clear contract chat history (creates a new conversation) -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/chat/history", - responses( - (status = 200, description = "Chat history cleared successfully"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn clear_contract_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - } - - // Clear conversation (archives existing and creates new) - match repository::clear_contract_conversation(pool, contract_id, auth.owner_id).await { - Ok(new_conversation) => { - ( - StatusCode::OK, - Json(json!({ - "message": "Chat history cleared", - "newConversationId": new_conversation.id - })), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to clear contract conversation: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to clear history: {}", e) })), - ) - .into_response() - } - } -} |
