diff options
Diffstat (limited to 'makima/src/server')
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 3183 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_daemon.rs | 936 | ||||
| -rw-r--r-- | makima/src/server/handlers/contract_discuss.rs | 592 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 2376 | ||||
| -rw-r--r-- | makima/src/server/handlers/mesh.rs | 34 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 8 | ||||
| -rw-r--r-- | makima/src/server/handlers/transcript_analysis.rs | 690 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 73 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 34 |
9 files changed, 44 insertions, 7882 deletions
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs deleted file mode 100644 index 5d8ab3e..0000000 --- a/makima/src/server/handlers/contract_chat.rs +++ /dev/null @@ -1,3183 +0,0 @@ -//! Chat endpoint for LLM-powered contract management. -//! -//! This handler provides an agentic loop for managing contracts: creating tasks, -//! adding files, managing repositories, and handling phase transitions. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{ - models::{ - ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest, - }, - repository, -}; -use crate::llm::{ - analyze_task_output, body_to_markdown, format_checklist_markdown, - format_parsed_tasks, parse_tasks_from_breakdown, - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - parse_contract_tool_call, ContractToolRequest, - LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS, - format_transcript_for_analysis, calculate_speaker_stats, - build_analysis_prompt, parse_analysis_response, -}; -use crate::server::auth::Authenticated; -use crate::server::state::{DaemonCommand, SharedState}; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 30; - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatHistoryMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatRequest { - /// The user's message/instruction - pub message: String, - /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq" - #[serde(default)] - pub model: Option<String>, - /// Optional conversation history for context continuity - #[serde(default)] - pub history: Option<Vec<ContractChatHistoryMessage>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractChatResponse { - /// The LLM's response message - pub response: String, - /// Tool calls that were executed - pub tool_calls: Vec<ContractToolCallInfo>, - /// Questions pending user answers (pauses conversation) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Enum to hold LLM clients -enum LlmClient { - Groq(GroqClient), - Claude(ClaudeClient), -} - -/// Unified result from LLM call -struct LlmResult { - content: Option<String>, - tool_calls: Vec<ToolCall>, - raw_tool_calls: Vec<ToolCallResponse>, - finish_reason: String, -} - -/// Helper to get contract with all relations -async fn get_contract_with_relations( - pool: &sqlx::PgPool, - contract_id: Uuid, - owner_id: Uuid, -) -> Result<Option<ContractWithRelations>, sqlx::Error> { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await? { - Some(c) => c, - None => return Ok(None), - }; - - let repositories = repository::list_contract_repositories(pool, contract_id) - .await - .unwrap_or_default(); - - let files = repository::list_files_in_contract(pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - let tasks = repository::list_tasks_in_contract(pool, contract_id, owner_id) - .await - .unwrap_or_default(); - - Ok(Some(ContractWithRelations { - contract, - repositories, - files, - tasks, - })) -} - -/// Chat with a contract using LLM tool calling for management -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/chat", - request_body = ContractChatRequest, - responses( - (status = 200, description = "Chat completed successfully", body = ContractChatResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn contract_chat_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, - Json(request): Json<ContractChatRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Get the contract (scoped by owner) - let contract = match get_contract_with_relations(pool, contract_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or(LlmModel::ClaudeSonnet); - - tracing::info!("Contract chat using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::GroqKimi => match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "GROQ_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Groq client error: {}", e) })), - ) - .into_response(); - } - }, - }; - - // Build contract context - let contract_context = build_contract_context(&contract); - - // Build system prompt for contract management - let system_prompt = format!( - r#"You are an intelligent contract management agent. You guide users through the contract lifecycle from research to completion, helping them organize work, create documentation, set up repositories, and execute tasks. - -## Your Capabilities -You have access to tools for: -- **Query**: get_contract_status, list_contract_files, list_contract_tasks, list_contract_repositories, read_file -- **File Management**: create_file_from_template, create_empty_file, list_available_templates -- **Task Management**: create_contract_task, delegate_content_generation, start_task -- **Phase Management**: get_phase_info, suggest_phase_transition, advance_phase -- **Repository Management**: list_daemon_directories, add_repository, set_primary_repository -- **Interactive**: ask_user - -## Content Generation Deferral -When asked to write substantial content, fill templates, or generate documentation: -- **Use delegate_content_generation** to create a task for the content generation -- This delegates the work to a task agent that can do more thorough research and writing - -**Use delegation for:** -- Filling in template content with real data -- Writing documentation based on requirements -- Generating user stories or specifications -- Creating detailed design documents -- Any substantial writing that requires research or analysis - -**Direct actions (no delegation needed):** -- Listing files/tasks/repos -- Reading files -- Phase transitions -- Creating empty files or templates -- Simple queries and status checks -- Asking user questions - -## Contract Lifecycle Phases - -### 1. RESEARCH Phase -**Purpose**: Gather information and understand the problem space -**Key Activities**: -- Conduct user research and interviews -- Analyze competitors and existing solutions -- Document findings and insights -- Identify opportunities and constraints -**Suggested Actions**: -- Create a "Research Notes" document to capture findings -- Create a "Competitor Analysis" document -- When research is complete, suggest transitioning to Specify phase - -### 2. SPECIFY Phase -**Purpose**: Define what needs to be built -**Key Activities**: -- Write clear requirements -- Create user stories with acceptance criteria -- Define scope and constraints -- Document technical constraints -**Suggested Actions**: -- Create a "Requirements" document -- Create "User Stories" with acceptance criteria -- When specifications are clear, suggest transitioning to Plan phase - -### 3. PLAN Phase -**Purpose**: Design the solution and break down the work -**Key Activities**: -- Design system architecture -- Create technical specifications -- Break work into implementable tasks -- Set up repositories for development -**Suggested Actions**: -- Create an "Architecture" document -- Create a "Task Breakdown" document -- **IMPORTANT**: Help set up a repository if not already configured -- When planning is complete and a repository is set, suggest transitioning to Execute phase - -### 4. EXECUTE Phase -**Purpose**: Implement the solution -**Key Activities**: -- Create and run tasks to implement features -- Write and run tests -- Track progress -- Document implementation decisions -**Suggested Actions**: -- Create tasks based on the task breakdown -- Monitor task progress and help resolve blockers -- When all tasks are complete, suggest transitioning to Review phase - -### 5. REVIEW Phase -**Purpose**: Validate and document the completed work -**Key Activities**: -- Review completed work -- Create release notes -- Conduct retrospective -- Document learnings -**Suggested Actions**: -- Create a "Release Notes" document -- Create a "Retrospective" document -- Help mark the contract as complete when review is done - -## Current Contract -{contract_context} - -## Proactive Guidance - -### Repository Setup (Critical for Plan/Execute phases) -When the user wants to add a local repository or set up for execution: -1. **First call list_daemon_directories** to get available paths from connected agents -2. Present the suggested directories to the user -3. Ask which path they want to use, or let them specify a custom path -4. Then call add_repository with the chosen path - -Example flow: -``` -User: "Set up a repository for this contract" -You: Call list_daemon_directories first -You: "I found these directories from your connected agent: - - /Users/alice/projects (Working Directory) - - /Users/alice/.makima/home (Makima Home) - Which would you like to use, or provide a custom path?" -``` - -### Phase Transitions -- Phases progress in order: research -> specify -> plan -> execute -> review -- You can ONLY advance forward one step at a time to the NEXT phase -- ALWAYS use suggest_phase_transition FIRST to get the exact nextPhase value -- Then use advance_phase with that exact nextPhase value -- Example: If currentPhase is "specify", nextPhase will be "plan" - use advance_phase with new_phase="plan" -- NEVER suggest advancing to the same phase the contract is already in - -### New Users -When a new contract is created or the user seems unsure: -1. Explain the current phase and what should be done -2. Suggest creating appropriate documents -3. Guide them toward the next milestone - -## Agentic Behavior Guidelines - -### 1. Understand Before Acting -- For complex requests, first gather information about the contract's current state -- Use get_contract_status or list_contract_files to understand what exists -- Consider the current phase when suggesting actions - -### 2. Phase-Appropriate Suggestions -- Suggest templates and actions appropriate for the current phase -- When creating files, prefer templates that match the contract's phase -- Advise when the contract might be ready for the next phase - -### 3. Help Plan Work -- When asked to plan work, read existing files to understand context -- Suggest creating tasks based on requirements or plans in files -- Offer to create task breakdowns from design documents - -### 4. Repository Management -- When adding local repositories, ALWAYS use list_daemon_directories first to get suggestions -- This provides the user with valid paths from their connected agents -- Don't ask users to manually type paths when suggestions are available - -### 5. Task Creation and Execution -- When creating tasks, derive plans from existing contract files when possible -- Use the contract's primary repository for tasks by default -- Create clear, actionable task plans -- After creating a task, you can use **start_task** to immediately begin execution -- A daemon must be connected for start_task to work - -### 6. Be Proactive but Efficient -- Guide users through the contract flow -- Don't over-analyze simple requests -- Use the minimum number of tool calls needed -- Provide clear summaries of actions taken - -## Important Notes -- This contract's ID is: {contract_id} -- All operations are scoped to this contract -- When creating tasks or files, they are automatically associated with this contract"#, - contract_context = contract_context, - contract_id = contract_id - ); - - // Run the agentic loop - run_contract_agentic_loop( - pool, - &state, - &llm_client, - system_prompt, - &request, - contract_id, - auth.owner_id, - ) - .await -} - -fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -> String { - let c = &contract.contract; - let mut context = format!( - "Name: {}\nID: {}\nPhase: {}\nStatus: {}\nContract Type: {}\nAutonomous Loop: {}\n", - c.name, c.id, c.phase, c.status, c.contract_type, c.autonomous_loop - ); - - if let Some(ref desc) = c.description { - context.push_str(&format!("Description: {}\n", desc)); - } - - // Get completed deliverables for the current phase - let completed_deliverables = c.get_completed_deliverables(&c.phase); - - // Build task infos for checklist - let task_infos: Vec<TaskInfo> = contract.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !contract.repositories.is_empty(); - let phase_checklist = crate::llm::get_phase_checklist_for_type(&c.phase, &completed_deliverables, &task_infos, has_repository, &c.contract_type); - - // Add phase checklist to context - context.push_str("\n"); - context.push_str(&format_checklist_markdown(&phase_checklist)); - - // Add deliverable check result for phase transition readiness - let deliverable_check = crate::llm::check_deliverables_met( - &c.phase, - &c.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Add deliverable prompt guidance - context.push_str(&crate::llm::generate_deliverable_prompt_guidance( - &c.phase, - &c.contract_type, - &deliverable_check, - )); - - // Files summary - context.push_str(&format!("\n### Files ({} total)\n", contract.files.len())); - if !contract.files.is_empty() { - for file in contract.files.iter().take(5) { - let phase_label = file.contract_phase.as_deref().unwrap_or("none"); - context.push_str(&format!("- {} [{}] (ID: {})\n", file.name, phase_label, file.id)); - } - if contract.files.len() > 5 { - context.push_str(&format!("... and {} more\n", contract.files.len() - 5)); - } - } - - // Tasks summary - context.push_str(&format!("\n### Tasks ({} total)\n", contract.tasks.len())); - if !contract.tasks.is_empty() { - let pending = contract.tasks.iter().filter(|t| t.status == "pending").count(); - let running = contract.tasks.iter().filter(|t| t.status == "running").count(); - let done = contract.tasks.iter().filter(|t| t.status == "done").count(); - context.push_str(&format!("{} pending, {} running, {} done\n", pending, running, done)); - for task in contract.tasks.iter().take(5) { - context.push_str(&format!("- {} ({}) - ID: {}\n", task.name, task.status, task.id)); - } - if contract.tasks.len() > 5 { - context.push_str(&format!("... and {} more\n", contract.tasks.len() - 5)); - } - } - - // Repositories summary - context.push_str(&format!("\n### Repositories ({} total)\n", contract.repositories.len())); - if !contract.repositories.is_empty() { - for repo in &contract.repositories { - let primary = if repo.is_primary { " (primary)" } else { "" }; - let url_or_path = repo.repository_url.as_deref() - .or(repo.local_path.as_deref()) - .unwrap_or("managed"); - context.push_str(&format!("- {}: {}{}\n", repo.name, url_or_path, primary)); - } - } - - context -} - -/// Summarize older conversation history to reduce token usage -async fn summarize_conversation_history( - llm_client: &LlmClient, - messages: &[&crate::db::models::ContractChatMessageRecord], -) -> String { - // Build conversation text for summarization - let mut conversation_text = String::new(); - for msg in messages { - let role_label = if msg.role == "user" { "User" } else { "Assistant" }; - // Limit each message to avoid overwhelming the summarizer - let content = if msg.content.len() > 500 { - format!("{}...", &msg.content[..500]) - } else { - msg.content.clone() - }; - conversation_text.push_str(&format!("{}: {}\n", role_label, content)); - } - - // Limit total text to summarize - if conversation_text.len() > 8000 { - conversation_text = format!("{}...", &conversation_text[..8000]); - } - - let summary_prompt = format!( - "Summarize this conversation history in 2-3 sentences, focusing on key decisions, actions taken, and current state:\n\n{}", - conversation_text - ); - - // Use a simple chat call without tools for summarization - let summary = match llm_client { - LlmClient::Claude(client) => { - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(summary_prompt.clone()), - }]; - match client.chat_with_tools(claude_messages, &[]).await { - Ok(response) => response.content.unwrap_or_default(), - Err(e) => { - tracing::warn!("Failed to summarize conversation: {}", e); - "Previous conversation covered contract management tasks.".to_string() - } - } - } - LlmClient::Groq(client) => { - let groq_messages = vec![Message { - role: "user".to_string(), - content: Some(summary_prompt.clone()), - tool_calls: None, - tool_call_id: None, - }]; - match client.chat_with_tools(groq_messages, &[]).await { - Ok(response) => response.content.unwrap_or_default(), - Err(e) => { - tracing::warn!("Failed to summarize conversation: {}", e); - "Previous conversation covered contract management tasks.".to_string() - } - } - } - }; - - // Limit summary length - if summary.len() > 500 { - format!("{}...", &summary[..500]) - } else { - summary - } -} - -/// Run the agentic loop for contract chat -async fn run_contract_agentic_loop( - pool: &sqlx::PgPool, - state: &SharedState, - llm_client: &LlmClient, - system_prompt: String, - request: &ContractChatRequest, - contract_id: Uuid, - owner_id: Uuid, -) -> axum::response::Response { - // Get or create the conversation for persistent history - let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, owner_id).await { - Ok(conv) => conv, - Err(e) => { - tracing::error!("Failed to get/create contract conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })), - ) - .into_response(); - } - }; - - // Load ALL existing messages from database - let saved_messages = match repository::list_contract_chat_messages(pool, conversation.id, None).await { - Ok(msgs) => msgs, - Err(e) => { - tracing::warn!("Failed to load contract chat history: {}", e); - Vec::new() - } - }; - - // Build initial messages - let mut messages = vec![Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }]; - - // Add saved conversation history, summarizing older messages if needed - // to stay under rate limits (~25k chars ≈ ~6k tokens for history) - const MAX_HISTORY_CHARS: usize = 25000; - const RECENT_MESSAGES_TO_KEEP: usize = 6; // Keep last 3 turns intact - - // Filter to user/assistant messages only - let history_messages: Vec<_> = saved_messages - .iter() - .filter(|m| m.role == "user" || m.role == "assistant") - .collect(); - - // Calculate total character count - let total_chars: usize = history_messages.iter().map(|m| m.content.len()).sum(); - - if total_chars > MAX_HISTORY_CHARS && history_messages.len() > RECENT_MESSAGES_TO_KEEP { - // Need to summarize older messages - let split_point = history_messages.len().saturating_sub(RECENT_MESSAGES_TO_KEEP); - let older_messages = &history_messages[..split_point]; - let recent_messages = &history_messages[split_point..]; - - // Generate summary of older conversation - let summary = summarize_conversation_history(&llm_client, older_messages).await; - - // Add summary as context - messages.push(Message { - role: "user".to_string(), - content: Some(format!("[Previous conversation summary: {}]", summary)), - tool_calls: None, - tool_call_id: None, - }); - messages.push(Message { - role: "assistant".to_string(), - content: Some("I understand the previous context. Let's continue.".to_string()), - tool_calls: None, - tool_call_id: None, - }); - - // Add recent messages in full - for saved_msg in recent_messages { - messages.push(Message { - role: saved_msg.role.clone(), - content: Some(saved_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - - tracing::info!( - total_messages = history_messages.len(), - summarized = older_messages.len(), - kept_recent = recent_messages.len(), - "Summarized older conversation history" - ); - } else { - // Add all messages directly - for saved_msg in history_messages { - messages.push(Message { - role: saved_msg.role.clone(), - content: Some(saved_msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - } - - // Add current user message - messages.push(Message { - role: "user".to_string(), - content: Some(request.message.clone()), - tool_calls: None, - tool_call_id: None, - }); - - // Save the user message to database - if let Err(e) = repository::add_contract_chat_message( - pool, - conversation.id, - "user", - &request.message, - None, - None, - ).await { - tracing::warn!("Failed to save user message to contract chat history: {}", e); - } - - // State for tracking - let mut all_tool_call_infos: Vec<ContractToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - let mut consecutive_failures = 0; - const MAX_CONSECUTIVE_FAILURES: usize = 3; - let mut pending_questions: Option<Vec<UserQuestion>> = None; - - // Multi-turn agentic tool calling loop - for round in 0..MAX_TOOL_ROUNDS { - tracing::info!( - round = round, - total_tool_calls = all_tool_call_infos.len(), - "Contract agentic loop iteration" - ); - - // Check consecutive failures - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - tracing::warn!( - "Breaking contract loop due to {} consecutive failures", - consecutive_failures - ); - final_response = Some( - "I encountered multiple consecutive errors and stopped. \ - Please check the contract state and try again." - .to_string(), - ); - break; - } - - // Call the appropriate LLM API - let result = match llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &CONTRACT_TOOLS).await { - Ok(r) => LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls: r.raw_tool_calls, - finish_reason: r.finish_reason, - }, - Err(e) => { - tracing::error!("Groq API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - LlmClient::Claude(claude_client) => { - let claude_messages = claude::groq_messages_to_claude(&messages); - match claude_client - .chat_with_tools(claude_messages, &CONTRACT_TOOLS) - .await - { - Ok(r) => { - let raw_tool_calls: Vec<ToolCallResponse> = r - .tool_calls - .iter() - .map(|tc| ToolCallResponse { - id: tc.id.clone(), - call_type: "function".to_string(), - function: crate::llm::groq::FunctionCall { - name: tc.name.clone(), - arguments: tc.arguments.to_string(), - }, - }) - .collect(); - - LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls, - finish_reason: r.stop_reason, - } - } - Err(e) => { - tracing::error!("Claude API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - }; - - // Check if there are tool calls to execute - if result.tool_calls.is_empty() { - final_response = result.content; - break; - } - - // Add assistant message with tool calls to conversation - messages.push(Message { - role: "assistant".to_string(), - content: result.content.clone(), - tool_calls: Some(result.raw_tool_calls.clone()), - tool_call_id: None, - }); - - // Execute each tool call - for (i, tool_call) in result.tool_calls.iter().enumerate() { - tracing::info!(tool = %tool_call.name, round = round, "Executing contract tool call"); - - // Parse the tool call - let mut execution_result = parse_contract_tool_call(tool_call); - - // Handle async contract tool requests - if let Some(contract_request) = execution_result.request.take() { - let async_result = - handle_contract_request(pool, &state.daemon_connections, contract_request, contract_id, owner_id).await; - execution_result.success = async_result.success; - execution_result.message = async_result.message; - execution_result.data = async_result.data; - } - - // Track consecutive failures - if execution_result.success { - consecutive_failures = 0; - } else { - consecutive_failures += 1; - tracing::warn!( - tool = %tool_call.name, - consecutive_failures = consecutive_failures, - "Contract tool call failed" - ); - } - - // Check for pending user questions - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "Contract LLM requesting user input" - ); - pending_questions = Some(questions); - all_tool_call_infos.push(ContractToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message.clone(), - }, - }); - break; - } - - // Build tool result message - let result_content = if let Some(data) = &execution_result.data { - json!({ - "success": execution_result.success, - "message": execution_result.message, - "data": data - }) - .to_string() - } else { - json!({ - "success": execution_result.success, - "message": execution_result.message - }) - .to_string() - }; - - // Add tool result message - let tool_call_id = match llm_client { - LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), - LlmClient::Claude(_) => tool_call.id.clone(), - }; - - messages.push(Message { - role: "tool".to_string(), - content: Some(result_content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - }); - - // Track for response - all_tool_call_infos.push(ContractToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message, - }, - }); - } - - // If user questions are pending, pause - if pending_questions.is_some() { - final_response = result.content; - break; - } - - // If finish reason indicates completion, exit loop - let finish_lower = result.finish_reason.to_lowercase(); - if finish_lower == "stop" || finish_lower == "end_turn" { - final_response = result.content; - break; - } - } - - // Build response - let response_text = final_response.unwrap_or_else(|| { - if all_tool_call_infos.is_empty() { - "I couldn't understand your request. Please try rephrasing.".to_string() - } else { - format!( - "Done! Executed {} tool{}.", - all_tool_call_infos.len(), - if all_tool_call_infos.len() == 1 { "" } else { "s" } - ) - } - }); - - // Save assistant response to database - let tool_calls_json = if all_tool_call_infos.is_empty() { - None - } else { - serde_json::to_value(&all_tool_call_infos).ok() - }; - - let pending_questions_json = pending_questions.as_ref().and_then(|q| serde_json::to_value(q).ok()); - - if let Err(e) = repository::add_contract_chat_message( - pool, - conversation.id, - "assistant", - &response_text, - tool_calls_json, - pending_questions_json, - ).await { - tracing::warn!("Failed to save assistant response to contract chat history: {}", e); - } - - ( - StatusCode::OK, - Json(ContractChatResponse { - response: response_text, - tool_calls: all_tool_call_infos, - pending_questions, - }), - ) - .into_response() -} - -/// Result from handling an async contract tool request -struct ContractRequestResult { - success: bool, - message: String, - data: Option<serde_json::Value>, -} - -/// Handle async contract tool requests that require database access -async fn handle_contract_request( - pool: &sqlx::PgPool, - daemon_connections: &dashmap::DashMap<String, crate::server::state::DaemonConnectionInfo>, - request: ContractToolRequest, - contract_id: Uuid, - owner_id: Uuid, -) -> ContractRequestResult { - match request { - ContractToolRequest::ListDaemonDirectories => { - let mut directories = Vec::new(); - - // Iterate over connected daemons belonging to this owner - for entry in daemon_connections.iter() { - let daemon = entry.value(); - - // Only include daemons belonging to this owner - if daemon.owner_id != owner_id { - continue; - } - - // Add working directory if available - if let Some(ref working_dir) = daemon.working_directory { - directories.push(json!({ - "path": working_dir, - "label": "Working Directory", - "type": "working", - "hostname": daemon.hostname, - })); - } - - // Add home directory if available - if let Some(ref home_dir) = daemon.home_directory { - directories.push(json!({ - "path": home_dir, - "label": "Makima Home", - "type": "home", - "hostname": daemon.hostname, - })); - } - } - - if directories.is_empty() { - ContractRequestResult { - success: true, - message: "No daemon directories available. Connect a daemon to get directory suggestions.".to_string(), - data: Some(json!({ "directories": [] })), - } - } else { - ContractRequestResult { - success: true, - message: format!("Found {} suggested directories from connected daemons", directories.len()), - data: Some(json!({ "directories": directories })), - } - } - } - - ContractToolRequest::GetContractStatus => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let c = &cwr.contract; - ContractRequestResult { - success: true, - message: format!( - "Contract '{}' is in '{}' phase with status '{}'", - c.name, c.phase, c.status - ), - data: Some(json!({ - "name": c.name, - "phase": c.phase, - "status": c.status, - "description": c.description, - "fileCount": cwr.files.len(), - "taskCount": cwr.tasks.len(), - "repositoryCount": cwr.repositories.len(), - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractFiles => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let files: Vec<serde_json::Value> = cwr - .files - .iter() - .map(|f| { - json!({ - "fileId": f.id, - "name": f.name, - "description": f.description, - "phase": f.contract_phase, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} files", files.len()), - data: Some(json!({ "files": files })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractTasks => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let tasks: Vec<serde_json::Value> = cwr - .tasks - .iter() - .map(|t| { - json!({ - "taskId": t.id, - "name": t.name, - "status": t.status, - "priority": t.priority, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} tasks", tasks.len()), - data: Some(json!({ "tasks": tasks })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ListContractRepositories => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let repos: Vec<serde_json::Value> = cwr - .repositories - .iter() - .map(|r| { - json!({ - "repositoryId": r.id, - "name": r.name, - "repositoryUrl": r.repository_url, - "localPath": r.local_path, - "isPrimary": r.is_primary, - }) - }) - .collect(); - - ContractRequestResult { - success: true, - message: format!("Found {} repositories", repos.len()), - data: Some(json!({ "repositories": repos })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ReadFile { file_id } => { - match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(file)) => { - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Convert body to markdown for LLM consumption - let markdown = body_to_markdown(&file.body); - - ContractRequestResult { - success: true, - message: format!("Read file '{}'", file.name), - data: Some(json!({ - "fileId": file.id, - "name": file.name, - "description": file.description, - "summary": file.summary, - "plainText": markdown, - "phase": file.contract_phase, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateEmptyFile { name, description } => { - // Verify contract exists and get current phase - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Create the file with current contract phase - let create_req = crate::db::models::CreateFileRequest { - contract_id, - name: Some(name.clone()), - description, - body: Vec::new(), - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some(contract.phase.clone()), - }; - - match repository::create_file_for_owner(pool, owner_id, create_req).await { - Ok(file) => ContractRequestResult { - success: true, - message: format!("Created empty file '{}'", name), - data: Some(json!({ - "fileId": file.id, - "name": file.name, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create file: {}", e), - data: None, - }, - } - } - - ContractToolRequest::MarkDeliverableComplete { - deliverable_id, - phase, - } => { - // Get the contract to determine current phase and contract type - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Use specified phase or default to current contract phase - let target_phase = phase.unwrap_or_else(|| contract.phase.clone()); - - // Validate the deliverable ID exists for this phase/contract type - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&target_phase, &contract.contract_type); - let deliverable_exists = phase_deliverables.deliverables.iter().any(|d| d.id == deliverable_id); - - if !deliverable_exists { - let valid_ids: Vec<&str> = phase_deliverables.deliverables.iter().map(|d| d.id.as_str()).collect(); - return ContractRequestResult { - success: false, - message: format!( - "Invalid deliverable_id '{}' for {} phase. Valid IDs: {:?}", - deliverable_id, target_phase, valid_ids - ), - data: None, - }; - } - - // Check if already completed - if contract.is_deliverable_complete(&target_phase, &deliverable_id) { - return ContractRequestResult { - success: true, - message: format!("Deliverable '{}' is already marked complete for {} phase", deliverable_id, target_phase), - data: Some(json!({ - "deliverableId": deliverable_id, - "phase": target_phase, - "alreadyComplete": true, - })), - }; - } - - // Mark the deliverable as complete - match repository::mark_deliverable_complete(pool, contract_id, &target_phase, &deliverable_id).await { - Ok(updated_contract) => { - let completed = updated_contract.get_completed_deliverables(&target_phase); - ContractRequestResult { - success: true, - message: format!("Marked deliverable '{}' as complete for {} phase", deliverable_id, target_phase), - data: Some(json!({ - "deliverableId": deliverable_id, - "phase": target_phase, - "completedDeliverables": completed, - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to mark deliverable complete: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateContractTask { - name, - plan, - repository_url, - base_branch, - } => { - // Get primary repository if not specified - let repo_url = if repository_url.is_some() { - repository_url - } else { - // Find primary repository - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => { - contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) - } - _ => None, - } - }; - - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: name.clone(), - description: None, - plan, - parent_task_id: None, - repository_url: repo_url, - base_branch, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => ContractRequestResult { - success: true, - message: format!("Created task '{}' in contract", name), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create task: {}", e), - data: None, - }, - } - } - - ContractToolRequest::DelegateContentGeneration { - file_id, - instruction, - context, - } => { - // Build a task plan that includes the content generation instruction - let mut plan = format!( - "Content Generation Task\n\n\ - ## Instruction\n{}\n\n", - instruction - ); - - if let Some(ctx) = context { - plan.push_str(&format!("## Context\n{}\n\n", ctx)); - } - - // If file_id is provided, get file details and include them - let (file_name, file_info) = if let Some(fid) = file_id { - match repository::get_file_for_owner(pool, fid, owner_id).await { - Ok(Some(file)) => { - let info = format!( - "## Target File\n\ - - File ID: {}\n\ - - Name: {}\n\ - - Description: {}\n\n\ - The generated content should be structured to update this file.\n", - fid, - file.name, - file.description.as_deref().unwrap_or("(no description)") - ); - (Some(file.name.clone()), Some(info)) - } - _ => (None, None), - } - } else { - (None, None) - }; - - if let Some(info) = file_info { - plan.push_str(&info); - } - - // Get primary repository - let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())), - _ => None, - }; - - let task_name = format!( - "Generate content{}", - file_name.map(|n| format!(": {}", n)).unwrap_or_default() - ); - - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: task_name.clone(), - description: Some(instruction.clone()), - plan, - parent_task_id: None, - repository_url: repo_url, - base_branch: None, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => ContractRequestResult { - success: true, - message: format!( - "Created content generation task '{}'. Start the task to generate the content.", - task_name - ), - data: Some(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - "targetFileId": file_id, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to create content generation task: {}", e), - data: None, - }, - } - } - - ContractToolRequest::StartTask { task_id } => { - // Get the task - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to get task: {}", e), - data: None, - } - } - }; - - // Check if task can be started - let startable_statuses = ["pending", "failed", "interrupted", "done", "merged"]; - if !startable_statuses.contains(&task.status.as_str()) { - return ContractRequestResult { - success: false, - message: format!("Task cannot be started from status: {}", task.status), - data: None, - }; - } - - // Find a connected daemon for this owner - let daemon_entry = daemon_connections - .iter() - .find(|d| d.value().owner_id == owner_id); - - let (target_daemon_id, command_sender) = match daemon_entry { - Some(entry) => { - let daemon = entry.value(); - (daemon.id, daemon.command_sender.clone()) - } - None => { - return ContractRequestResult { - success: false, - message: "No daemon connected. Start a daemon to run tasks.".to_string(), - data: None, - }; - } - }; - - // Check if this is an orchestrator - let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await { - Ok(subtasks) => subtasks.len(), - Err(_) => 0, - }; - let is_orchestrator = task.depth == 0 && subtask_count > 0; - - // Update task status to 'starting' and assign daemon_id - let update_req = crate::db::models::UpdateTaskRequest { - status: Some("starting".to_string()), - daemon_id: Some(target_daemon_id), - version: Some(task.version), - ..Default::default() - }; - - let _updated_task = match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to update task: {}", e), - data: None, - }; - } - }; - - // Get local_only and auto_merge_local from contract if task has one - let (local_only, auto_merge_local) = if let Some(contract_id) = task.contract_id { - match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local), - _ => (false, false), - } - } else { - (false, false) - }; - - // Send SpawnTask command to daemon - let command = DaemonCommand::SpawnTask { - task_id, - task_name: task.name.clone(), - plan: task.plan.clone(), - repo_url: task.repository_url.clone(), - base_branch: task.base_branch.clone(), - target_branch: task.target_branch.clone(), - parent_task_id: task.parent_task_id, - depth: task.depth, - is_orchestrator, - target_repo_path: task.target_repo_path.clone(), - completion_action: task.completion_action.clone(), - continue_from_task_id: task.continue_from_task_id, - copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()), - contract_id: task.contract_id, - is_supervisor: task.is_supervisor, - autonomous_loop: false, - resume_session: false, - conversation_history: None, - patch_data: None, - patch_base_sha: None, - local_only, - auto_merge_local, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: task.directive_id, - }; - - if let Err(e) = command_sender.send(command).await { - // Rollback: reset status since command failed - let rollback_req = crate::db::models::UpdateTaskRequest { - status: Some("pending".to_string()), - clear_daemon_id: true, - ..Default::default() - }; - let _ = repository::update_task_for_owner(pool, task_id, owner_id, rollback_req).await; - return ContractRequestResult { - success: false, - message: format!("Failed to send task to daemon: {}", e), - data: None, - }; - } - - // Note: TaskUpdateNotification broadcast is handled by the mesh handler when daemon reports status - ContractRequestResult { - success: true, - message: format!("Started task '{}'. The task is now running on a connected daemon.", task.name), - data: Some(json!({ - "taskId": task_id, - "name": task.name, - "status": "starting", - })), - } - } - - ContractToolRequest::GetPhaseInfo => { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let phase_info = get_phase_description(&contract.phase); - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - let deliverable_names: Vec<String> = phase_deliverables.deliverables.iter().map(|d| d.name.clone()).collect(); - - ContractRequestResult { - success: true, - message: format!("Contract is in '{}' phase", contract.phase), - data: Some(json!({ - "phase": contract.phase, - "description": phase_info.0, - "activities": phase_info.1, - "deliverables": deliverable_names, - "guidance": phase_deliverables.guidance, - "nextPhase": get_next_phase(&contract.phase), - })), - } - } - - ContractToolRequest::SuggestPhaseTransition => { - let contract = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - let analysis = analyze_phase_readiness(&contract); - - ContractRequestResult { - success: true, - message: analysis.summary.clone(), - data: Some(json!({ - "currentPhase": contract.contract.phase, - "nextPhase": get_next_phase(&contract.contract.phase), - "ready": analysis.ready, - "summary": analysis.summary, - "reasons": analysis.reasons, - "suggestions": analysis.suggestions, - })), - } - } - - ContractToolRequest::AdvancePhase { new_phase, confirmed, feedback } => { - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - } - } - }; - - // Validate phase transition - let current_phase = &contract.phase; - let valid_next = get_next_phase(current_phase); - - if valid_next.as_deref() != Some(&new_phase) { - return ContractRequestResult { - success: false, - message: format!( - "Cannot transition from '{}' to '{}'. Next valid phase is: {:?}", - current_phase, new_phase, valid_next - ), - data: None, - }; - } - - // Check if deliverables are met before allowing transition - let cwr = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) | Err(_) => { - // Fall through - we'll just skip the deliverables check - return ContractRequestResult { - success: false, - message: "Failed to load contract for deliverables check".to_string(), - data: None, - }; - } - }; - - // Get completed deliverables for the current phase - let completed_deliverables = cwr.contract.get_completed_deliverables(current_phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - - let check_result = crate::llm::check_deliverables_met( - current_phase, - &contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Block transition if deliverables are not met - if !check_result.deliverables_met { - return ContractRequestResult { - success: false, - message: format!( - "Cannot advance to '{}' phase: deliverables not met. {}", - new_phase, check_result.summary - ), - data: Some(json!({ - "status": "deliverables_not_met", - "currentPhase": current_phase, - "requestedPhase": new_phase, - "deliverablesMet": false, - "requiredDeliverables": check_result.required_deliverables, - "missing": check_result.missing, - "action": "Complete the missing deliverables before advancing to the next phase" - })), - }; - } - - // Check if phase_guard is enabled - if contract.phase_guard { - // If user provided feedback, return it for the task to address - if let Some(ref user_feedback) = feedback { - return ContractRequestResult { - success: true, - message: format!( - "Phase transition to '{}' requires changes. User feedback: {}", - new_phase, user_feedback - ), - data: Some(json!({ - "status": "changes_requested", - "currentPhase": current_phase, - "requestedPhase": new_phase, - "feedback": user_feedback, - "action": "Address the user feedback and try again when ready" - })), - }; - } - - // If not confirmed, return requires_confirmation with phase deliverables - // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level - if !confirmed { - // Get files created in this phase - let phase_files = match repository::list_files_in_contract(pool, contract_id, owner_id).await { - Ok(files) => files - .into_iter() - .filter(|f| f.contract_phase.as_deref() == Some(current_phase)) - .map(|f| json!({ - "id": f.id, - "name": f.name, - "description": f.description - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get tasks completed in this contract - let phase_tasks = match repository::list_tasks_in_contract(pool, contract_id, owner_id).await { - Ok(tasks) => tasks - .into_iter() - .filter(|t| t.status == "done" || t.status == "completed") - .map(|t| json!({ - "id": t.id, - "name": t.name, - "status": t.status - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get phase deliverables with completion status - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(current_phase, &contract.contract_type); - let completed_deliverables = contract.get_completed_deliverables(current_phase); - - let deliverables: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| json!({ - "id": d.id, - "name": d.name, - "completed": completed_deliverables.contains(&d.id) - })) - .collect(); - - // Build deliverables summary - let deliverables_summary = format!( - "Phase '{}' deliverables: {} files created, {} tasks completed.", - current_phase, - phase_files.len(), - phase_tasks.len() - ); - - let transition_id = uuid::Uuid::new_v4().to_string(); - - return ContractRequestResult { - success: true, - message: format!( - "Phase transition to '{}' requires user confirmation. Review the deliverables and call advance_phase again with confirmed=true to proceed, or provide feedback to request changes.", - new_phase - ), - data: Some(json!({ - "status": "requires_confirmation", - "transitionId": transition_id, - "currentPhase": current_phase, - "nextPhase": new_phase, - "deliverablesSummary": deliverables_summary, - "deliverables": deliverables, - "phaseFiles": phase_files, - "phaseTasks": phase_tasks, - "requiresConfirmation": true, - "message": "Phase guard is enabled. User confirmation required.", - "instructions": "To proceed: call advance_phase with confirmed=true. To request changes: call advance_phase with feedback='your feedback here'" - })), - }; - } - } - - // Update phase (either phase_guard is disabled, or user confirmed) - match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await { - Ok(Some(updated)) => { - // Get deliverables for the new phase (using contract type) - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&new_phase, &contract.contract_type); - - // Build deliverables list - let deliverables_list: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| json!({ - "id": d.id, - "name": d.name, - "priority": format!("{:?}", d.priority).to_lowercase(), - "description": d.description, - })) - .collect(); - - ContractRequestResult { - success: true, - message: format!( - "Advanced contract from '{}' to '{}' phase. {}", - current_phase, new_phase, phase_deliverables.guidance - ), - data: Some(json!({ - "status": "advanced", - "previousPhase": current_phase, - "newPhase": updated.phase, - "phaseGuidance": phase_deliverables.guidance, - "deliverables": deliverables_list, - "requiresRepository": phase_deliverables.requires_repository, - "requiresTasks": phase_deliverables.requires_tasks, - })), - } - }, - Ok(None) => ContractRequestResult { - success: false, - message: "Failed to update phase".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to update phase: {}", e), - data: None, - }, - } - } - - ContractToolRequest::AddRepository { - repo_type, - name, - url, - is_primary, - } => { - let add_result = match repo_type.as_str() { - "remote" => { - let url = url.unwrap_or_default(); - repository::add_remote_repository( - pool, - contract_id, - &name, - &url, - is_primary, - ) - .await - } - "local" => { - let path = url.unwrap_or_default(); - repository::add_local_repository( - pool, - contract_id, - &name, - &path, - is_primary, - ) - .await - } - "managed" => { - repository::create_managed_repository(pool, contract_id, &name, is_primary) - .await - } - _ => { - return ContractRequestResult { - success: false, - message: format!("Invalid repository type: {}", repo_type), - data: None, - } - } - }; - - match add_result { - Ok(repo) => ContractRequestResult { - success: true, - message: format!("Added {} repository '{}'", repo_type, name), - data: Some(json!({ - "repositoryId": repo.id, - "name": repo.name, - "isPrimary": repo.is_primary, - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to add repository: {}", e), - data: None, - }, - } - } - - ContractToolRequest::SetPrimaryRepository { repository_id } => { - match repository::set_repository_primary(pool, repository_id, contract_id).await { - Ok(true) => ContractRequestResult { - success: true, - message: "Set repository as primary".to_string(), - data: Some(json!({ - "repositoryId": repository_id, - })), - }, - Ok(false) => ContractRequestResult { - success: false, - message: "Repository not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to set primary repository: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Phase Guidance Handlers - // ============================================================================= - - ContractToolRequest::GetPhaseChecklist => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - let checklist = crate::llm::get_phase_checklist_for_type(&cwr.contract.phase, &completed_deliverables, &task_infos, has_repository, &cwr.contract.contract_type); - - ContractRequestResult { - success: true, - message: checklist.summary.clone(), - data: Some(json!({ - "phase": checklist.phase, - "completionPercentage": checklist.completion_percentage, - "deliverables": checklist.deliverables, - "hasRepository": checklist.has_repository, - "repositoryRequired": checklist.repository_required, - "taskStats": checklist.task_stats, - "suggestions": checklist.suggestions, - "summary": checklist.summary, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CheckDeliverablesMet => { - match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(cwr)) => { - let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase); - - let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo { - name: t.name.clone(), - status: t.status.clone(), - }).collect(); - - let has_repository = !cwr.repositories.is_empty(); - - let check_result = crate::llm::check_deliverables_met( - &cwr.contract.phase, - &cwr.contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - ); - - // Check if we should auto-progress - let auto_progress = crate::llm::should_auto_progress( - &cwr.contract.phase, - &cwr.contract.contract_type, - &completed_deliverables, - &task_infos, - has_repository, - cwr.contract.autonomous_loop, - ); - - ContractRequestResult { - success: true, - message: check_result.summary.clone(), - data: Some(json!({ - "deliverablesMet": check_result.deliverables_met, - "readyToAdvance": check_result.ready_to_advance, - "phase": check_result.phase, - "nextPhase": check_result.next_phase, - "requiredDeliverables": check_result.required_deliverables, - "missing": check_result.missing, - "summary": check_result.summary, - "autoProgressRecommended": check_result.auto_progress_recommended, - "autoProgress": { - "shouldProgress": auto_progress.should_progress, - "nextPhase": auto_progress.next_phase, - "reason": auto_progress.reason, - "action": format!("{:?}", auto_progress.action), - }, - "autonomousLoop": cwr.contract.autonomous_loop, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Task Derivation Handlers - // ============================================================================= - - ContractToolRequest::DeriveTasksFromFile { file_id } => { - // First get the file - match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(file)) => { - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Convert body to markdown for task parsing - let markdown = body_to_markdown(&file.body); - - // Parse tasks from the content - let parse_result = parse_tasks_from_breakdown(&markdown); - - ContractRequestResult { - success: true, - message: format!("Found {} tasks in file '{}'", parse_result.total, file.name), - data: Some(json!({ - "fileId": file_id, - "fileName": file.name, - "tasks": parse_result.tasks, - "groups": parse_result.groups, - "total": parse_result.total, - "warnings": parse_result.warnings, - "formatted": format_parsed_tasks(&parse_result), - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::CreateChainedTasks { tasks } => { - // Get primary repository for tasks - let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await { - Ok(Some(contract)) => { - contract - .repositories - .iter() - .find(|r| r.is_primary) - .and_then(|r| r.repository_url.clone().or(r.local_path.clone())) - } - _ => None, - }; - - let mut created_tasks = Vec::new(); - let mut previous_task_id: Option<Uuid> = None; - - for task_def in &tasks { - let create_req = CreateTaskRequest { - contract_id: Some(contract_id), - name: task_def.name.clone(), - description: None, - plan: task_def.plan.clone(), - parent_task_id: None, - repository_url: repo_url.clone(), - base_branch: None, - target_branch: None, - merge_mode: None, - priority: 0, - target_repo_path: None, - completion_action: None, - continue_from_task_id: previous_task_id, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, owner_id, create_req).await { - Ok(task) => { - created_tasks.push(json!({ - "taskId": task.id, - "name": task.name, - "status": task.status, - "chainedFrom": previous_task_id, - })); - previous_task_id = Some(task.id); - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create task '{}': {}", task_def.name, e), - data: Some(json!({ - "createdSoFar": created_tasks, - })), - }; - } - } - } - - ContractRequestResult { - success: true, - message: format!("Created {} chained tasks", created_tasks.len()), - data: Some(json!({ - "tasks": created_tasks, - "total": created_tasks.len(), - })), - } - } - - // ============================================================================= - // Task Completion Processing Handlers - // ============================================================================= - - ContractToolRequest::ProcessTaskCompletion { task_id } => { - // Get the task - match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(task)) => { - // Verify task belongs to this contract - if task.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "Task does not belong to this contract".to_string(), - data: None, - }; - } - - // Get contract for context - let contract = get_contract_with_relations(pool, contract_id, owner_id).await.ok().flatten(); - - let total_tasks = contract.as_ref().map(|c| c.tasks.len()).unwrap_or(0); - let completed_tasks = contract.as_ref() - .map(|c| c.tasks.iter().filter(|t| t.status == "done").count()) - .unwrap_or(0); - - // Note: Finding next chained task would require querying full Task objects - // Since TaskSummary doesn't have continue_from_task_id, we skip this for now - let next_task: Option<(Uuid, String)> = None; - - // Find Dev Notes file if exists - let dev_notes = if let Some(ref c) = contract { - c.files.iter() - .find(|f| f.name.to_lowercase().contains("dev") && f.name.to_lowercase().contains("notes")) - .map(|f| (f.id, f.name.clone())) - } else { - None - }; - - let contract_phase = contract.as_ref() - .map(|c| c.contract.phase.clone()) - .unwrap_or_else(|| "execute".to_string()); - - // Analyze the task output - let analysis = analyze_task_output( - task_id, - &task.name, - task.last_output.as_deref(), - task.progress_summary.as_deref(), - &contract_phase, - total_tasks, - completed_tasks, - next_task, - dev_notes, - ); - - ContractRequestResult { - success: true, - message: format!("Analyzed completion of task '{}'", task.name), - data: Some(json!({ - "taskId": task_id, - "taskName": task.name, - "taskStatus": task.status, - "summary": analysis.summary, - "filesAffected": analysis.files_affected, - "nextSteps": analysis.next_steps, - "phaseImpact": analysis.phase_impact, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - ContractToolRequest::UpdateFileFromTask { file_id, task_id, section_title } => { - // Get the task - let task = match repository::get_task_for_owner(pool, task_id, owner_id).await { - Ok(Some(t)) => t, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "Task not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - // Get the file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - // Verify file belongs to this contract - if file.contract_id != Some(contract_id) { - return ContractRequestResult { - success: false, - message: "File does not belong to this contract".to_string(), - data: None, - }; - } - - // Build the section to add - let title = section_title.unwrap_or_else(|| format!("Task: {}", task.name)); - let result_text = task.last_output.as_deref().unwrap_or("Task completed"); - - // Create new body elements to append - let mut new_body = file.body.clone(); - new_body.push(crate::db::models::BodyElement::Heading { - level: 2, - text: title, - }); - new_body.push(crate::db::models::BodyElement::Paragraph { - text: format!("Status: {}", task.status), - }); - new_body.push(crate::db::models::BodyElement::Paragraph { - text: result_text.to_string(), - }); - - // Update the file using UpdateFileRequest - let update_req = UpdateFileRequest { - name: None, - description: None, - transcript: None, - summary: None, - body: Some(new_body), - version: None, // Don't require version for this update - repo_file_path: None, - }; - - match repository::update_file_for_owner(pool, file_id, owner_id, update_req).await { - Ok(Some(updated_file)) => { - ContractRequestResult { - success: true, - message: format!("Updated file '{}' with task summary", file.name), - data: Some(json!({ - "fileId": file_id, - "fileName": updated_file.name, - "taskId": task_id, - "taskName": task.name, - })), - } - } - Ok(None) => ContractRequestResult { - success: false, - message: "Failed to update file".to_string(), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - } - } - - // ============================================================================= - // Transcript Analysis Handlers - // ============================================================================= - - ContractToolRequest::AnalyzeTranscript { file_id } => { - // Get the file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - if file.transcript.is_empty() { - return ContractRequestResult { - success: false, - message: "File has no transcript to analyze".to_string(), - data: None, - }; - } - - // Format and analyze - let transcript_text = format_transcript_for_analysis(&file.transcript); - let speaker_stats = calculate_speaker_stats(&file.transcript); - let prompt = build_analysis_prompt(&transcript_text); - - // Call Claude for analysis - let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create Claude client: {}", e), - data: None, - }; - } - }; - - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(prompt), - }]; - - match client.chat_with_tools(claude_messages, &[]).await { - Ok(result) => { - let response_content = result.content.unwrap_or_default(); - match parse_analysis_response(&response_content, speaker_stats) { - Ok(analysis) => { - ContractRequestResult { - success: true, - message: format!( - "Analysis complete: {} requirements, {} decisions, {} action items", - analysis.requirements.len(), - analysis.decisions.len(), - analysis.action_items.len() - ), - data: Some(json!({ - "analysis": analysis - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to parse analysis: {}", e), - data: None, - } - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Claude API error: {}", e), - data: None, - } - } - } - - ContractToolRequest::CreateContractFromTranscript { - file_id, name, description, include_requirements, include_decisions, include_action_items - } => { - // Get file - let file = match repository::get_file_for_owner(pool, file_id, owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ContractRequestResult { - success: false, - message: "File not found".to_string(), - data: None, - }; - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }; - } - }; - - if file.transcript.is_empty() { - return ContractRequestResult { - success: false, - message: "File has no transcript".to_string(), - data: None, - }; - } - - // Analyze transcript - let transcript_text = format_transcript_for_analysis(&file.transcript); - let speaker_stats = calculate_speaker_stats(&file.transcript); - let prompt = build_analysis_prompt(&transcript_text); - - let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create Claude client: {}", e), - data: None, - }; - } - }; - - let claude_messages = vec![claude::Message { - role: "user".to_string(), - content: claude::MessageContent::Text(prompt), - }]; - - let analysis = match client.chat_with_tools(claude_messages, &[]).await { - Ok(result) => { - let response_content = result.content.unwrap_or_default(); - match parse_analysis_response(&response_content, speaker_stats) { - Ok(a) => a, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to parse analysis: {}", e), - data: None, - }; - } - } - } - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Claude API error: {}", e), - data: None, - }; - } - }; - - // Create contract - let contract_name = name - .or(analysis.suggested_contract_name.clone()) - .unwrap_or_else(|| format!("Contract from {}", file.name)); - let contract_description = description.or(analysis.suggested_description.clone()); - - let contract_req = crate::db::models::CreateContractRequest { - name: contract_name.clone(), - description: contract_description, - contract_type: Some("specification".to_string()), - initial_phase: Some("research".to_string()), - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - template_id: None, - }; - - let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await { - Ok(c) => c, - Err(e) => { - return ContractRequestResult { - success: false, - message: format!("Failed to create contract: {}", e), - data: None, - }; - } - }; - - let mut files_created = 0; - let mut tasks_created = 0; - - // Create requirements file if requested and there are requirements - if include_requirements && !analysis.requirements.is_empty() { - let requirements_items: Vec<String> = analysis.requirements - .iter() - .map(|req| format!("[{}] {}", req.speaker, req.text)) - .collect(); - - let body: Vec<crate::db::models::BodyElement> = vec![ - crate::db::models::BodyElement::Heading { - level: 1, - text: "Requirements".to_string(), - }, - crate::db::models::BodyElement::Paragraph { - text: format!("Extracted {} requirements from transcript analysis.", analysis.requirements.len()), - }, - crate::db::models::BodyElement::Heading { - level: 2, - text: "Extracted Requirements".to_string(), - }, - crate::db::models::BodyElement::List { - ordered: false, - items: requirements_items, - }, - ]; - - let create_req = crate::db::models::CreateFileRequest { - contract_id: contract.id, - name: Some("Requirements".to_string()), - description: Some("Requirements extracted from transcript analysis".to_string()), - body, - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some("specify".to_string()), - }; - - if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { - files_created += 1; - } - } - - // Create decisions file if requested and there are decisions - if include_decisions && !analysis.decisions.is_empty() { - let decisions_items: Vec<String> = analysis.decisions - .iter() - .map(|dec| format!("[{}] {}", dec.speaker, dec.text)) - .collect(); - - let body: Vec<crate::db::models::BodyElement> = vec![ - crate::db::models::BodyElement::Heading { - level: 1, - text: "Decisions".to_string(), - }, - crate::db::models::BodyElement::Paragraph { - text: format!("Extracted {} decisions from transcript analysis.", analysis.decisions.len()), - }, - crate::db::models::BodyElement::Heading { - level: 2, - text: "Recorded Decisions".to_string(), - }, - crate::db::models::BodyElement::List { - ordered: false, - items: decisions_items, - }, - ]; - - let create_req = crate::db::models::CreateFileRequest { - contract_id: contract.id, - name: Some("Decisions".to_string()), - description: Some("Decisions extracted from transcript analysis".to_string()), - body, - transcript: Vec::new(), - location: None, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() { - files_created += 1; - } - } - - // Create tasks from action items if requested - if include_action_items && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = CreateTaskRequest { - contract_id: Some(contract.id), - name: item.text.chars().take(100).collect(), - description: Some(format!("Action item from: {}", item.speaker)), - plan: item.text.clone(), - parent_task_id: None, - repository_url: None, - base_branch: None, - target_branch: None, - merge_mode: None, - priority: match item.priority.as_deref() { - Some("high") => 10, - Some("medium") => 5, - _ => 0, - }, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if repository::create_task_for_owner(pool, owner_id, task_req).await.is_ok() { - tasks_created += 1; - } - } - } - - ContractRequestResult { - success: true, - message: format!( - "Created contract '{}' with {} files and {} tasks from transcript analysis", - contract_name, files_created, tasks_created - ), - data: Some(json!({ - "contractId": contract.id, - "contractName": contract_name, - "filesCreated": files_created, - "tasksCreated": tasks_created, - "analysis": { - "requirementsCount": analysis.requirements.len(), - "decisionsCount": analysis.decisions.len(), - "actionItemsCount": analysis.action_items.len() - } - })), - } - } - - - } -} - -/// Get description and activities for a phase -fn get_phase_description(phase: &str) -> (String, Vec<String>) { - match phase { - "research" => ( - "Gather information, analyze competitors, and understand user needs".to_string(), - vec![ - "Conduct user research".to_string(), - "Analyze competitors".to_string(), - "Document findings".to_string(), - "Identify opportunities".to_string(), - ], - ), - "specify" => ( - "Define requirements, user stories, and acceptance criteria".to_string(), - vec![ - "Write requirements".to_string(), - "Create user stories".to_string(), - "Define acceptance criteria".to_string(), - "Document constraints".to_string(), - ], - ), - "plan" => ( - "Design architecture, create task breakdowns, and technical designs".to_string(), - vec![ - "Design system architecture".to_string(), - "Create technical specifications".to_string(), - "Break down into tasks".to_string(), - "Plan implementation order".to_string(), - ], - ), - "execute" => ( - "Implement features, write code, and run tasks".to_string(), - vec![ - "Implement features".to_string(), - "Write tests".to_string(), - "Track progress".to_string(), - "Document implementation details".to_string(), - ], - ), - "review" => ( - "Review work, create release notes, and conduct retrospectives".to_string(), - vec![ - "Review code and features".to_string(), - "Create release notes".to_string(), - "Conduct retrospective".to_string(), - "Document learnings".to_string(), - ], - ), - _ => ( - "Unknown phase".to_string(), - vec![], - ), - } -} - -/// Get the next phase in the lifecycle -fn get_next_phase(current: &str) -> Option<String> { - match current { - "research" => Some("specify".to_string()), - "specify" => Some("plan".to_string()), - "plan" => Some("execute".to_string()), - "execute" => Some("review".to_string()), - "review" => None, // Final phase - _ => None, - } -} - -/// Phase readiness analysis result -struct PhaseReadinessAnalysis { - ready: bool, - summary: String, - reasons: Vec<String>, - suggestions: Vec<String>, -} - -/// Analyze if the contract is ready to transition to the next phase -fn analyze_phase_readiness(contract: &crate::db::models::ContractWithRelations) -> PhaseReadinessAnalysis { - let mut reasons = Vec::new(); - let mut suggestions = Vec::new(); - - match contract.contract.phase.as_str() { - "research" => { - // Check for research files - let research_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("research")) - .count(); - - if research_files == 0 { - reasons.push("No research documents created yet".to_string()); - suggestions.push("Create research notes or competitor analysis documents".to_string()); - } else { - reasons.push(format!("{} research document(s) created", research_files)); - } - - let ready = research_files > 0; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Research phase has documentation. Consider transitioning to Specify phase.".to_string() - } else { - "Research phase needs more documentation before transitioning.".to_string() - }, - reasons, - suggestions, - } - } - "specify" => { - let spec_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("specify")) - .count(); - - if spec_files == 0 { - reasons.push("No specification documents created yet".to_string()); - suggestions.push("Create requirements or user stories documents".to_string()); - } else { - reasons.push(format!("{} specification document(s) created", spec_files)); - } - - let ready = spec_files > 0; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Specification phase has documentation. Consider transitioning to Plan phase.".to_string() - } else { - "Specification phase needs requirements or user stories.".to_string() - }, - reasons, - suggestions, - } - } - "plan" => { - let plan_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("plan")) - .count(); - - let has_repos = !contract.repositories.is_empty(); - - if plan_files == 0 { - reasons.push("No planning documents created yet".to_string()); - suggestions.push("Create architecture or task breakdown documents".to_string()); - } else { - reasons.push(format!("{} planning document(s) created", plan_files)); - } - - if !has_repos { - reasons.push("No repositories configured".to_string()); - suggestions.push("Add a repository for task execution".to_string()); - } else { - reasons.push(format!("{} repository(ies) configured", contract.repositories.len())); - } - - let ready = plan_files > 0 && has_repos; - PhaseReadinessAnalysis { - ready, - summary: if ready { - "Planning phase complete with documents and repositories. Ready for Execute phase.".to_string() - } else { - "Planning phase needs documentation and/or repository configuration.".to_string() - }, - reasons, - suggestions, - } - } - "execute" => { - let total_tasks = contract.tasks.len(); - let done_tasks = contract.tasks.iter().filter(|t| t.status == "done").count(); - let running_tasks = contract.tasks.iter().filter(|t| t.status == "running").count(); - - if total_tasks == 0 { - reasons.push("No tasks created yet".to_string()); - suggestions.push("Create tasks to implement the planned work".to_string()); - } else { - reasons.push(format!("{} of {} tasks completed", done_tasks, total_tasks)); - } - - if running_tasks > 0 { - reasons.push(format!("{} task(s) still running", running_tasks)); - suggestions.push("Wait for running tasks to complete".to_string()); - } - - let ready = total_tasks > 0 && done_tasks == total_tasks; - - // For simple contracts, execute is the terminal phase - suggest completion - if ready && contract.contract.contract_type == "simple" { - suggestions.push("Mark the contract as completed".to_string()); - } - - PhaseReadinessAnalysis { - ready, - summary: if ready { - if contract.contract.contract_type == "simple" { - "All tasks completed. Contract can be marked as completed.".to_string() - } else { - "All tasks completed. Ready for Review phase.".to_string() - } - } else if total_tasks == 0 { - "No tasks created yet. Create and complete tasks before reviewing.".to_string() - } else { - format!("{}/{} tasks complete. Finish remaining tasks before review.", done_tasks, total_tasks) - }, - reasons, - suggestions, - } - } - "review" => { - let review_files = contract.files.iter() - .filter(|f| f.contract_phase.as_deref() == Some("review")) - .count(); - - if review_files == 0 { - suggestions.push("Create review checklist or release notes".to_string()); - } else { - // Review documentation exists - suggest completion - suggestions.push("Mark the contract as completed".to_string()); - } - - PhaseReadinessAnalysis { - ready: review_files > 0, - summary: if review_files > 0 { - "Review documentation complete. Contract can be marked as completed.".to_string() - } else { - "Review phase needs documentation before completion.".to_string() - }, - reasons: vec!["Review is the final phase".to_string()], - suggestions, - } - } - _ => PhaseReadinessAnalysis { - ready: false, - summary: "Unknown phase".to_string(), - reasons: vec!["Phase not recognized".to_string()], - suggestions: vec![], - }, - } -} - -// ============================================================================= -// Contract Chat History Endpoints -// ============================================================================= - -/// Get contract chat history -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/chat/history", - responses( - (status = 200, description = "Chat history retrieved successfully", body = ContractChatHistoryResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_contract_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - } - - // Get or create conversation - let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, auth.owner_id).await { - Ok(conv) => conv, - Err(e) => { - tracing::error!("Failed to get contract conversation: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to get conversation: {}", e) })), - ) - .into_response(); - } - }; - - // Get messages - let messages = match repository::list_contract_chat_messages(pool, conversation.id, Some(100)).await { - Ok(msgs) => msgs, - Err(e) => { - tracing::error!("Failed to list contract chat messages: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to list messages: {}", e) })), - ) - .into_response(); - } - }; - - ( - StatusCode::OK, - Json(ContractChatHistoryResponse { - contract_id, - conversation_id: conversation.id, - messages, - }), - ) - .into_response() -} - -/// Clear contract chat history (creates a new conversation) -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/chat/history", - responses( - (status = 200, description = "Chat history cleared successfully"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Contract not found"), - (status = 500, description = "Internal server error") - ), - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn clear_contract_chat_history( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(contract_id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "Contract not found" })), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Database error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Database error: {}", e) })), - ) - .into_response(); - } - } - - // Clear conversation (archives existing and creates new) - match repository::clear_contract_conversation(pool, contract_id, auth.owner_id).await { - Ok(new_conversation) => { - ( - StatusCode::OK, - Json(json!({ - "message": "Chat history cleared", - "newConversationId": new_conversation.id - })), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to clear contract conversation: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Failed to clear history: {}", e) })), - ) - .into_response() - } - } -} diff --git a/makima/src/server/handlers/contract_daemon.rs b/makima/src/server/handlers/contract_daemon.rs deleted file mode 100644 index 5f56f06..0000000 --- a/makima/src/server/handlers/contract_daemon.rs +++ /dev/null @@ -1,936 +0,0 @@ -//! HTTP handlers for daemon-to-contract interaction. -//! -//! These endpoints allow tasks running in daemons to interact with their -//! associated contracts via the contract.sh script. Authentication is via -//! tool keys registered by the daemon when starting a task. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models::FileSummary, repository}; -use crate::llm::phase_guidance::{self, PhaseChecklist, TaskInfo}; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Request/Response Types -// ============================================================================= - -/// Contract status response for daemon. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractStatusResponse { - pub id: Uuid, - pub name: String, - pub phase: String, - pub status: String, - pub description: Option<String>, -} - -/// Contract goals response. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ContractGoalsResponse { - /// Description serves as goals for the contract - pub description: Option<String>, - pub phase: String, - pub phase_guidance: String, -} - -/// Progress report request from daemon. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ProgressReportRequest { - pub message: String, - #[serde(default)] - pub task_id: Option<Uuid>, -} - -/// Suggested action from server. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SuggestedActionResponse { - pub action: String, - pub description: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option<serde_json::Value>, -} - -/// Completion action request. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CompletionActionRequest { - #[serde(default)] - pub task_id: Option<Uuid>, - #[serde(default)] - pub files_modified: Vec<String>, - #[serde(default)] - pub lines_added: i32, - #[serde(default)] - pub lines_removed: i32, - #[serde(default)] - pub has_code_changes: bool, -} - -/// Recommended completion action. -#[derive(Debug, Clone, Serialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum CompletionAction { - Branch, - Merge, - Pr, - None, -} - -impl std::fmt::Display for CompletionAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CompletionAction::Branch => write!(f, "branch"), - CompletionAction::Merge => write!(f, "merge"), - CompletionAction::Pr => write!(f, "pr"), - CompletionAction::None => write!(f, "none"), - } - } -} - -/// Completion action response. -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CompletionActionResponse { - pub action: String, - pub reason: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub branch_name: Option<String>, -} - -/// Create file request from daemon. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateFileRequest { - pub name: String, - pub content: String, - #[serde(default)] - pub template_id: Option<String>, -} - -/// Update file request from daemon. -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DaemonUpdateFileRequest { - /// Content to update in the file (as markdown body element) - pub content: String, -} - -// ============================================================================= -// Handlers -// ============================================================================= - -/// Get contract status for daemon. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/status", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract status", body = ContractStatusResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_status( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(contract)) => Json(ContractStatusResponse { - id: contract.id, - name: contract.name, - phase: contract.phase, - status: contract.status, - description: contract.description, - }) - .into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get phase deliverables checklist. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/checklist", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Phase checklist", body = PhaseChecklist), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_checklist( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get completed deliverables for the current phase - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - // Get tasks for this contract - let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(t) => t - .into_iter() - .map(|t| TaskInfo { - name: t.name, - status: t.status, - }) - .collect::<Vec<_>>(), - Err(e) => { - tracing::warn!("Failed to get tasks for contract {}: {}", id, e); - Vec::new() - } - }; - - // Check if repository is configured - let has_repository = match repository::list_contract_repositories(pool, id).await { - Ok(repos) => !repos.is_empty(), - Err(_) => false, - }; - - let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type); - - Json(checklist).into_response() -} - -/// Get contract goals. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/goals", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract goals", body = ContractGoalsResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_goals( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(contract)) => { - let deliverables = phase_guidance::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - Json(ContractGoalsResponse { - description: contract.description, - phase: contract.phase, - phase_guidance: deliverables.guidance, - }) - .into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Post progress report to contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/report", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = ProgressReportRequest, - responses( - (status = 200, description = "Report received"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn post_progress_report( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<ProgressReportRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Log the report as a contract event - let event_type = "progress_report"; - let payload = serde_json::json!({ - "message": req.message, - "task_id": req.task_id, - }); - - if let Err(e) = repository::record_contract_event(pool, id, event_type, Some(payload)).await { - tracing::warn!("Failed to create contract event: {}", e); - } - - Json(serde_json::json!({"status": "received"})).into_response() -} - -/// Get suggested action based on contract state. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/suggest-action", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Suggested action", body = SuggestedActionResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_suggest_action( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get completed deliverables and tasks for checklist - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id) - .await - .unwrap_or_default() - .into_iter() - .map(|t| TaskInfo { - name: t.name, - status: t.status, - }) - .collect::<Vec<_>>(); - - let has_repository = repository::list_contract_repositories(pool, id) - .await - .map(|r| !r.is_empty()) - .unwrap_or(false); - - let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type); - - // Determine suggested action based on checklist - let (action, description) = if !checklist.suggestions.is_empty() { - ("follow_suggestion", checklist.suggestions.first().unwrap().clone()) - } else if checklist.completion_percentage >= 100 { - ("advance_phase", format!("Phase {} is complete, consider advancing to next phase", contract.phase)) - } else { - ("continue", format!("Continue working on {} phase ({}% complete)", contract.phase, checklist.completion_percentage)) - }; - - Json(SuggestedActionResponse { - action: action.to_string(), - description, - data: None, - }) - .into_response() -} - -/// Get recommended completion action. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/completion-action", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CompletionActionRequest, - responses( - (status = 200, description = "Recommended completion action", body = CompletionActionResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_completion_action( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CompletionActionRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Determine completion action based on phase and changes - let has_changes = !req.files_modified.is_empty() || req.lines_added > 0 || req.lines_removed > 0; - let has_significant_changes = req.lines_added + req.lines_removed > 50; - - let (action, reason) = match contract.phase.as_str() { - "research" | "specify" => { - if has_changes { - (CompletionAction::Merge, "Early phase changes can be merged directly".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - "plan" => { - if has_significant_changes { - (CompletionAction::Pr, "Significant planning changes require review".to_string()) - } else if has_changes { - (CompletionAction::Merge, "Minor planning changes can be merged".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - "execute" => { - if req.has_code_changes { - (CompletionAction::Pr, "Code changes in execute phase require review".to_string()) - } else if has_changes { - (CompletionAction::Branch, "Documentation changes can be branched".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - "review" => { - if has_changes { - (CompletionAction::Pr, "Review phase changes should be reviewed".to_string()) - } else { - (CompletionAction::None, "No changes to commit".to_string()) - } - } - _ => (CompletionAction::None, "Unknown phase".to_string()), - }; - - // Generate branch name based on contract - let branch_name = if matches!(action, CompletionAction::Branch | CompletionAction::Pr) { - let slug = contract.name.to_lowercase().replace(' ', "-"); - Some(format!("contract/{}", slug)) - } else { - None - }; - - Json(CompletionActionResponse { - action: action.to_string(), - reason, - branch_name, - }) - .into_response() -} - -/// List contract files for daemon. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/files", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "List of contract files", body = Vec<FileSummary>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn list_contract_files( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(files) => Json(files).into_response(), - Err(e) => { - tracing::error!("Failed to list files for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a specific contract file. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/daemon/files/{file_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("file_id" = Uuid, Path, description = "File ID") - ), - responses( - (status = 200, description = "File content"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or file not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn get_contract_file( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, file_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Get file and verify it belongs to this contract - match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { - Ok(Some(file)) => { - if file.contract_id != Some(id) { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found in this contract")), - ) - .into_response(); - } - Json(file).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get file {}: {}", file_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a contract file. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}/daemon/files/{file_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("file_id" = Uuid, Path, description = "File ID") - ), - request_body = DaemonUpdateFileRequest, - responses( - (status = 200, description = "File updated"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or file not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn update_contract_file( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, file_id)): Path<(Uuid, Uuid)>, - Json(req): Json<DaemonUpdateFileRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Get file and verify it belongs to this contract - let file = match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get file {}: {}", file_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - if file.contract_id != Some(id) { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found in this contract")), - ) - .into_response(); - } - - // Update the file with content parsed as markdown - let body = crate::llm::markdown_to_body(&req.content); - let update_req = crate::db::models::UpdateFileRequest { - name: None, - description: None, - transcript: None, - summary: None, - body: Some(body), - version: None, - repo_file_path: None, - }; - - match repository::update_file_for_owner(pool, file_id, auth.owner_id, update_req).await { - Ok(Some(updated)) => Json(updated).into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to update file {}: {}", file_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", format!("{}", e))), - ) - .into_response() - } - } -} - -/// Create a new contract file. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/daemon/files", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CreateFileRequest, - responses( - (status = 201, description = "File created"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - ), - security( - ("tool_key" = []), - ("api_key" = []) - ), - tag = "Contract Daemon" -)] -pub async fn create_contract_file( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CreateFileRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Create the file with content parsed as markdown - let body = crate::llm::markdown_to_body(&req.content); - let create_req = crate::db::models::CreateFileRequest { - contract_id: id, - name: Some(req.name), - description: None, - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: None, // Will be looked up from contract's current phase - }; - - match repository::create_file_for_owner(pool, auth.owner_id, create_req).await { - Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), - Err(e) => { - tracing::error!("Failed to create file for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} diff --git a/makima/src/server/handlers/contract_discuss.rs b/makima/src/server/handlers/contract_discuss.rs deleted file mode 100644 index 1f98f53..0000000 --- a/makima/src/server/handlers/contract_discuss.rs +++ /dev/null @@ -1,592 +0,0 @@ -//! Discussion endpoint for LLM-powered contract creation. -//! -//! This handler provides an ephemeral conversation with Makima to help users -//! define and create contracts through natural dialogue. - -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models::CreateContractRequest, repository}; -use crate::llm::{ - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - discuss_tools::{parse_discuss_tool_call, DiscussToolRequest, DISCUSS_TOOLS}, - LlmModel, ToolCall, ToolResult, UserQuestion, -}; -use crate::server::auth::Authenticated; -use crate::server::state::SharedState; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 10; - -/// System prompt for Makima character in contract discussions -const DISCUSS_SYSTEM_PROMPT: &str = r#" -You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation. - -## Your Personality -- Professional yet personable -- Focused on understanding the user's actual needs -- Ask clarifying questions when requirements are vague -- Guide the conversation toward actionable outcomes -- Comfortable making recommendations based on experience - -## Your Goal -Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes: -- A clear name and description -- The right contract type (simple, specification, or execute) -- Understanding of the scope and requirements - -## Contract Types -- **simple**: Quick tasks with minimal planning (plan -> execute phases only) -- **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review) -- **execute**: Direct implementation when requirements are already clear (execute phase only) - -## Guidelines -1. **Start by understanding**: Ask about what they want to build -2. **Clarify scope**: Is this a quick fix, a new feature, or a full project? -3. **Gather requirements**: What are the must-haves vs nice-to-haves? -4. **Identify context**: Is there existing code? Which repository? -5. **Recommend type**: Suggest the appropriate contract type -6. **Confirm and create**: When the user is satisfied, create the contract - -## When to Create the Contract -Create the contract when: -- You have a clear understanding of what the user wants -- The user has confirmed they're ready to proceed -- You've gathered enough information for a meaningful contract - -Do NOT create the contract if: -- The user is still exploring ideas -- Key information is missing -- The user hasn't indicated readiness - -{transcript_context} -"#; - -/// Chat message in history -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -/// Request to discuss a potential contract -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DiscussContractRequest { - /// The user's message - pub message: String, - /// Optional model selection (default: claude-sonnet) - #[serde(default)] - pub model: Option<String>, - /// Conversation history for context continuity - #[serde(default)] - pub history: Option<Vec<ChatMessage>>, - /// Optional transcript context from current session - #[serde(default)] - pub transcript_context: Option<String>, -} - -/// Response from the discussion endpoint -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DiscussContractResponse { - /// Makima's response message - pub response: String, - /// Tool calls that were executed (e.g., create_contract) - pub tool_calls: Vec<ToolCallInfo>, - /// If a contract was created, its details - #[serde(skip_serializing_if = "Option::is_none")] - pub created_contract: Option<CreatedContractInfo>, - /// Pending questions (if LLM needs clarification) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -/// Information about a tool call that was executed -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Information about a created contract -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreatedContractInfo { - pub id: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - pub contract_type: String, - pub initial_phase: String, -} - -/// 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, -} - -/// Discuss a potential contract with Makima -#[utoipa::path( - post, - path = "/api/v1/contracts/discuss", - request_body = DiscussContractRequest, - responses( - (status = 200, description = "Discussion completed successfully", body = DiscussContractResponse), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn discuss_contract_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<DiscussContractRequest>, -) -> 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(); - }; - - // 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 discussion 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 system prompt with optional transcript context - let transcript_section = match &request.transcript_context { - Some(ctx) => format!( - "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n", - ctx - ), - None => String::new(), - }; - - let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section); - - // Run the discussion agentic loop - run_discuss_agentic_loop( - pool, - &llm_client, - system_prompt, - &request, - auth.owner_id, - ) - .await -} - -/// Run the agentic loop for contract discussion -async fn run_discuss_agentic_loop( - pool: &sqlx::PgPool, - llm_client: &LlmClient, - system_prompt: String, - request: &DiscussContractRequest, - owner_id: Uuid, -) -> axum::response::Response { - // Build initial messages - let mut messages = vec![Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }]; - - // Add conversation history if provided - if let Some(history) = &request.history { - for msg in history { - messages.push(Message { - role: msg.role.clone(), - content: Some(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, - }); - - // State for tracking - let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - let mut created_contract: Option<CreatedContractInfo> = None; - 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 discussion loop iteration" - ); - - // Call the appropriate LLM API - let result = match llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &DISCUSS_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, &DISCUSS_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 discussion tool call"); - - // Parse the tool call - let mut execution_result = parse_discuss_tool_call(tool_call); - - // Handle async discussion tool requests - if let Some(discuss_request) = execution_result.request.take() { - let async_result = - handle_discuss_request(pool, discuss_request, owner_id).await; - execution_result.success = async_result.success; - execution_result.message = async_result.message; - execution_result.data = async_result.data; - - // Check if a contract was created - if let Some(ref data) = execution_result.data { - if let Some(contract_info) = data.get("createdContract") { - created_contract = Some(CreatedContractInfo { - id: contract_info["id"].as_str().unwrap_or("").to_string(), - name: contract_info["name"].as_str().unwrap_or("").to_string(), - description: contract_info["description"].as_str().map(|s| s.to_string()), - contract_type: contract_info["contractType"].as_str().unwrap_or("").to_string(), - initial_phase: contract_info["initialPhase"].as_str().unwrap_or("").to_string(), - }); - } - } - } - - // Check for pending user questions - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "Discussion LLM requesting user input" - ); - pending_questions = Some(questions); - all_tool_call_infos.push(ToolCallInfo { - 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(ToolCallInfo { - 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 { - "Done!".to_string() - } - }); - - ( - StatusCode::OK, - Json(DiscussContractResponse { - response: response_text, - tool_calls: all_tool_call_infos, - created_contract, - pending_questions, - }), - ) - .into_response() -} - -/// Result from handling an async discussion tool request -struct DiscussRequestResult { - success: bool, - message: String, - data: Option<serde_json::Value>, -} - -/// Handle async discussion tool requests that require database access -async fn handle_discuss_request( - pool: &sqlx::PgPool, - request: DiscussToolRequest, - owner_id: Uuid, -) -> DiscussRequestResult { - match request { - DiscussToolRequest::CreateContract { - name, - description, - contract_type, - repository_url, - local_only, - } => { - // Create the contract request - let create_req = CreateContractRequest { - name: name.clone(), - description: Some(description.clone()), - contract_type: Some(contract_type.clone()), - template_id: None, - initial_phase: None, - autonomous_loop: None, - phase_guard: None, - local_only: Some(local_only), - auto_merge_local: None, - }; - - match repository::create_contract_for_owner(pool, owner_id, create_req).await { - Ok(contract) => { - // If repository URL was provided, try to add it - if let Some(repo_url) = repository_url { - // Try to add as remote repository - let add_result = repository::add_remote_repository( - pool, - contract.id, - &format!("{} Repository", name), - &repo_url, - true, // is_primary - ) - .await; - - if let Err(e) = add_result { - tracing::warn!( - "Failed to add repository to contract {}: {}", - contract.id, - e - ); - } - } - - DiscussRequestResult { - success: true, - message: format!("Contract '{}' created successfully!", contract.name), - data: Some(json!({ - "createdContract": { - "id": contract.id.to_string(), - "name": contract.name, - "description": contract.description, - "contractType": contract.contract_type, - "initialPhase": contract.phase, - } - })), - } - } - Err(e) => { - tracing::error!("Failed to create contract: {}", e); - DiscussRequestResult { - success: false, - message: format!("Failed to create contract: {}", e), - data: None, - } - } - } - } - } -} diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs deleted file mode 100644 index bdd4d40..0000000 --- a/makima/src/server/handlers/contracts.rs +++ /dev/null @@ -1,2376 +0,0 @@ -//! HTTP handlers for contract CRUD operations. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Deserialize; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::models::{ - AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest, - ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CreateContractRequest, CreateManagedRepositoryRequest, PhaseChangeResult, - UpdateContractRequest, UpdateTaskRequest, -}; -use crate::db::repository::{self, RepositoryError}; -use crate::llm::PhaseDeliverables; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Deliverable Validation -// ============================================================================= - -/// Error type for deliverable validation failures -#[derive(Debug, Clone)] -pub struct DeliverableValidationError { - /// The error message with details about valid deliverables - pub message: String, -} - -impl DeliverableValidationError { - pub fn new(message: impl Into<String>) -> Self { - Self { - message: message.into(), - } - } -} - -impl std::fmt::Display for DeliverableValidationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for DeliverableValidationError {} - -/// Validates that a deliverable ID is valid for the given phase deliverables. -/// -/// # Arguments -/// * `deliverable_id` - The deliverable ID to validate -/// * `phase_deliverables` - The phase deliverables configuration to validate against -/// -/// # Returns -/// * `Ok(())` if the deliverable is valid -/// * `Err(DeliverableValidationError)` if the deliverable is not valid -pub fn validate_deliverable( - deliverable_id: &str, - phase_deliverables: &PhaseDeliverables, -) -> Result<(), DeliverableValidationError> { - let valid_deliverable = phase_deliverables - .deliverables - .iter() - .any(|d| d.id == deliverable_id); - - if valid_deliverable { - Ok(()) - } else { - let valid_ids: Vec<&str> = phase_deliverables - .deliverables - .iter() - .map(|d| d.id.as_str()) - .collect(); - - Err(DeliverableValidationError::new(format!( - "Invalid deliverable '{}' for {} phase. Valid IDs: [{}]", - deliverable_id, - phase_deliverables.phase, - valid_ids.join(", ") - ))) - } -} - -// ============================================================================= -// Supervisor Repository Update Helper -// ============================================================================= - -/// Helper function to update the supervisor task with repository info when a primary repo is added. -/// This ensures the supervisor has access to the repository when it starts. -async fn update_supervisor_with_repo_if_needed( - pool: &sqlx::PgPool, - contract_id: uuid::Uuid, - owner_id: uuid::Uuid, - repo: &ContractRepository, -) { - // Only update for primary repositories - if !repo.is_primary { - return; - } - - // Get the supervisor task - let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await { - Ok(Some(s)) => s, - Ok(None) => { - tracing::debug!(contract_id = %contract_id, "No supervisor task found"); - return; - } - Err(e) => { - tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task"); - return; - } - }; - - // Only update if supervisor doesn't have a repository URL yet - if supervisor.repository_url.is_some() { - tracing::debug!( - supervisor_id = %supervisor.id, - "Supervisor already has repository URL" - ); - return; - } - - // Get repository URL (for remote repos) or local path (for local repos) - let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone()); - - if repo_url.is_none() && repo.source_type != "managed" { - tracing::debug!( - supervisor_id = %supervisor.id, - "Repository has no URL or path to assign" - ); - return; - } - - // Update supervisor task with repository info - let update_req = UpdateTaskRequest { - repository_url: repo_url, - version: Some(supervisor.version), - ..Default::default() - }; - - match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await { - Ok(Some(updated)) => { - tracing::info!( - supervisor_id = %updated.id, - repository_url = ?updated.repository_url, - "Updated supervisor task with repository URL" - ); - } - Ok(None) => { - tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update"); - } - Err(e) => { - tracing::warn!( - supervisor_id = %supervisor.id, - error = %e, - "Failed to update supervisor with repository URL" - ); - } - } -} - -/// List all root contracts (no parent) for the authenticated user's owner. -#[utoipa::path( - get, - path = "/api/v1/contracts", - responses( - (status = 200, description = "List of root contracts", body = ContractListResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn list_contracts( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::list_contracts_for_owner(pool, auth.owner_id).await { - Ok(contracts) => { - let total = contracts.len() as i64; - Json(ContractListResponse { contracts, total }).into_response() - } - Err(e) => { - tracing::error!("Failed to list contracts: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a contract by ID with all its relations (repositories, files, tasks). -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract details with relations", body = ContractWithRelations), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get the contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get repositories - let repositories = match repository::list_contract_repositories(pool, id).await { - Ok(r) => r, - Err(e) => { - tracing::warn!("Failed to get repositories for {}: {}", id, e); - Vec::new() - } - }; - - // Get files - let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(f) => f, - Err(e) => { - tracing::warn!("Failed to get files for contract {}: {}", id, e); - Vec::new() - } - }; - - // Get tasks - let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(t) => t, - Err(e) => { - tracing::warn!("Failed to get tasks for contract {}: {}", id, e); - Vec::new() - } - }; - - Json(ContractWithRelations { - contract, - repositories, - files, - tasks, - }) - .into_response() -} - -/// Create a new contract. -#[utoipa::path( - post, - path = "/api/v1/contracts", - request_body = CreateContractRequest, - responses( - (status = 201, description = "Contract created", body = ContractSummary), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn create_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(req): Json<CreateContractRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await { - Ok(contract) => { - // Create supervisor task for this contract - let supervisor_name = format!("{} Supervisor", contract.name); - let supervisor_plan = format!( - "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", - contract.name, - contract.description.as_deref().unwrap_or("No description provided.") - ); - - // Get repository info from contract if available - let repo_url = { - // Try to get the first repository associated with this contract - match repository::list_contract_repositories(pool, contract.id).await { - Ok(repos) if !repos.is_empty() => { - let repo = &repos[0]; - repo.repository_url.clone() - } - _ => None, - } - }; - - let supervisor_req = crate::db::models::CreateTaskRequest { - name: supervisor_name, - description: None, - plan: supervisor_plan, - repository_url: repo_url, - base_branch: None, - target_branch: None, - parent_task_id: None, - contract_id: Some(contract.id), - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: true, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Supervisor uses its own worktree - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { - Ok(supervisor_task) => { - tracing::info!( - contract_id = %contract.id, - supervisor_task_id = %supervisor_task.id, - is_supervisor = supervisor_task.is_supervisor, - "Created supervisor task for contract" - ); - - // Update contract with supervisor_task_id - let update_req = crate::db::models::UpdateContractRequest { - supervisor_task_id: Some(supervisor_task.id), - version: Some(contract.version), - ..Default::default() - }; - if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to link supervisor task to contract" - ); - } - } - Err(e) => { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to create supervisor task for contract" - ); - } - } - - // Record history event for contract creation - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "contract", - Some("created"), - Some(&contract.phase), - serde_json::json!({ - "name": &contract.name, - "type": &contract.contract_type, - "description": &contract.description, - }), - ).await; - - // Get the summary version with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await - { - Ok(Some(summary)) => (StatusCode::CREATED, Json(summary)).into_response(), - Ok(None) => { - // Shouldn't happen, but return basic info if it does - ( - StatusCode::CREATED, - Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }), - ) - .into_response() - } - Err(e) => { - tracing::warn!("Failed to get contract summary: {}", e); - ( - StatusCode::CREATED, - Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }), - ) - .into_response() - } - } - } - Err(e) => { - tracing::error!("Failed to create contract: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a contract. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = UpdateContractRequest, - responses( - (status = 200, description = "Contract updated", body = ContractSummary), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn update_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<UpdateContractRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::update_contract_for_owner(pool, id, auth.owner_id, req).await { - Ok(Some(contract)) => { - // If contract is completed, stop the supervisor task and clean up worktrees - if contract.status == "completed" { - if let Some(supervisor_task_id) = contract.supervisor_task_id { - // Get the supervisor task to find its daemon - if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - if let Some(daemon_id) = supervisor.daemon_id { - let state_clone = state.clone(); - tokio::spawn(async move { - // Gracefully interrupt the supervisor - let cmd = crate::server::state::DaemonCommand::InterruptTask { - task_id: supervisor_task_id, - graceful: true, - }; - if let Err(e) = state_clone.send_daemon_command(daemon_id, cmd).await { - tracing::warn!( - supervisor_task_id = %supervisor_task_id, - daemon_id = %daemon_id, - error = %e, - "Failed to stop supervisor task on contract completion" - ); - } else { - tracing::info!( - supervisor_task_id = %supervisor_task_id, - contract_id = %id, - "Stopped supervisor task on contract completion" - ); - } - }); - } - } - } - - // Clean up all task worktrees for this contract - let pool_clone = pool.clone(); - let state_clone = state.clone(); - let contract_id = id; - tokio::spawn(async move { - cleanup_contract_worktrees(&pool_clone, &state_clone, contract_id).await; - }); - - // Record history event for contract completion - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "contract", - Some("completed"), - Some(&contract.phase), - serde_json::json!({ - "name": &contract.name, - "status": &contract.status, - }), - ).await; - - } - - // Get summary with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await - { - Ok(Some(summary)) => Json(summary).into_response(), - _ => Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }) - .into_response(), - } - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(RepositoryError::VersionConflict { expected, actual }) => { - tracing::info!( - "Version conflict on contract {}: expected {}, actual {}", - id, - expected, - actual - ); - ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "code": "VERSION_CONFLICT", - "message": format!( - "Contract was modified. Expected version {}, actual version {}", - expected, actual - ), - "expectedVersion": expected, - "actualVersion": actual, - })), - ) - .into_response() - } - Err(RepositoryError::Database(e)) => { - tracing::error!("Failed to update contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 204, description = "Contract deleted"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn delete_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // First, verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Clean up any pending supervisor questions for this contract - state.remove_pending_questions_for_contract(id); - - // Clean up all task worktrees BEFORE deleting the contract - // (because CASCADE delete will remove tasks from DB) - cleanup_contract_worktrees(pool, &state, id).await; - - match repository::delete_contract_for_owner(pool, id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Repository Management -// ============================================================================= - -/// Add a remote repository to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/remote", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = AddRemoteRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_remote_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<AddRemoteRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_remote_repository(pool, id, &req.name, &req.repository_url, req.is_primary) - .await - { - Ok(repo) => { - // Update supervisor task with repository info if this is a primary repo - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - - // Track repository in history for future suggestions - if let Err(e) = repository::add_or_update_repository_history( - pool, - auth.owner_id, - &req.name, - Some(&req.repository_url), - None, - "remote", - ) - .await - { - // Log but don't fail the request if history tracking fails - tracing::warn!("Failed to track repository in history: {}", e); - } - - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!("Failed to add remote repository to contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Add a local repository to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/local", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = AddLocalRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_local_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<AddLocalRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_local_repository(pool, id, &req.name, &req.local_path, req.is_primary) - .await - { - Ok(repo) => { - // Update supervisor task with repository info if this is a primary repo - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - - // Track repository in history for future suggestions - if let Err(e) = repository::add_or_update_repository_history( - pool, - auth.owner_id, - &req.name, - None, - Some(&req.local_path), - "local", - ) - .await - { - // Log but don't fail the request if history tracking fails - tracing::warn!("Failed to track repository in history: {}", e); - } - - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!("Failed to add local repository to contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Create a managed repository (daemon will create it). -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/managed", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CreateManagedRepositoryRequest, - responses( - (status = 201, description = "Repository creation requested", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn create_managed_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CreateManagedRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::create_managed_repository(pool, id, &req.name, req.is_primary).await { - Ok(repo) => { - // For managed repos, the daemon will create the repo and we'll update later - // For now, just mark that this is a managed repo configuration - // The helper handles the case where repo has no URL yet - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!( - "Failed to create managed repository for contract {}: {}", - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a repository from a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/repositories/{repo_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("repo_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 204, description = "Repository removed"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn delete_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, repo_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::delete_contract_repository(pool, repo_id, id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to delete repository {} from contract {}: {}", - repo_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Set a repository as primary for a contract. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}/repositories/{repo_id}/primary", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("repo_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 204, description = "Repository set as primary"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn set_repository_primary( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, repo_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::set_repository_primary(pool, repo_id, id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to set repository {} as primary for contract {}: {}", - repo_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Task Association -// ============================================================================= - -/// Add a task to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/tasks/{task_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("task_id" = Uuid, Path, description = "Task ID") - ), - responses( - (status = 204, description = "Task added to contract"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or task not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_task_to_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, task_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify task exists and belongs to owner - match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get task {}: {}", task_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_task_to_contract(pool, id, task_id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to add task {} to contract {}: {}", task_id, id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Remove a task from a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/tasks/{task_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("task_id" = Uuid, Path, description = "Task ID") - ), - responses( - (status = 204, description = "Task removed from contract"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or task not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn remove_task_from_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, task_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::remove_task_from_contract(pool, id, task_id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found in this contract")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to remove task {} from contract {}: {}", - task_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Phase Management -// ============================================================================= - -/// Change contract phase. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/phase", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = ChangePhaseRequest, - responses( - (status = 200, description = "Phase changed", body = ContractSummary), - (status = 400, description = "Validation failed", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn change_phase( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<ChangePhaseRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // First, get the contract to check phase_guard - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // If phase_guard is enabled and not confirmed, return phase deliverables for review - // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level - if contract.phase_guard && !req.confirmed.unwrap_or(false) { - // If user provided feedback, return it - if let Some(ref feedback) = req.feedback { - return Json(serde_json::json!({ - "status": "changes_requested", - "currentPhase": contract.phase, - "requestedPhase": req.phase, - "feedback": feedback, - "message": "Feedback has been noted. Address the changes and try again." - })) - .into_response(); - } - - // Get files created in this phase - let phase_files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(files) => files - .into_iter() - .filter(|f| f.contract_phase.as_deref() == Some(&contract.phase)) - .map(|f| serde_json::json!({ - "id": f.id, - "name": f.name, - "description": f.description - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get tasks completed in this contract - let phase_tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(tasks) => tasks - .into_iter() - .filter(|t| t.status == "done" || t.status == "completed") - .map(|t| serde_json::json!({ - "id": t.id, - "name": t.name, - "status": t.status - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get phase deliverables with completion status - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - let deliverables: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| serde_json::json!({ - "id": d.id, - "name": d.name, - "completed": completed_deliverables.contains(&d.id) - })) - .collect(); - - let deliverables_summary = format!( - "Phase '{}' deliverables: {} files created, {} tasks completed.", - contract.phase, - phase_files.len(), - phase_tasks.len() - ); - - let transition_id = uuid::Uuid::new_v4().to_string(); - - return Json(serde_json::json!({ - "status": "requires_confirmation", - "transitionId": transition_id, - "currentPhase": contract.phase, - "nextPhase": req.phase, - "deliverablesSummary": deliverables_summary, - "deliverables": deliverables, - "phaseFiles": phase_files, - "phaseTasks": phase_tasks, - "requiresConfirmation": true, - "message": "Phase guard is enabled. User confirmation required." - })) - .into_response(); - } - - // Phase guard is disabled or user confirmed - proceed with phase change - // Use the version-checking function for explicit conflict detection - match repository::change_contract_phase_with_version( - pool, - id, - auth.owner_id, - &req.phase, - req.expected_version, - ) - .await - { - Ok(PhaseChangeResult::Success(updated_contract)) => { - // Save supervisor state on phase change (Task 3.3) - // This is a key save point for restoration - let new_phase_for_state = updated_contract.phase.clone(); - let contract_id_for_state = updated_contract.id; - let pool_for_state = pool.clone(); - tokio::spawn(async move { - if let Err(e) = repository::update_supervisor_phase(&pool_for_state, contract_id_for_state, &new_phase_for_state).await { - tracing::warn!( - contract_id = %contract_id_for_state, - new_phase = %new_phase_for_state, - error = %e, - "Failed to save supervisor state on phase change" - ); - } - }); - - // Notify supervisor of phase change - if let Some(supervisor_task_id) = updated_contract.supervisor_task_id { - if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - let state_clone = state.clone(); - let contract_id = updated_contract.id; - let new_phase = updated_contract.phase.clone(); - tokio::spawn(async move { - state_clone.notify_supervisor_of_phase_change( - supervisor.id, - supervisor.daemon_id, - contract_id, - &new_phase, - ).await; - }); - } - } - - // Record history event for phase change - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "phase", - Some("changed"), - Some(&contract.phase), - serde_json::json!({ - "contractName": &contract.name, - "newPhase": &updated_contract.phase, - }), - ).await; - - // Get summary with counts - match repository::get_contract_summary_for_owner(pool, updated_contract.id, auth.owner_id).await - { - Ok(Some(summary)) => Json(summary).into_response(), - _ => Json(ContractSummary { - id: updated_contract.id, - name: updated_contract.name, - description: updated_contract.description, - contract_type: updated_contract.contract_type, - phase: updated_contract.phase, - status: updated_contract.status, - supervisor_task_id: updated_contract.supervisor_task_id, - local_only: updated_contract.local_only, - auto_merge_local: updated_contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: updated_contract.version, - created_at: updated_contract.created_at, - }) - .into_response(), - } - } - Ok(PhaseChangeResult::VersionConflict { expected, actual, current_phase }) => { - tracing::info!( - contract_id = %id, - expected_version = expected, - actual_version = actual, - current_phase = %current_phase, - "Phase change failed due to version conflict" - ); - ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "code": "VERSION_CONFLICT", - "message": "Phase change failed due to concurrent modification", - "details": { - "expected_version": expected, - "actual_version": actual, - "current_phase": current_phase - } - })), - ) - .into_response() - } - Ok(PhaseChangeResult::ValidationFailed { reason, missing_requirements }) => { - tracing::warn!( - contract_id = %id, - reason = %reason, - "Phase change validation failed" - ); - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "code": "VALIDATION_FAILED", - "message": reason, - "details": { - "missing_requirements": missing_requirements - } - })), - ) - .into_response() - } - Ok(PhaseChangeResult::NotFound) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Ok(PhaseChangeResult::Unauthorized) => ( - StatusCode::UNAUTHORIZED, - Json(ApiError::new("UNAUTHORIZED", "Not authorized to change this contract's phase")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to change phase for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Deliverables -// ============================================================================= - -/// Request body for marking a deliverable complete -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MarkDeliverableRequest { - /// The deliverable ID to mark as complete (e.g., 'plan-document', 'pull-request') - pub deliverable_id: String, - /// Phase the deliverable belongs to. Defaults to current contract phase if not specified. - pub phase: Option<String>, -} - -/// Mark a deliverable as complete for a contract phase. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/deliverables/complete", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = MarkDeliverableRequest, - responses( - (status = 200, description = "Deliverable marked complete", body = serde_json::Value), - (status = 400, description = "Invalid deliverable ID", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn mark_deliverable_complete( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<MarkDeliverableRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Use specified phase or default to current contract phase - let target_phase = req.phase.unwrap_or_else(|| contract.phase.clone()); - - // Validate the deliverable ID exists for this phase/contract type - // Use custom phase_config if present, otherwise fall back to built-in contract types - let phase_config = contract.get_phase_config(); - let phase_deliverables = crate::llm::get_phase_deliverables_with_config( - &target_phase, - &contract.contract_type, - phase_config.as_ref(), - ); - - // Validate deliverable exists - if let Err(validation_error) = validate_deliverable(&req.deliverable_id, &phase_deliverables) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "code": "INVALID_DELIVERABLE", - "message": validation_error.message, - })), - ) - .into_response(); - } - - // Check if already completed - if contract.is_deliverable_complete(&target_phase, &req.deliverable_id) { - return Json(serde_json::json!({ - "success": true, - "message": format!("Deliverable '{}' is already marked complete for {} phase", req.deliverable_id, target_phase), - "deliverableId": req.deliverable_id, - "phase": target_phase, - "alreadyComplete": true, - })) - .into_response(); - } - - // Mark the deliverable as complete - match repository::mark_deliverable_complete(pool, id, &target_phase, &req.deliverable_id).await { - Ok(updated_contract) => { - let completed = updated_contract.get_completed_deliverables(&target_phase); - Json(serde_json::json!({ - "success": true, - "message": format!("Marked deliverable '{}' as complete for {} phase", req.deliverable_id, target_phase), - "deliverableId": req.deliverable_id, - "phase": target_phase, - "completedDeliverables": completed, - })) - .into_response() - } - Err(e) => { - tracing::error!("Failed to mark deliverable complete for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Events -// ============================================================================= - -/// Get contract event history. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/events", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Event history", body = Vec<crate::db::models::ContractEvent>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_events( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_contract_events(pool, id).await { - Ok(events) => Json(events).into_response(), - Err(e) => { - tracing::error!("Failed to get events for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Internal Helper Functions -// ============================================================================= - -/// Clean up all worktrees for tasks in a contract. -/// -/// This is called when a contract is completed or deleted to remove -/// all associated task worktrees from connected daemons. -async fn cleanup_contract_worktrees( - pool: &sqlx::PgPool, - state: &SharedState, - contract_id: Uuid, -) { - tracing::info!( - contract_id = %contract_id, - "Cleaning up worktrees for contract tasks" - ); - - // Get all tasks with worktree info for this contract - let tasks = match repository::list_contract_tasks_with_worktree_info(pool, contract_id).await { - Ok(tasks) => tasks, - Err(e) => { - tracing::error!( - contract_id = %contract_id, - error = %e, - "Failed to list tasks for worktree cleanup" - ); - return; - } - }; - - if tasks.is_empty() { - tracing::debug!( - contract_id = %contract_id, - "No tasks with worktrees to clean up" - ); - return; - } - - tracing::info!( - contract_id = %contract_id, - task_count = tasks.len(), - "Found tasks with worktrees to clean up" - ); - - // Send cleanup command to each task's daemon - // Skip tasks that share a supervisor's worktree (they don't own the worktree) - for task in tasks { - // Skip tasks that reuse the supervisor's worktree - the supervisor owns it - if task.supervisor_worktree_task_id.is_some() { - tracing::debug!( - task_id = %task.id, - supervisor_worktree_task_id = ?task.supervisor_worktree_task_id, - contract_id = %contract_id, - "Task shares supervisor worktree, skipping worktree cleanup" - ); - continue; - } - - if let Some(daemon_id) = task.daemon_id { - let cmd = crate::server::state::DaemonCommand::CleanupWorktree { - task_id: task.id, - delete_branch: true, // Delete the branch when contract is done - }; - - match state.send_daemon_command(daemon_id, cmd).await { - Ok(()) => { - tracing::info!( - task_id = %task.id, - daemon_id = %daemon_id, - contract_id = %contract_id, - "Sent worktree cleanup command" - ); - } - Err(e) => { - tracing::warn!( - task_id = %task.id, - daemon_id = %daemon_id, - contract_id = %contract_id, - error = %e, - "Failed to send worktree cleanup command (daemon may be offline)" - ); - } - } - } else { - tracing::debug!( - task_id = %task.id, - contract_id = %contract_id, - "Task has no daemon assigned, skipping worktree cleanup" - ); - } - } -} - -// ============================================================================= -// Supervisor Status API -// ============================================================================= - -/// Query parameters for supervisor heartbeat history -#[derive(Debug, Deserialize)] -pub struct HeartbeatHistoryQuery { - /// Maximum number of heartbeats to return (default: 10) - pub limit: Option<i32>, - /// Offset for pagination (default: 0) - pub offset: Option<i32>, -} - -/// Get supervisor status for a contract. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/status", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor status", body = crate::db::models::SupervisorStatusResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or supervisor not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_supervisor_status( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if contract has a supervisor - let supervisor_task_id = match contract.supervisor_task_id { - Some(task_id) => task_id, - None => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), - ) - .into_response(); - } - }; - - // Get supervisor status from supervisor_states table - match repository::get_supervisor_status(pool, id, auth.owner_id).await { - Ok(Some(status_info)) => { - // Determine if supervisor is actively running - let is_running = status_info.is_running && status_info.task_status == "running"; - - let response = crate::db::models::SupervisorStatusResponse { - task_id: status_info.task_id, - state: status_info.supervisor_state, - phase: status_info.phase, - current_activity: status_info.current_activity, - progress: None, // We don't track progress percentage yet - last_heartbeat: status_info.last_heartbeat, - pending_task_ids: status_info.pending_task_ids, - is_running, - }; - Json(response).into_response() - } - Ok(None) => { - // No supervisor state record exists, but supervisor task might exist - // Try to get info from the task itself - match repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - Ok(Some(task)) => { - let is_running = task.daemon_id.is_some() && task.status == "running"; - let response = crate::db::models::SupervisorStatusResponse { - task_id: task.id, - state: task.status.clone(), - phase: contract.phase.clone(), - current_activity: task.progress_summary.clone(), - progress: None, - last_heartbeat: task.updated_at, - pending_task_ids: Vec::new(), - is_running, - }; - Json(response).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "Supervisor task not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get supervisor task {}: {}", supervisor_task_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } - } - Err(e) => { - tracing::error!("Failed to get supervisor status for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get supervisor heartbeat history for a contract. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/heartbeats", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("limit" = Option<i32>, Query, description = "Maximum number of heartbeats to return (default: 10)"), - ("offset" = Option<i32>, Query, description = "Offset for pagination (default: 0)") - ), - responses( - (status = 200, description = "Supervisor heartbeat history", body = crate::db::models::SupervisorHeartbeatHistoryResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_supervisor_heartbeats( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - axum::extract::Query(query): axum::extract::Query<HeartbeatHistoryQuery>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - let limit = query.limit.unwrap_or(10).min(100); // Cap at 100 - let offset = query.offset.unwrap_or(0); - - // Get activity history as heartbeats - let activities = match repository::get_supervisor_activity_history(pool, id, limit, offset).await { - Ok(activities) => activities, - Err(e) => { - tracing::error!("Failed to get supervisor heartbeats for contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get total count for pagination - let total = match repository::count_supervisor_activity_history(pool, id).await { - Ok(count) => count, - Err(e) => { - tracing::warn!("Failed to count supervisor heartbeats: {}", e); - activities.len() as i64 - } - }; - - // Convert to heartbeat entries - let heartbeats: Vec<crate::db::models::SupervisorHeartbeatEntry> = activities - .into_iter() - .map(|a| crate::db::models::SupervisorHeartbeatEntry { - timestamp: a.timestamp, - state: a.state, - activity: a.activity, - progress: a.progress.map(|p| p as u8), - phase: a.phase, - pending_task_ids: a.pending_task_ids, - }) - .collect(); - - Json(crate::db::models::SupervisorHeartbeatHistoryResponse { - heartbeats, - total, - }) - .into_response() -} - -/// Sync supervisor state (refresh last_activity timestamp). -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/supervisor/sync", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor synced", body = crate::db::models::SupervisorSyncResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or supervisor not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn sync_supervisor( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if contract has a supervisor - if contract.supervisor_task_id.is_none() { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), - ) - .into_response(); - } - - // Sync supervisor state (update last_activity) - match repository::sync_supervisor_state(pool, id).await { - Ok(Some(_state)) => { - // Get task status to determine current state - let task_status = if let Some(task_id) = contract.supervisor_task_id { - match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { - Ok(Some(task)) => task.status, - _ => "unknown".to_string(), - } - } else { - "unknown".to_string() - }; - - Json(crate::db::models::SupervisorSyncResponse { - synced: true, - state: task_status, - message: Some("Supervisor state synced successfully".to_string()), - }) - .into_response() - } - Ok(None) => { - // No supervisor state exists, return not found - ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor state found for this contract")), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to sync supervisor state for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - use crate::db::models::{DeliverableDefinition, PhaseConfig, PhaseDefinition}; - use crate::llm::{get_phase_deliverables_for_type, get_phase_deliverables_with_config}; - use std::collections::HashMap; - - #[test] - fn test_validate_deliverable_valid_simple_plan() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("plan-document", &phase_deliverables); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_deliverable_valid_simple_execute() { - let phase_deliverables = get_phase_deliverables_for_type("execute", "simple"); - let result = validate_deliverable("pull-request", &phase_deliverables); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_deliverable_invalid_id() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("nonexistent-deliverable", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Invalid deliverable")); - assert!(err.message.contains("nonexistent-deliverable")); - assert!(err.message.contains("plan-document")); - } - - #[test] - fn test_validate_deliverable_specification_phases() { - // Research phase - let phase_deliverables = get_phase_deliverables_for_type("research", "specification"); - assert!(validate_deliverable("research-notes", &phase_deliverables).is_ok()); - assert!(validate_deliverable("invalid", &phase_deliverables).is_err()); - - // Specify phase - let phase_deliverables = get_phase_deliverables_for_type("specify", "specification"); - assert!(validate_deliverable("requirements-document", &phase_deliverables).is_ok()); - assert!(validate_deliverable("plan-document", &phase_deliverables).is_err()); - - // Review phase - let phase_deliverables = get_phase_deliverables_for_type("review", "specification"); - assert!(validate_deliverable("release-notes", &phase_deliverables).is_ok()); - } - - #[test] - fn test_validate_deliverable_execute_type_no_deliverables() { - // Execute-only contracts have no deliverables - let phase_deliverables = get_phase_deliverables_for_type("execute", "execute"); - // Any deliverable should fail since there are none - let result = validate_deliverable("pull-request", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Valid IDs: []")); - } - - #[test] - fn test_validate_deliverable_with_custom_phase_config() { - // Create a custom phase config - let mut deliverables = HashMap::new(); - deliverables.insert( - "design".to_string(), - vec![ - DeliverableDefinition { - id: "architecture-doc".to_string(), - name: "Architecture Document".to_string(), - priority: "required".to_string(), - }, - DeliverableDefinition { - id: "api-spec".to_string(), - name: "API Specification".to_string(), - priority: "recommended".to_string(), - }, - ], - ); - - let phase_config = PhaseConfig { - phases: vec![ - PhaseDefinition { - id: "design".to_string(), - name: "Design".to_string(), - order: 0, - }, - PhaseDefinition { - id: "build".to_string(), - name: "Build".to_string(), - order: 1, - }, - ], - default_phase: "design".to_string(), - deliverables, - }; - - // Validate against custom config - let phase_deliverables = - get_phase_deliverables_with_config("design", "custom", Some(&phase_config)); - - // Valid custom deliverables - assert!(validate_deliverable("architecture-doc", &phase_deliverables).is_ok()); - assert!(validate_deliverable("api-spec", &phase_deliverables).is_ok()); - - // Invalid deliverable for custom config - let result = validate_deliverable("plan-document", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Invalid deliverable")); - assert!(err.message.contains("plan-document")); - assert!(err.message.contains("architecture-doc")); - assert!(err.message.contains("api-spec")); - } - - #[test] - fn test_validate_deliverable_error_message_format() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("xyz", &phase_deliverables); - let err = result.unwrap_err(); - - // Check error message format matches the specification - assert!(err.message.contains("Invalid deliverable 'xyz'")); - assert!(err.message.contains("plan phase")); - assert!(err.message.contains("Valid IDs:")); - assert!(err.message.contains("plan-document")); - } - - #[test] - fn test_deliverable_validation_error_display() { - let err = DeliverableValidationError::new("Test error message"); - assert_eq!(format!("{}", err), "Test error message"); - } - - #[test] - fn test_validate_deliverable_unknown_phase() { - // Unknown phase should return empty deliverables - let phase_deliverables = get_phase_deliverables_for_type("unknown", "simple"); - let result = validate_deliverable("any-id", &phase_deliverables); - assert!(result.is_err()); - } -} diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs index ac5652a..63b1827 100644 --- a/makima/src/server/handlers/mesh.rs +++ b/makima/src/server/handlers/mesh.rs @@ -122,7 +122,11 @@ pub async fn list_tasks( }; let result = if query.orphan { - repository::list_orphan_tasks_for_owner(pool, auth.owner_id).await + // Backed by the per-owner tmp directive going forward — see + // `list_tmp_tasks_for_owner` for the semantics. The query parameter + // name (`?orphan=true`) is preserved for backwards compatibility + // with existing frontend callers. + repository::list_tmp_tasks_for_owner(pool, auth.owner_id).await } else { repository::list_tasks_for_owner(pool, auth.owner_id).await }; @@ -228,7 +232,7 @@ pub async fn get_task( pub async fn create_task( State(state): State<SharedState>, Authenticated(auth): Authenticated, - Json(req): Json<CreateTaskRequest>, + Json(mut req): Json<CreateTaskRequest>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( @@ -238,6 +242,32 @@ pub async fn create_task( .into_response(); }; + // Every top-level task must live under SOME directive going forward — + // the unified directive surface is the only way users see tasks. If a + // caller doesn't supply directive_id, attach to the owner's tmp + // (scratchpad) directive, auto-creating it if needed. Subtasks + // (parent_task_id set) inherit their parent's directive linkage and + // are fine without an explicit directive_id. + if req.directive_id.is_none() && req.parent_task_id.is_none() { + match repository::get_or_create_tmp_directive(pool, auth.owner_id).await { + Ok(tmp) => { + req.directive_id = Some(tmp.id); + } + Err(e) => { + tracing::error!( + owner_id = %auth.owner_id, + error = %e, + "Failed to provision tmp directive for orphan task" + ); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("TMP_PROVISION_FAILED", &e.to_string())), + ) + .into_response(); + } + } + } + match repository::create_task_for_owner(pool, auth.owner_id, req).await { Ok(task) => { // Record history event for task creation diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 4bdb424..c761dcc 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -1,12 +1,11 @@ //! HTTP and WebSocket request handlers. +//! +//! Phase 5 removed: contract_chat, contract_daemon, contract_discuss, +//! contracts, transcript_analysis. Contracts subsystem is gone. pub mod api_keys; pub mod chat; -pub mod contract_chat; -pub mod contract_daemon; -pub mod contract_discuss; pub mod daemon_download; -pub mod contracts; pub mod directives; pub mod file_ws; pub mod files; @@ -23,6 +22,5 @@ pub mod repository_history; pub mod speak; pub mod templates; pub mod voice; -pub mod transcript_analysis; pub mod users; pub mod versions; diff --git a/makima/src/server/handlers/transcript_analysis.rs b/makima/src/server/handlers/transcript_analysis.rs deleted file mode 100644 index 9261c0c..0000000 --- a/makima/src/server/handlers/transcript_analysis.rs +++ /dev/null @@ -1,690 +0,0 @@ -//! HTTP handlers for transcript analysis and contract integration. - -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models, repository}; -use crate::llm::transcript_analyzer::{ - TranscriptAnalysisResult, build_analysis_prompt, calculate_speaker_stats, - format_transcript_for_analysis, parse_analysis_response, -}; -use crate::llm::claude::{ClaudeClient, ClaudeModel, Message, MessageContent}; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Request/Response Types -// ============================================================================= - -/// Request to analyze a file's transcript -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AnalyzeTranscriptRequest { - /// File ID containing the transcript to analyze - pub file_id: Uuid, -} - -/// Response from transcript analysis -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AnalyzeTranscriptResponse { - pub file_id: Uuid, - pub analysis: TranscriptAnalysisResult, -} - -/// Request to create a contract from analysis -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateContractFromAnalysisRequest { - /// File ID containing the analyzed transcript - pub file_id: Uuid, - /// Override the suggested name (optional) - pub name: Option<String>, - /// Override the suggested description (optional) - pub description: Option<String>, - /// Include requirements as file content (default: true) - #[serde(default = "default_true")] - pub include_requirements: bool, - /// Include decisions as file content (default: true) - #[serde(default = "default_true")] - pub include_decisions: bool, - /// Include action items as tasks (default: true) - #[serde(default = "default_true")] - pub include_action_items: bool, -} - -fn default_true() -> bool { - true -} - -/// Response from creating contract from analysis -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateContractFromAnalysisResponse { - pub contract_id: Uuid, - pub contract_name: String, - pub files_created: Vec<FileCreatedInfo>, - pub tasks_created: Vec<TaskCreatedInfo>, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct FileCreatedInfo { - pub id: Uuid, - pub name: String, - pub file_type: String, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct TaskCreatedInfo { - pub id: Uuid, - pub name: String, -} - -/// Request to update an existing contract from analysis -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateContractFromAnalysisRequest { - /// File ID containing the transcript - pub file_id: Uuid, - /// Contract ID to update - pub contract_id: Uuid, - /// Add requirements to contract files - #[serde(default = "default_true")] - pub add_requirements: bool, - /// Add decisions to contract files - #[serde(default = "default_true")] - pub add_decisions: bool, - /// Create tasks from action items - #[serde(default = "default_true")] - pub create_tasks: bool, -} - -/// Response from updating contract -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct UpdateContractFromAnalysisResponse { - pub contract_id: Uuid, - pub files_updated: Vec<Uuid>, - pub tasks_created: Vec<TaskCreatedInfo>, - pub analysis_summary: String, -} - -// ============================================================================= -// Handlers -// ============================================================================= - -/// Analyze a file's transcript to extract requirements, decisions, and action items. -#[utoipa::path( - post, - path = "/api/v1/listen/analyze", - request_body = AnalyzeTranscriptRequest, - responses( - (status = 200, description = "Transcript analyzed", body = AnalyzeTranscriptResponse), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "File not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Listen" -)] -pub async fn analyze_transcript( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<AnalyzeTranscriptRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ).into_response(); - }; - - // Get the file - let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - // Check if transcript is empty - if file.transcript.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript to analyze")), - ).into_response(); - } - - // Analyze the transcript - match analyze_transcript_internal(&file.transcript).await { - Ok(analysis) => { - Json(AnalyzeTranscriptResponse { - file_id: request.file_id, - analysis, - }).into_response() - } - Err(e) => { - tracing::error!(error = %e, "Failed to analyze transcript"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("ANALYSIS_ERROR", e)), - ).into_response() - } - } -} - -/// Create a new contract from an analyzed transcript. -#[utoipa::path( - post, - path = "/api/v1/listen/create-contract", - request_body = CreateContractFromAnalysisRequest, - responses( - (status = 201, description = "Contract created", body = CreateContractFromAnalysisResponse), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "File not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Listen" -)] -pub async fn create_contract_from_analysis( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<CreateContractFromAnalysisRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ).into_response(); - }; - - // Get the file with transcript - let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - if file.transcript.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")), - ).into_response(); - } - - // Analyze transcript - let analysis = match analyze_transcript_internal(&file.transcript).await { - Ok(a) => a, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("ANALYSIS_ERROR", e)), - ).into_response(); - } - }; - - // Determine contract name and description - let contract_name = request.name - .or(analysis.suggested_contract_name.clone()) - .unwrap_or_else(|| format!("Contract from {}", file.name)); - let contract_description = request.description - .or(analysis.suggested_description.clone()); - - // Create the contract - let contract_req = models::CreateContractRequest { - name: contract_name.clone(), - description: contract_description, - contract_type: Some("specification".to_string()), - initial_phase: Some("research".to_string()), - autonomous_loop: None, - phase_guard: None, - local_only: None, - auto_merge_local: None, - template_id: None, - }; - - let contract = match repository::create_contract_for_owner(pool, auth.owner_id, contract_req).await { - Ok(c) => c, - Err(e) => { - tracing::error!(error = %e, "Failed to create contract"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - let mut files_created: Vec<FileCreatedInfo> = Vec::new(); - let mut tasks_created: Vec<TaskCreatedInfo> = Vec::new(); - - // Create requirements file if we have requirements - if request.include_requirements && !analysis.requirements.is_empty() { - let body = build_requirements_body(&analysis.requirements); - let file_req = models::CreateFileRequest { - contract_id: contract.id, - name: Some("Requirements from Transcript".to_string()), - description: Some("Requirements extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_created.push(FileCreatedInfo { - id: f.id, - name: f.name, - file_type: "requirements".to_string(), - }); - } - } - - // Create decisions file if we have decisions - if request.include_decisions && !analysis.decisions.is_empty() { - let body = build_decisions_body(&analysis.decisions); - let file_req = models::CreateFileRequest { - contract_id: contract.id, - name: Some("Decisions from Transcript".to_string()), - description: Some("Decisions extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: Some("research".to_string()), - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_created.push(FileCreatedInfo { - id: f.id, - name: f.name, - file_type: "decisions".to_string(), - }); - } - } - - // Create tasks from action items - if request.include_action_items && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = models::CreateTaskRequest { - contract_id: Some(contract.id), - name: truncate_for_name(&item.text, 100), - description: Some(format!("Action item from transcript (Speaker: {})", item.speaker)), - plan: item.text.clone(), - repository_url: None, - base_branch: None, - target_branch: None, - parent_task_id: None, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - priority: match item.priority.as_deref() { - Some("high") => 10, - Some("medium") => 5, - _ => 0, - }, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { - tasks_created.push(TaskCreatedInfo { - id: t.id, - name: t.name, - }); - } - } - } - - ( - StatusCode::CREATED, - Json(CreateContractFromAnalysisResponse { - contract_id: contract.id, - contract_name, - files_created, - tasks_created, - }), - ).into_response() -} - -/// Update an existing contract with information from transcript analysis. -#[utoipa::path( - post, - path = "/api/v1/listen/update-contract", - request_body = UpdateContractFromAnalysisRequest, - responses( - (status = 200, description = "Contract updated", body = UpdateContractFromAnalysisResponse), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "File or contract not found"), - (status = 500, description = "Internal server error"), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Listen" -)] -pub async fn update_contract_from_analysis( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<UpdateContractFromAnalysisRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ).into_response(); - }; - - // Get the file - let file = match repository::get_file_for_owner(pool, request.file_id, auth.owner_id).await { - Ok(Some(f)) => f, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "File not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get file"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - // Verify contract exists - let _contract = match repository::get_contract_for_owner(pool, request.contract_id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ).into_response(); - } - Err(e) => { - tracing::error!(error = %e, "Failed to get contract"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ).into_response(); - } - }; - - if file.transcript.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(ApiError::new("NO_TRANSCRIPT", "File has no transcript")), - ).into_response(); - } - - // Analyze transcript - let analysis = match analyze_transcript_internal(&file.transcript).await { - Ok(a) => a, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("ANALYSIS_ERROR", e)), - ).into_response(); - } - }; - - let mut files_updated: Vec<Uuid> = Vec::new(); - let mut tasks_created: Vec<TaskCreatedInfo> = Vec::new(); - - // Create or update requirements file - if request.add_requirements && !analysis.requirements.is_empty() { - let body = build_requirements_body(&analysis.requirements); - let file_req = models::CreateFileRequest { - contract_id: request.contract_id, - name: Some(format!("Requirements from {}", file.name)), - description: Some("Requirements extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: None, - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_updated.push(f.id); - } - } - - // Create or update decisions file - if request.add_decisions && !analysis.decisions.is_empty() { - let body = build_decisions_body(&analysis.decisions); - let file_req = models::CreateFileRequest { - contract_id: request.contract_id, - name: Some(format!("Decisions from {}", file.name)), - description: Some("Decisions extracted from voice transcript".to_string()), - transcript: vec![], - location: None, - body, - repo_file_path: None, - contract_phase: None, - }; - - if let Ok(f) = repository::create_file_for_owner(pool, auth.owner_id, file_req).await { - files_updated.push(f.id); - } - } - - // Create tasks from action items - if request.create_tasks && !analysis.action_items.is_empty() { - for item in &analysis.action_items { - let task_req = models::CreateTaskRequest { - contract_id: Some(request.contract_id), - name: truncate_for_name(&item.text, 100), - description: Some(format!("Action item from {} (Speaker: {})", file.name, item.speaker)), - plan: item.text.clone(), - repository_url: None, - base_branch: None, - target_branch: None, - parent_task_id: None, - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: false, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Not spawned by supervisor - directive_id: None, - directive_step_id: None, - }; - - if let Ok(t) = repository::create_task_for_owner(pool, auth.owner_id, task_req).await { - tasks_created.push(TaskCreatedInfo { - id: t.id, - name: t.name, - }); - } - } - } - - let summary = format!( - "Extracted {} requirements, {} decisions, {} action items from transcript", - analysis.requirements.len(), - analysis.decisions.len(), - analysis.action_items.len() - ); - - Json(UpdateContractFromAnalysisResponse { - contract_id: request.contract_id, - files_updated, - tasks_created, - analysis_summary: summary, - }).into_response() -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -/// Analyze transcript using Claude -async fn analyze_transcript_internal( - transcript: &[models::TranscriptEntry], -) -> Result<TranscriptAnalysisResult, String> { - let transcript_text = format_transcript_for_analysis(transcript); - let speaker_stats = calculate_speaker_stats(transcript); - let prompt = build_analysis_prompt(&transcript_text); - - // Create Claude client - let client = ClaudeClient::from_env(ClaudeModel::Sonnet) - .map_err(|e| format!("Failed to create Claude client: {}", e))?; - - // Call Claude API with empty tools to make a simple chat call - let messages = vec![Message { - role: "user".to_string(), - content: MessageContent::Text(prompt), - }]; - - let result = client.chat_with_tools(messages, &[]).await - .map_err(|e| format!("Claude API error: {}", e))?; - - // Parse the response - let content = result.content.ok_or_else(|| "No response content from Claude".to_string())?; - parse_analysis_response(&content, speaker_stats) -} - -/// Build file body elements from requirements -fn build_requirements_body(requirements: &[crate::llm::transcript_analyzer::ExtractedRequirement]) -> Vec<models::BodyElement> { - let mut body = vec![ - models::BodyElement::Heading { - level: 1, - text: "Requirements".to_string(), - }, - ]; - - // Group by category if available - let mut functional = Vec::new(); - let mut technical = Vec::new(); - let mut other = Vec::new(); - - for req in requirements { - match req.category.as_deref() { - Some("functional") => functional.push(req), - Some("technical") => technical.push(req), - _ => other.push(req), - } - } - - if !functional.is_empty() { - body.push(models::BodyElement::Heading { - level: 2, - text: "Functional Requirements".to_string(), - }); - body.push(models::BodyElement::List { - ordered: false, - items: functional.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), - }); - } - - if !technical.is_empty() { - body.push(models::BodyElement::Heading { - level: 2, - text: "Technical Requirements".to_string(), - }); - body.push(models::BodyElement::List { - ordered: false, - items: technical.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), - }); - } - - if !other.is_empty() { - body.push(models::BodyElement::Heading { - level: 2, - text: "Other Requirements".to_string(), - }); - body.push(models::BodyElement::List { - ordered: false, - items: other.iter().map(|r| format!("{} ({})", r.text, r.speaker)).collect(), - }); - } - - body -} - -/// Build file body elements from decisions -fn build_decisions_body(decisions: &[crate::llm::transcript_analyzer::ExtractedDecision]) -> Vec<models::BodyElement> { - let mut body = vec![ - models::BodyElement::Heading { - level: 1, - text: "Decisions".to_string(), - }, - ]; - - let items: Vec<String> = decisions.iter().map(|d| { - let context = d.context.as_ref().map(|c| format!(" (Context: {})", c)).unwrap_or_default(); - format!("**{}**: {}{}", d.speaker, d.text, context) - }).collect(); - - body.push(models::BodyElement::List { - ordered: true, - items, - }); - - body -} - -/// Truncate text to fit as a task name -fn truncate_for_name(text: &str, max_len: usize) -> String { - if text.len() <= max_len { - text.to_string() - } else { - format!("{}...", &text[..max_len - 3]) - } -} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index efae901..59eff2e 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -45,10 +45,8 @@ pub fn make_router(state: SharedState) -> Router { let api_v1 = Router::new() .route("/listen", get(listen::websocket_handler)) .route("/speak", get(speak::websocket_handler)) - // Listen/transcript analysis endpoints - .route("/listen/analyze", post(transcript_analysis::analyze_transcript)) - .route("/listen/create-contract", post(transcript_analysis::create_contract_from_analysis)) - .route("/listen/update-contract", post(transcript_analysis::update_contract_from_analysis)) + // Listen/transcript-analysis endpoints removed in Phase 5 with the + // contracts subsystem. .route("/files/subscribe", get(file_ws::file_subscription_handler)) .route("/files", get(files::list_files).post(files::create_file)) .route( @@ -167,68 +165,9 @@ pub fn make_router(state: SharedState) -> Router { get(users::get_user_settings_handler) .put(users::update_user_settings_handler), ) - // Contract endpoints - .route("/contracts/discuss", post(contract_discuss::discuss_contract_handler)) - .route( - "/contracts", - get(contracts::list_contracts).post(contracts::create_contract), - ) - .route( - "/contracts/{id}", - get(contracts::get_contract) - .put(contracts::update_contract) - .delete(contracts::delete_contract), - ) - .route("/contracts/{id}/phase", post(contracts::change_phase)) - .route("/contracts/{id}/deliverables/complete", post(contracts::mark_deliverable_complete)) - .route("/contracts/{id}/events", get(contracts::get_events)) - .route("/contracts/{id}/chat", post(contract_chat::contract_chat_handler)) - .route( - "/contracts/{id}/chat/history", - get(contract_chat::get_contract_chat_history).delete(contract_chat::clear_contract_chat_history), - ) - // Contract supervisor resume endpoints - .route("/contracts/{id}/supervisor/resume", post(mesh_supervisor::resume_supervisor)) - .route("/contracts/{id}/supervisor/conversation/rewind", post(mesh_supervisor::rewind_conversation)) - // Contract supervisor status endpoints - .route("/contracts/{id}/supervisor/status", get(contracts::get_supervisor_status)) - .route("/contracts/{id}/supervisor/heartbeats", get(contracts::get_supervisor_heartbeats)) - .route("/contracts/{id}/supervisor/sync", post(contracts::sync_supervisor)) - // History endpoints - .route("/contracts/{id}/history", get(history::get_contract_history)) - .route("/contracts/{id}/supervisor/conversation", get(history::get_supervisor_conversation)) - // Contract daemon endpoints (for tasks to interact with contracts) - .route("/contracts/{id}/daemon/status", get(contract_daemon::get_contract_status)) - .route("/contracts/{id}/daemon/checklist", get(contract_daemon::get_contract_checklist)) - .route("/contracts/{id}/daemon/goals", get(contract_daemon::get_contract_goals)) - .route("/contracts/{id}/daemon/report", post(contract_daemon::post_progress_report)) - .route("/contracts/{id}/daemon/suggest-action", post(contract_daemon::get_suggest_action)) - .route("/contracts/{id}/daemon/completion-action", post(contract_daemon::get_completion_action)) - .route( - "/contracts/{id}/daemon/files", - get(contract_daemon::list_contract_files).post(contract_daemon::create_contract_file), - ) - .route( - "/contracts/{id}/daemon/files/{file_id}", - get(contract_daemon::get_contract_file).put(contract_daemon::update_contract_file), - ) - // Contract repository endpoints - .route("/contracts/{id}/repositories/remote", post(contracts::add_remote_repository)) - .route("/contracts/{id}/repositories/local", post(contracts::add_local_repository)) - .route("/contracts/{id}/repositories/managed", post(contracts::create_managed_repository)) - .route( - "/contracts/{id}/repositories/{repo_id}", - axum::routing::delete(contracts::delete_repository), - ) - .route( - "/contracts/{id}/repositories/{repo_id}/primary", - axum::routing::put(contracts::set_repository_primary), - ) - // Contract task association endpoints - .route( - "/contracts/{id}/tasks/{task_id}", - post(contracts::add_task_to_contract).delete(contracts::remove_task_from_contract), - ) + // Contract endpoints removed in Phase 5. The contracts subsystem + // has been folded into directives — see Phase 5 in the unified + // surface plan. Routes are gone; handler files were deleted. // Directive endpoints .route( "/directives", diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index 7a4b004..51a1c0d 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -31,7 +31,7 @@ use crate::server::auth::{ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse, RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, }; -use crate::server::handlers::{api_keys, contract_chat, contract_discuss, contracts, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; +use crate::server::handlers::{api_keys, directives, files, listen, mesh, mesh_chat, mesh_merge, orders, repository_history, users}; use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage}; #[derive(OpenApi)] @@ -92,27 +92,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage users::delete_account_handler, users::get_user_settings_handler, users::update_user_settings_handler, - // Contract endpoints - contracts::list_contracts, - contracts::get_contract, - contracts::create_contract, - contracts::update_contract, - contracts::delete_contract, - contracts::change_phase, - contracts::get_events, - contracts::add_remote_repository, - contracts::add_local_repository, - contracts::create_managed_repository, - contracts::delete_repository, - contracts::set_repository_primary, - contracts::add_task_to_contract, - contracts::remove_task_from_contract, - // Contract chat endpoints - contract_chat::contract_chat_handler, - contract_chat::get_contract_chat_history, - contract_chat::clear_contract_chat_history, - // Contract discuss endpoint - contract_discuss::discuss_contract_handler, + // Contract endpoints removed in Phase 5. // Directive endpoints directives::list_directives, directives::create_directive, @@ -182,15 +162,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage MeshChatConversation, MeshChatMessageRecord, MeshChatHistoryResponse, - // Contract chat schemas - ContractChatMessageRecord, - ContractChatHistoryResponse, - // Contract discuss schemas - contract_discuss::ChatMessage, - contract_discuss::DiscussContractRequest, - contract_discuss::DiscussContractResponse, - contract_discuss::ToolCallInfo, - contract_discuss::CreatedContractInfo, + // Contract chat / discuss schemas removed in Phase 5. // Merge schemas BranchInfo, BranchListResponse, |
