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