diff options
Diffstat (limited to 'makima/src/server/handlers/contract_chat.rs')
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 2592 |
1 files changed, 2592 insertions, 0 deletions
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs new file mode 100644 index 0000000..d090999 --- /dev/null +++ b/makima/src/server/handlers/contract_chat.rs @@ -0,0 +1,2592 @@ +//! 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::{ + all_templates, analyze_task_output, body_to_markdown, format_checklist_markdown, + format_parsed_tasks, get_phase_checklist, parse_tasks_from_breakdown, + claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, + groq::{GroqClient, GroqError, Message, ToolCallResponse}, + parse_contract_tool_call, templates_for_phase, ContractToolRequest, FileInfo, + LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS, +}; +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: {}\n", + c.name, c.id, c.phase, c.status + ); + + if let Some(ref desc) = c.description { + context.push_str(&format!("Description: {}\n", desc)); + } + + // Build phase checklist + let file_infos: Vec<FileInfo> = contract.files.iter().map(|f| FileInfo { + id: f.id, + name: f.name.clone(), + contract_phase: f.contract_phase.clone(), + }).collect(); + + let task_infos: Vec<TaskInfo> = contract.tasks.iter().map(|t| TaskInfo { + id: t.id, + name: t.name.clone(), + status: t.status.clone(), + }).collect(); + + let has_repository = !contract.repositories.is_empty(); + let phase_checklist = get_phase_checklist(&c.phase, &file_infos, &task_infos, has_repository); + + // Add phase checklist to context + context.push_str("\n"); + context.push_str(&format_checklist_markdown(&phase_checklist)); + + // 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::CreateFileFromTemplate { + template_id, + name, + description, + } => { + // Find the template + let templates = all_templates(); + let template = templates.iter().find(|t| t.id == template_id); + + let Some(template) = template else { + return ContractRequestResult { + success: false, + message: format!("Template '{}' not found", template_id), + data: None, + }; + }; + + // 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, + } + } + }; + + // Use template's phase if available, otherwise use contract's current phase + let contract_phase = Some(template.phase.clone()).or(Some(contract.phase.clone())); + + // Create the file (contract_id is now required) + let create_req = crate::db::models::CreateFileRequest { + contract_id, + name: Some(name.clone()), + description, + body: template.suggested_body.clone(), + transcript: Vec::new(), + location: None, + repo_file_path: None, + contract_phase, + }; + + match repository::create_file_for_owner(pool, owner_id, create_req).await { + Ok(file) => ContractRequestResult { + success: true, + message: format!( + "Created file '{}' from template '{}'", + name, template.name + ), + data: Some(json!({ + "fileId": file.id, + "name": file.name, + "templateId": template_id, + })), + }, + Err(e) => ContractRequestResult { + success: false, + message: format!("Failed to create file: {}", 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::ListAvailableTemplates { phase } => { + let templates = if let Some(p) = phase { + templates_for_phase(&p) + } else { + all_templates() + }; + + let template_data: Vec<serde_json::Value> = templates + .iter() + .map(|t| { + json!({ + "id": t.id, + "name": t.name, + "phase": t.phase, + "description": t.description, + }) + }) + .collect(); + + ContractRequestResult { + success: true, + message: format!("Found {} templates", templates.len()), + data: Some(json!({ "templates": template_data })), + } + } + + 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, + 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, + }; + + 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, + 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, + }; + + 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, + }; + } + }; + + // 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, + }; + + 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 templates = templates_for_phase(&contract.phase); + let template_names: Vec<String> = templates.iter().map(|t| t.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, + "suggestedTemplates": template_names, + "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 } => { + 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, + }; + } + + // Update phase + match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await { + Ok(Some(updated)) => { + // Get deliverables for the new phase + let deliverables = crate::llm::get_phase_deliverables(&new_phase); + + // Build suggested files list + let suggested_files: Vec<serde_json::Value> = deliverables + .recommended_files + .iter() + .map(|f| json!({ + "templateId": f.template_id, + "name": f.name_suggestion, + "priority": format!("{:?}", f.priority).to_lowercase(), + "description": f.description, + })) + .collect(); + + ContractRequestResult { + success: true, + message: format!( + "Advanced contract from '{}' to '{}' phase. {}", + current_phase, new_phase, deliverables.guidance + ), + data: Some(json!({ + "previousPhase": current_phase, + "newPhase": updated.phase, + "phaseGuidance": deliverables.guidance, + "suggestedFiles": suggested_files, + "requiresRepository": deliverables.requires_repository, + "requiresTasks": 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 file_infos: Vec<FileInfo> = cwr.files.iter().map(|f| FileInfo { + id: f.id, + name: f.name.clone(), + contract_phase: f.contract_phase.clone(), + }).collect(); + + let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { + id: t.id, + name: t.name.clone(), + status: t.status.clone(), + }).collect(); + + let has_repository = !cwr.repositories.is_empty(); + let checklist = get_phase_checklist(&cwr.contract.phase, &file_infos, &task_infos, has_repository); + + ContractRequestResult { + success: true, + message: checklist.summary.clone(), + data: Some(json!({ + "phase": checklist.phase, + "completionPercentage": checklist.completion_percentage, + "deliverables": checklist.file_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, + }, + } + } + + // ============================================================================= + // 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, + 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, + }; + + 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, + }, + } + } + } +} + +/// 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; + PhaseReadinessAnalysis { + ready, + summary: if ready { + "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()); + } + + PhaseReadinessAnalysis { + ready: false, + summary: "Review is the final phase. Contract can be marked as complete when review is done.".to_string(), + reasons: vec!["Review phase 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() + } + } +} |
