summaryrefslogtreecommitdiff
path: root/makima/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server')
-rw-r--r--makima/src/server/handlers/contract_chat.rs3183
-rw-r--r--makima/src/server/handlers/contract_daemon.rs936
-rw-r--r--makima/src/server/handlers/contract_discuss.rs592
-rw-r--r--makima/src/server/handlers/contracts.rs2376
-rw-r--r--makima/src/server/handlers/mesh.rs34
-rw-r--r--makima/src/server/handlers/mod.rs8
-rw-r--r--makima/src/server/handlers/transcript_analysis.rs690
-rw-r--r--makima/src/server/mod.rs73
-rw-r--r--makima/src/server/openapi.rs34
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,