//! 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, 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, /// Optional conversation history for context continuity #[serde(default)] pub history: Option>, } #[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, /// Questions pending user answers (pauses conversation) #[serde(skip_serializing_if = "Option::is_none")] pub pending_questions: Option>, } #[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, tool_calls: Vec, raw_tool_calls: Vec, 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, 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, Authenticated(auth): Authenticated, Path(contract_id): Path, Json(request): Json, ) -> 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 = contract.files.iter().map(|f| FileInfo { id: f.id, name: f.name.clone(), contract_phase: f.contract_phase.clone(), }).collect(); let task_infos: Vec = 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 = Vec::new(); let mut final_response: Option = None; let mut consecutive_failures = 0; const MAX_CONSECUTIVE_FAILURES: usize = 3; let mut pending_questions: Option> = 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 = 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, } /// Handle async contract tool requests that require database access async fn handle_contract_request( pool: &sqlx::PgPool, daemon_connections: &dashmap::DashMap, 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 = 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 = 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 = 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 = 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 = 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, 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 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 pending confirmation with phase deliverables 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::>(), 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::>(), Err(_) => Vec::new(), }; // 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": "pending_confirmation", "transitionId": transition_id, "currentPhase": current_phase, "nextPhase": new_phase, "deliverablesSummary": deliverables_summary, "phaseFiles": phase_files, "phaseTasks": phase_tasks, "requiresConfirmation": true, "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 let deliverables = crate::llm::get_phase_deliverables(&new_phase); // Build suggested files list let suggested_files: Vec = 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!({ "status": "advanced", "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 = cwr.files.iter().map(|f| FileInfo { id: f.id, name: f.name.clone(), contract_phase: f.contract_phase.clone(), }).collect(); let task_infos: Vec = 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 = 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, }, } } // ============================================================================= // 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, }; 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 = analysis.requirements .iter() .map(|req| format!("[{}] {}", req.speaker, req.text)) .collect(); let body: Vec = 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 = analysis.decisions .iter() .map(|dec| format!("[{}] {}", dec.speaker, dec.text)) .collect(); let body: Vec = 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: 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, }; 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) { 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 { 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, suggestions: Vec, } /// 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, Authenticated(auth): Authenticated, Path(contract_id): Path, ) -> 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, Authenticated(auth): Authenticated, Path(contract_id): Path, ) -> 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() } } }