//! 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,
is_red_team: false,
checkpoint_sha: None,
branched_from_task_id: None,
conversation_history: None,
supervisor_worktree_task_id: None, // Not spawned by supervisor
};
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,
is_red_team: false,
checkpoint_sha: None,
branched_from_task_id: None,
conversation_history: None,
supervisor_worktree_task_id: None, // Not spawned by supervisor
};
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 from contract if task has one
let local_only = 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,
_ => false,
}
} else {
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,
supervisor_worktree_task_id: None, // Not spawned by 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 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 pending confirmation with phase deliverables
if !confirmed {
// Get files created in this phase
let phase_files = match repository::list_files_in_contract(pool, contract_id, owner_id).await {
Ok(files) => files
.into_iter()
.filter(|f| f.contract_phase.as_deref() == Some(current_phase))
.map(|f| json!({
"id": f.id,
"name": f.name,
"description": f.description
}))
.collect::<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(),
};
// Build deliverables summary
let deliverables_summary = format!(
"Phase '{}' deliverables: {} files created, {} tasks completed.",
current_phase,
phase_files.len(),
phase_tasks.len()
);
let transition_id = uuid::Uuid::new_v4().to_string();
return ContractRequestResult {
success: true,
message: format!(
"Phase transition to '{}' requires user confirmation. Review the deliverables and call advance_phase again with confirmed=true to proceed, or provide feedback to request changes.",
new_phase
),
data: Some(json!({
"status": "pending_confirmation",
"transitionId": transition_id,
"currentPhase": current_phase,
"nextPhase": new_phase,
"deliverablesSummary": deliverables_summary,
"phaseFiles": phase_files,
"phaseTasks": phase_tasks,
"requiresConfirmation": true,
"instructions": "To proceed: call advance_phase with confirmed=true. To request changes: call advance_phase with feedback='your feedback here'"
})),
};
}
}
// Update phase (either phase_guard is disabled, or user confirmed)
match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await {
Ok(Some(updated)) => {
// Get deliverables for the new phase (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,
is_red_team: false,
checkpoint_sha: None,
branched_from_task_id: None,
conversation_history: None,
supervisor_worktree_task_id: None, // Not spawned by supervisor
};
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,
red_team_enabled: None,
red_team_prompt: 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,
is_red_team: false,
checkpoint_sha: None,
branched_from_task_id: None,
conversation_history: None,
supervisor_worktree_task_id: None, // Not spawned by supervisor
};
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()
}
}
}