summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/contract_chat.rs
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-05-01 23:56:51 +0100
committerGitHub <noreply@github.com>2026-05-01 23:56:51 +0100
commite11759447b1ac00becfb1e979e488f7f9c9cf478 (patch)
treef8a58368de3f6dda3f2f5c1af34e869a0e714205 /makima/src/server/handlers/contract_chat.rs
parent80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (diff)
downloadsoryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.tar.gz
soryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.zip
chore(cleanup): Phase 5 contracts removal + tmp directive + 30-day expiry + scroll fix (#118)
Sweeping cleanup across the surface and the wire. Net: -14k LOC of legacy contracts code, plus the tmp/scroll/UX fixes the user asked for. ## Sidebar/editor independent scroll Replace `height: calc(100vh - 80px)` (which assumed an 80px masthead and quietly clipped or pushed the whole page below the fold when the masthead was taller) with `h-screen + overflow-hidden` on the page root and proper `flex-1 min-h-0` sizing on `<main>`. Sidebar and editor pane now manage their own scroll independently; the page itself never scrolls. Same fix in /tmp/:taskId. ## tmp directive — real backing for orphans/ephemerals New migration `20260501100000_tmp_directive_and_clear_orphans.sql`: * Adds `directives.is_tmp` BOOLEAN NOT NULL DEFAULT false. * Partial unique index `(owner_id) WHERE is_tmp` — at most ONE tmp directive per owner. * Hard-deletes every existing orphan task (`directive_id IS NULL`). Per the user spec: "ALSO there are TOO MANY old tasks in tmp, we need to remove all of them as well." New repository helpers: * `get_or_create_tmp_directive(pool, owner_id) -> Directive` INSERT ON CONFLICT DO NOTHING + fallback SELECT, race-safe. * `list_all_tmp_directives` — drives the expiry sweep. * `delete_expired_tmp_tasks(tmp_directive_id) -> u64`. * `list_tmp_tasks_for_owner` (replaces `list_orphan_tasks_for_owner`). `mesh::create_task`: every top-level task must have a directive. If a caller doesn't supply `directive_id` and isn't a subtask, attach to the caller's tmp directive (auto-creating it on first use). `list_directives_for_owner` filters out `is_tmp=true` so the scratchpad directive doesn't pollute the contract list — surfaced via the sidebar's `tmp/` folder instead. ## 30-day expiry on tmp tasks New `phase_tmp_expiry` in the directive reconciler. Throttled to once per hour: enumerates every tmp directive, calls `delete_expired_tmp_tasks`, logs the count. The actual delete is `WHERE created_at < NOW() - INTERVAL '30 days'` and is fast on the existing index. Subtasks die via FK cascade. ## Phase 5 — contracts removed ### Frontend Deleted entire `/contracts` surface: * routes: `contracts.tsx`, `contract-file.tsx` * components/contracts: ContractList, ContractDetail, ContractCliInput, ContractContextMenu, CommandModePanel, PhaseBadge, PhaseHint, PhaseDeliverablesPanel, PhaseProgressBar, QuickActionButtons, RepositoryPanel, TaskDerivationPreview * (Kept `PhaseConfirmationModal` — used outside the contracts surface by `TaskOutput` and `PhaseConfirmationNotification`.) * Routes deregistered from `main.tsx`; nav entry removed from `NavStrip`. ### Backend handlers Deleted: `contracts.rs` (2.4k LOC), `contract_chat.rs` (3.2k LOC), `contract_daemon.rs` (~940 LOC), `contract_discuss.rs` (~590 LOC), `transcript_analysis.rs` (~690 LOC). All `/api/v1/contracts/*` routes deregistered. OpenAPI entries dropped. Module declarations removed from `server/handlers/mod.rs`. ### CLI Removed `makima contract` and `makima supervisor` subcommands. Deleted `daemon/cli/contract.rs` and `daemon/cli/supervisor.rs`. Bin dispatch trimmed (~377 LOC). ### Orchestrator Removed the contract-spawn path from `phase_execution` (`spawn_step_contract` and its caller). `directive_steps.contract_type` now logs a warning and falls through to standalone-task spawn. Column itself stays — old data still reads, just no longer triggers a contract+supervisor spawn. ### TUI `Action::PerformCreateContract` is now a no-op that surfaces a status message: "Contracts have been removed. Use directives instead." The TUI form is dead code pending a wider refresh. ## Out of scope (deliberately left) * Contracts DB tables (`contracts`, `contract_repositories`, `contract_chat_history`, `contract_events`, `contract_templates`) are retained for historical data + because some peripheral code still joins to them in TaskSummary queries. * `mesh_supervisor` handlers are retained — they aren't only used by contracts (some mesh-level supervisor behaviour persists), and the cross-cutting cleanup is bigger than this PR. * `directive_steps.contract_type` column itself isn't dropped; just no longer functional. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/src/server/handlers/contract_chat.rs')
-rw-r--r--makima/src/server/handlers/contract_chat.rs3183
1 files changed, 0 insertions, 3183 deletions
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs
deleted file mode 100644
index 5d8ab3e..0000000
--- a/makima/src/server/handlers/contract_chat.rs
+++ /dev/null
@@ -1,3183 +0,0 @@
-//! Chat endpoint for LLM-powered contract management.
-//!
-//! This handler provides an agentic loop for managing contracts: creating tasks,
-//! adding files, managing repositories, and handling phase transitions.
-
-use axum::{
- extract::{Path, State},
- http::StatusCode,
- response::IntoResponse,
- Json,
-};
-use serde::{Deserialize, Serialize};
-use serde_json::json;
-use utoipa::ToSchema;
-use uuid::Uuid;
-
-use crate::db::{
- models::{
- ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest,
- },
- repository,
-};
-use crate::llm::{
- analyze_task_output, body_to_markdown, format_checklist_markdown,
- format_parsed_tasks, parse_tasks_from_breakdown,
- claude::{self, ClaudeClient, ClaudeError, ClaudeModel},
- groq::{GroqClient, GroqError, Message, ToolCallResponse},
- parse_contract_tool_call, ContractToolRequest,
- LlmModel, TaskInfo, ToolCall, ToolResult, UserQuestion, CONTRACT_TOOLS,
- format_transcript_for_analysis, calculate_speaker_stats,
- build_analysis_prompt, parse_analysis_response,
-};
-use crate::server::auth::Authenticated;
-use crate::server::state::{DaemonCommand, SharedState};
-
-/// Maximum number of tool-calling rounds to prevent infinite loops
-const MAX_TOOL_ROUNDS: usize = 30;
-
-#[derive(Debug, Clone, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractChatHistoryMessage {
- /// Role: "user" or "assistant"
- pub role: String,
- /// Message content
- pub content: String,
-}
-
-#[derive(Debug, Deserialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractChatRequest {
- /// The user's message/instruction
- pub message: String,
- /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq"
- #[serde(default)]
- pub model: Option<String>,
- /// Optional conversation history for context continuity
- #[serde(default)]
- pub history: Option<Vec<ContractChatHistoryMessage>>,
-}
-
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractChatResponse {
- /// The LLM's response message
- pub response: String,
- /// Tool calls that were executed
- pub tool_calls: Vec<ContractToolCallInfo>,
- /// Questions pending user answers (pauses conversation)
- #[serde(skip_serializing_if = "Option::is_none")]
- pub pending_questions: Option<Vec<UserQuestion>>,
-}
-
-#[derive(Debug, Serialize, ToSchema)]
-#[serde(rename_all = "camelCase")]
-pub struct ContractToolCallInfo {
- pub name: String,
- pub result: ToolResult,
-}
-
-/// Enum to hold LLM clients
-enum LlmClient {
- Groq(GroqClient),
- Claude(ClaudeClient),
-}
-
-/// Unified result from LLM call
-struct LlmResult {
- content: Option<String>,
- tool_calls: Vec<ToolCall>,
- raw_tool_calls: Vec<ToolCallResponse>,
- finish_reason: String,
-}
-
-/// Helper to get contract with all relations
-async fn get_contract_with_relations(
- pool: &sqlx::PgPool,
- contract_id: Uuid,
- owner_id: Uuid,
-) -> Result<Option<ContractWithRelations>, sqlx::Error> {
- let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await? {
- Some(c) => c,
- None => return Ok(None),
- };
-
- let repositories = repository::list_contract_repositories(pool, contract_id)
- .await
- .unwrap_or_default();
-
- let files = repository::list_files_in_contract(pool, contract_id, owner_id)
- .await
- .unwrap_or_default();
-
- let tasks = repository::list_tasks_in_contract(pool, contract_id, owner_id)
- .await
- .unwrap_or_default();
-
- Ok(Some(ContractWithRelations {
- contract,
- repositories,
- files,
- tasks,
- }))
-}
-
-/// Chat with a contract using LLM tool calling for management
-#[utoipa::path(
- post,
- path = "/api/v1/contracts/{id}/chat",
- request_body = ContractChatRequest,
- responses(
- (status = 200, description = "Chat completed successfully", body = ContractChatResponse),
- (status = 401, description = "Unauthorized"),
- (status = 404, description = "Contract not found"),
- (status = 500, description = "Internal server error")
- ),
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn contract_chat_handler(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(contract_id): Path<Uuid>,
- Json(request): Json<ContractChatRequest>,
-) -> impl IntoResponse {
- // Check if database is configured
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "Database not configured" })),
- )
- .into_response();
- };
-
- // Get the contract (scoped by owner)
- let contract = match get_contract_with_relations(pool, contract_id, auth.owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(json!({ "error": "Contract not found" })),
- )
- .into_response();
- }
- Err(e) => {
- tracing::error!("Database error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Database error: {}", e) })),
- )
- .into_response();
- }
- };
-
- // Parse model selection (default to Claude Sonnet)
- let model = request
- .model
- .as_ref()
- .and_then(|m| LlmModel::from_str(m))
- .unwrap_or(LlmModel::ClaudeSonnet);
-
- tracing::info!("Contract chat using LLM model: {:?}", model);
-
- // Initialize the appropriate LLM client
- let llm_client = match model {
- LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) {
- Ok(client) => LlmClient::Claude(client),
- Err(ClaudeError::MissingApiKey) => {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
- )
- .into_response();
- }
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Claude client error: {}", e) })),
- )
- .into_response();
- }
- },
- LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) {
- Ok(client) => LlmClient::Claude(client),
- Err(ClaudeError::MissingApiKey) => {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
- )
- .into_response();
- }
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Claude client error: {}", e) })),
- )
- .into_response();
- }
- },
- LlmModel::GroqKimi => match GroqClient::from_env() {
- Ok(client) => LlmClient::Groq(client),
- Err(GroqError::MissingApiKey) => {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "GROQ_API_KEY not configured" })),
- )
- .into_response();
- }
- Err(e) => {
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Groq client error: {}", e) })),
- )
- .into_response();
- }
- },
- };
-
- // Build contract context
- let contract_context = build_contract_context(&contract);
-
- // Build system prompt for contract management
- let system_prompt = format!(
- r#"You are an intelligent contract management agent. You guide users through the contract lifecycle from research to completion, helping them organize work, create documentation, set up repositories, and execute tasks.
-
-## Your Capabilities
-You have access to tools for:
-- **Query**: get_contract_status, list_contract_files, list_contract_tasks, list_contract_repositories, read_file
-- **File Management**: create_file_from_template, create_empty_file, list_available_templates
-- **Task Management**: create_contract_task, delegate_content_generation, start_task
-- **Phase Management**: get_phase_info, suggest_phase_transition, advance_phase
-- **Repository Management**: list_daemon_directories, add_repository, set_primary_repository
-- **Interactive**: ask_user
-
-## Content Generation Deferral
-When asked to write substantial content, fill templates, or generate documentation:
-- **Use delegate_content_generation** to create a task for the content generation
-- This delegates the work to a task agent that can do more thorough research and writing
-
-**Use delegation for:**
-- Filling in template content with real data
-- Writing documentation based on requirements
-- Generating user stories or specifications
-- Creating detailed design documents
-- Any substantial writing that requires research or analysis
-
-**Direct actions (no delegation needed):**
-- Listing files/tasks/repos
-- Reading files
-- Phase transitions
-- Creating empty files or templates
-- Simple queries and status checks
-- Asking user questions
-
-## Contract Lifecycle Phases
-
-### 1. RESEARCH Phase
-**Purpose**: Gather information and understand the problem space
-**Key Activities**:
-- Conduct user research and interviews
-- Analyze competitors and existing solutions
-- Document findings and insights
-- Identify opportunities and constraints
-**Suggested Actions**:
-- Create a "Research Notes" document to capture findings
-- Create a "Competitor Analysis" document
-- When research is complete, suggest transitioning to Specify phase
-
-### 2. SPECIFY Phase
-**Purpose**: Define what needs to be built
-**Key Activities**:
-- Write clear requirements
-- Create user stories with acceptance criteria
-- Define scope and constraints
-- Document technical constraints
-**Suggested Actions**:
-- Create a "Requirements" document
-- Create "User Stories" with acceptance criteria
-- When specifications are clear, suggest transitioning to Plan phase
-
-### 3. PLAN Phase
-**Purpose**: Design the solution and break down the work
-**Key Activities**:
-- Design system architecture
-- Create technical specifications
-- Break work into implementable tasks
-- Set up repositories for development
-**Suggested Actions**:
-- Create an "Architecture" document
-- Create a "Task Breakdown" document
-- **IMPORTANT**: Help set up a repository if not already configured
-- When planning is complete and a repository is set, suggest transitioning to Execute phase
-
-### 4. EXECUTE Phase
-**Purpose**: Implement the solution
-**Key Activities**:
-- Create and run tasks to implement features
-- Write and run tests
-- Track progress
-- Document implementation decisions
-**Suggested Actions**:
-- Create tasks based on the task breakdown
-- Monitor task progress and help resolve blockers
-- When all tasks are complete, suggest transitioning to Review phase
-
-### 5. REVIEW Phase
-**Purpose**: Validate and document the completed work
-**Key Activities**:
-- Review completed work
-- Create release notes
-- Conduct retrospective
-- Document learnings
-**Suggested Actions**:
-- Create a "Release Notes" document
-- Create a "Retrospective" document
-- Help mark the contract as complete when review is done
-
-## Current Contract
-{contract_context}
-
-## Proactive Guidance
-
-### Repository Setup (Critical for Plan/Execute phases)
-When the user wants to add a local repository or set up for execution:
-1. **First call list_daemon_directories** to get available paths from connected agents
-2. Present the suggested directories to the user
-3. Ask which path they want to use, or let them specify a custom path
-4. Then call add_repository with the chosen path
-
-Example flow:
-```
-User: "Set up a repository for this contract"
-You: Call list_daemon_directories first
-You: "I found these directories from your connected agent:
- - /Users/alice/projects (Working Directory)
- - /Users/alice/.makima/home (Makima Home)
- Which would you like to use, or provide a custom path?"
-```
-
-### Phase Transitions
-- Phases progress in order: research -> specify -> plan -> execute -> review
-- You can ONLY advance forward one step at a time to the NEXT phase
-- ALWAYS use suggest_phase_transition FIRST to get the exact nextPhase value
-- Then use advance_phase with that exact nextPhase value
-- Example: If currentPhase is "specify", nextPhase will be "plan" - use advance_phase with new_phase="plan"
-- NEVER suggest advancing to the same phase the contract is already in
-
-### New Users
-When a new contract is created or the user seems unsure:
-1. Explain the current phase and what should be done
-2. Suggest creating appropriate documents
-3. Guide them toward the next milestone
-
-## Agentic Behavior Guidelines
-
-### 1. Understand Before Acting
-- For complex requests, first gather information about the contract's current state
-- Use get_contract_status or list_contract_files to understand what exists
-- Consider the current phase when suggesting actions
-
-### 2. Phase-Appropriate Suggestions
-- Suggest templates and actions appropriate for the current phase
-- When creating files, prefer templates that match the contract's phase
-- Advise when the contract might be ready for the next phase
-
-### 3. Help Plan Work
-- When asked to plan work, read existing files to understand context
-- Suggest creating tasks based on requirements or plans in files
-- Offer to create task breakdowns from design documents
-
-### 4. Repository Management
-- When adding local repositories, ALWAYS use list_daemon_directories first to get suggestions
-- This provides the user with valid paths from their connected agents
-- Don't ask users to manually type paths when suggestions are available
-
-### 5. Task Creation and Execution
-- When creating tasks, derive plans from existing contract files when possible
-- Use the contract's primary repository for tasks by default
-- Create clear, actionable task plans
-- After creating a task, you can use **start_task** to immediately begin execution
-- A daemon must be connected for start_task to work
-
-### 6. Be Proactive but Efficient
-- Guide users through the contract flow
-- Don't over-analyze simple requests
-- Use the minimum number of tool calls needed
-- Provide clear summaries of actions taken
-
-## Important Notes
-- This contract's ID is: {contract_id}
-- All operations are scoped to this contract
-- When creating tasks or files, they are automatically associated with this contract"#,
- contract_context = contract_context,
- contract_id = contract_id
- );
-
- // Run the agentic loop
- run_contract_agentic_loop(
- pool,
- &state,
- &llm_client,
- system_prompt,
- &request,
- contract_id,
- auth.owner_id,
- )
- .await
-}
-
-fn build_contract_context(contract: &crate::db::models::ContractWithRelations) -> String {
- let c = &contract.contract;
- let mut context = format!(
- "Name: {}\nID: {}\nPhase: {}\nStatus: {}\nContract Type: {}\nAutonomous Loop: {}\n",
- c.name, c.id, c.phase, c.status, c.contract_type, c.autonomous_loop
- );
-
- if let Some(ref desc) = c.description {
- context.push_str(&format!("Description: {}\n", desc));
- }
-
- // Get completed deliverables for the current phase
- let completed_deliverables = c.get_completed_deliverables(&c.phase);
-
- // Build task infos for checklist
- let task_infos: Vec<TaskInfo> = contract.tasks.iter().map(|t| TaskInfo {
- name: t.name.clone(),
- status: t.status.clone(),
- }).collect();
-
- let has_repository = !contract.repositories.is_empty();
- let phase_checklist = crate::llm::get_phase_checklist_for_type(&c.phase, &completed_deliverables, &task_infos, has_repository, &c.contract_type);
-
- // Add phase checklist to context
- context.push_str("\n");
- context.push_str(&format_checklist_markdown(&phase_checklist));
-
- // Add deliverable check result for phase transition readiness
- let deliverable_check = crate::llm::check_deliverables_met(
- &c.phase,
- &c.contract_type,
- &completed_deliverables,
- &task_infos,
- has_repository,
- );
-
- // Add deliverable prompt guidance
- context.push_str(&crate::llm::generate_deliverable_prompt_guidance(
- &c.phase,
- &c.contract_type,
- &deliverable_check,
- ));
-
- // Files summary
- context.push_str(&format!("\n### Files ({} total)\n", contract.files.len()));
- if !contract.files.is_empty() {
- for file in contract.files.iter().take(5) {
- let phase_label = file.contract_phase.as_deref().unwrap_or("none");
- context.push_str(&format!("- {} [{}] (ID: {})\n", file.name, phase_label, file.id));
- }
- if contract.files.len() > 5 {
- context.push_str(&format!("... and {} more\n", contract.files.len() - 5));
- }
- }
-
- // Tasks summary
- context.push_str(&format!("\n### Tasks ({} total)\n", contract.tasks.len()));
- if !contract.tasks.is_empty() {
- let pending = contract.tasks.iter().filter(|t| t.status == "pending").count();
- let running = contract.tasks.iter().filter(|t| t.status == "running").count();
- let done = contract.tasks.iter().filter(|t| t.status == "done").count();
- context.push_str(&format!("{} pending, {} running, {} done\n", pending, running, done));
- for task in contract.tasks.iter().take(5) {
- context.push_str(&format!("- {} ({}) - ID: {}\n", task.name, task.status, task.id));
- }
- if contract.tasks.len() > 5 {
- context.push_str(&format!("... and {} more\n", contract.tasks.len() - 5));
- }
- }
-
- // Repositories summary
- context.push_str(&format!("\n### Repositories ({} total)\n", contract.repositories.len()));
- if !contract.repositories.is_empty() {
- for repo in &contract.repositories {
- let primary = if repo.is_primary { " (primary)" } else { "" };
- let url_or_path = repo.repository_url.as_deref()
- .or(repo.local_path.as_deref())
- .unwrap_or("managed");
- context.push_str(&format!("- {}: {}{}\n", repo.name, url_or_path, primary));
- }
- }
-
- context
-}
-
-/// Summarize older conversation history to reduce token usage
-async fn summarize_conversation_history(
- llm_client: &LlmClient,
- messages: &[&crate::db::models::ContractChatMessageRecord],
-) -> String {
- // Build conversation text for summarization
- let mut conversation_text = String::new();
- for msg in messages {
- let role_label = if msg.role == "user" { "User" } else { "Assistant" };
- // Limit each message to avoid overwhelming the summarizer
- let content = if msg.content.len() > 500 {
- format!("{}...", &msg.content[..500])
- } else {
- msg.content.clone()
- };
- conversation_text.push_str(&format!("{}: {}\n", role_label, content));
- }
-
- // Limit total text to summarize
- if conversation_text.len() > 8000 {
- conversation_text = format!("{}...", &conversation_text[..8000]);
- }
-
- let summary_prompt = format!(
- "Summarize this conversation history in 2-3 sentences, focusing on key decisions, actions taken, and current state:\n\n{}",
- conversation_text
- );
-
- // Use a simple chat call without tools for summarization
- let summary = match llm_client {
- LlmClient::Claude(client) => {
- let claude_messages = vec![claude::Message {
- role: "user".to_string(),
- content: claude::MessageContent::Text(summary_prompt.clone()),
- }];
- match client.chat_with_tools(claude_messages, &[]).await {
- Ok(response) => response.content.unwrap_or_default(),
- Err(e) => {
- tracing::warn!("Failed to summarize conversation: {}", e);
- "Previous conversation covered contract management tasks.".to_string()
- }
- }
- }
- LlmClient::Groq(client) => {
- let groq_messages = vec![Message {
- role: "user".to_string(),
- content: Some(summary_prompt.clone()),
- tool_calls: None,
- tool_call_id: None,
- }];
- match client.chat_with_tools(groq_messages, &[]).await {
- Ok(response) => response.content.unwrap_or_default(),
- Err(e) => {
- tracing::warn!("Failed to summarize conversation: {}", e);
- "Previous conversation covered contract management tasks.".to_string()
- }
- }
- }
- };
-
- // Limit summary length
- if summary.len() > 500 {
- format!("{}...", &summary[..500])
- } else {
- summary
- }
-}
-
-/// Run the agentic loop for contract chat
-async fn run_contract_agentic_loop(
- pool: &sqlx::PgPool,
- state: &SharedState,
- llm_client: &LlmClient,
- system_prompt: String,
- request: &ContractChatRequest,
- contract_id: Uuid,
- owner_id: Uuid,
-) -> axum::response::Response {
- // Get or create the conversation for persistent history
- let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, owner_id).await {
- Ok(conv) => conv,
- Err(e) => {
- tracing::error!("Failed to get/create contract conversation: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })),
- )
- .into_response();
- }
- };
-
- // Load ALL existing messages from database
- let saved_messages = match repository::list_contract_chat_messages(pool, conversation.id, None).await {
- Ok(msgs) => msgs,
- Err(e) => {
- tracing::warn!("Failed to load contract chat history: {}", e);
- Vec::new()
- }
- };
-
- // Build initial messages
- let mut messages = vec![Message {
- role: "system".to_string(),
- content: Some(system_prompt),
- tool_calls: None,
- tool_call_id: None,
- }];
-
- // Add saved conversation history, summarizing older messages if needed
- // to stay under rate limits (~25k chars ≈ ~6k tokens for history)
- const MAX_HISTORY_CHARS: usize = 25000;
- const RECENT_MESSAGES_TO_KEEP: usize = 6; // Keep last 3 turns intact
-
- // Filter to user/assistant messages only
- let history_messages: Vec<_> = saved_messages
- .iter()
- .filter(|m| m.role == "user" || m.role == "assistant")
- .collect();
-
- // Calculate total character count
- let total_chars: usize = history_messages.iter().map(|m| m.content.len()).sum();
-
- if total_chars > MAX_HISTORY_CHARS && history_messages.len() > RECENT_MESSAGES_TO_KEEP {
- // Need to summarize older messages
- let split_point = history_messages.len().saturating_sub(RECENT_MESSAGES_TO_KEEP);
- let older_messages = &history_messages[..split_point];
- let recent_messages = &history_messages[split_point..];
-
- // Generate summary of older conversation
- let summary = summarize_conversation_history(&llm_client, older_messages).await;
-
- // Add summary as context
- messages.push(Message {
- role: "user".to_string(),
- content: Some(format!("[Previous conversation summary: {}]", summary)),
- tool_calls: None,
- tool_call_id: None,
- });
- messages.push(Message {
- role: "assistant".to_string(),
- content: Some("I understand the previous context. Let's continue.".to_string()),
- tool_calls: None,
- tool_call_id: None,
- });
-
- // Add recent messages in full
- for saved_msg in recent_messages {
- messages.push(Message {
- role: saved_msg.role.clone(),
- content: Some(saved_msg.content.clone()),
- tool_calls: None,
- tool_call_id: None,
- });
- }
-
- tracing::info!(
- total_messages = history_messages.len(),
- summarized = older_messages.len(),
- kept_recent = recent_messages.len(),
- "Summarized older conversation history"
- );
- } else {
- // Add all messages directly
- for saved_msg in history_messages {
- messages.push(Message {
- role: saved_msg.role.clone(),
- content: Some(saved_msg.content.clone()),
- tool_calls: None,
- tool_call_id: None,
- });
- }
- }
-
- // Add current user message
- messages.push(Message {
- role: "user".to_string(),
- content: Some(request.message.clone()),
- tool_calls: None,
- tool_call_id: None,
- });
-
- // Save the user message to database
- if let Err(e) = repository::add_contract_chat_message(
- pool,
- conversation.id,
- "user",
- &request.message,
- None,
- None,
- ).await {
- tracing::warn!("Failed to save user message to contract chat history: {}", e);
- }
-
- // State for tracking
- let mut all_tool_call_infos: Vec<ContractToolCallInfo> = Vec::new();
- let mut final_response: Option<String> = None;
- let mut consecutive_failures = 0;
- const MAX_CONSECUTIVE_FAILURES: usize = 3;
- let mut pending_questions: Option<Vec<UserQuestion>> = None;
-
- // Multi-turn agentic tool calling loop
- for round in 0..MAX_TOOL_ROUNDS {
- tracing::info!(
- round = round,
- total_tool_calls = all_tool_call_infos.len(),
- "Contract agentic loop iteration"
- );
-
- // Check consecutive failures
- if consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
- tracing::warn!(
- "Breaking contract loop due to {} consecutive failures",
- consecutive_failures
- );
- final_response = Some(
- "I encountered multiple consecutive errors and stopped. \
- Please check the contract state and try again."
- .to_string(),
- );
- break;
- }
-
- // Call the appropriate LLM API
- let result = match llm_client {
- LlmClient::Groq(groq) => {
- match groq.chat_with_tools(messages.clone(), &CONTRACT_TOOLS).await {
- Ok(r) => LlmResult {
- content: r.content,
- tool_calls: r.tool_calls,
- raw_tool_calls: r.raw_tool_calls,
- finish_reason: r.finish_reason,
- },
- Err(e) => {
- tracing::error!("Groq API error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("LLM API error: {}", e) })),
- )
- .into_response();
- }
- }
- }
- LlmClient::Claude(claude_client) => {
- let claude_messages = claude::groq_messages_to_claude(&messages);
- match claude_client
- .chat_with_tools(claude_messages, &CONTRACT_TOOLS)
- .await
- {
- Ok(r) => {
- let raw_tool_calls: Vec<ToolCallResponse> = r
- .tool_calls
- .iter()
- .map(|tc| ToolCallResponse {
- id: tc.id.clone(),
- call_type: "function".to_string(),
- function: crate::llm::groq::FunctionCall {
- name: tc.name.clone(),
- arguments: tc.arguments.to_string(),
- },
- })
- .collect();
-
- LlmResult {
- content: r.content,
- tool_calls: r.tool_calls,
- raw_tool_calls,
- finish_reason: r.stop_reason,
- }
- }
- Err(e) => {
- tracing::error!("Claude API error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("LLM API error: {}", e) })),
- )
- .into_response();
- }
- }
- }
- };
-
- // Check if there are tool calls to execute
- if result.tool_calls.is_empty() {
- final_response = result.content;
- break;
- }
-
- // Add assistant message with tool calls to conversation
- messages.push(Message {
- role: "assistant".to_string(),
- content: result.content.clone(),
- tool_calls: Some(result.raw_tool_calls.clone()),
- tool_call_id: None,
- });
-
- // Execute each tool call
- for (i, tool_call) in result.tool_calls.iter().enumerate() {
- tracing::info!(tool = %tool_call.name, round = round, "Executing contract tool call");
-
- // Parse the tool call
- let mut execution_result = parse_contract_tool_call(tool_call);
-
- // Handle async contract tool requests
- if let Some(contract_request) = execution_result.request.take() {
- let async_result =
- handle_contract_request(pool, &state.daemon_connections, contract_request, contract_id, owner_id).await;
- execution_result.success = async_result.success;
- execution_result.message = async_result.message;
- execution_result.data = async_result.data;
- }
-
- // Track consecutive failures
- if execution_result.success {
- consecutive_failures = 0;
- } else {
- consecutive_failures += 1;
- tracing::warn!(
- tool = %tool_call.name,
- consecutive_failures = consecutive_failures,
- "Contract tool call failed"
- );
- }
-
- // Check for pending user questions
- if let Some(questions) = execution_result.pending_questions {
- tracing::info!(
- question_count = questions.len(),
- "Contract LLM requesting user input"
- );
- pending_questions = Some(questions);
- all_tool_call_infos.push(ContractToolCallInfo {
- name: tool_call.name.clone(),
- result: ToolResult {
- success: execution_result.success,
- message: execution_result.message.clone(),
- },
- });
- break;
- }
-
- // Build tool result message
- let result_content = if let Some(data) = &execution_result.data {
- json!({
- "success": execution_result.success,
- "message": execution_result.message,
- "data": data
- })
- .to_string()
- } else {
- json!({
- "success": execution_result.success,
- "message": execution_result.message
- })
- .to_string()
- };
-
- // Add tool result message
- let tool_call_id = match llm_client {
- LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(),
- LlmClient::Claude(_) => tool_call.id.clone(),
- };
-
- messages.push(Message {
- role: "tool".to_string(),
- content: Some(result_content),
- tool_calls: None,
- tool_call_id: Some(tool_call_id),
- });
-
- // Track for response
- all_tool_call_infos.push(ContractToolCallInfo {
- name: tool_call.name.clone(),
- result: ToolResult {
- success: execution_result.success,
- message: execution_result.message,
- },
- });
- }
-
- // If user questions are pending, pause
- if pending_questions.is_some() {
- final_response = result.content;
- break;
- }
-
- // If finish reason indicates completion, exit loop
- let finish_lower = result.finish_reason.to_lowercase();
- if finish_lower == "stop" || finish_lower == "end_turn" {
- final_response = result.content;
- break;
- }
- }
-
- // Build response
- let response_text = final_response.unwrap_or_else(|| {
- if all_tool_call_infos.is_empty() {
- "I couldn't understand your request. Please try rephrasing.".to_string()
- } else {
- format!(
- "Done! Executed {} tool{}.",
- all_tool_call_infos.len(),
- if all_tool_call_infos.len() == 1 { "" } else { "s" }
- )
- }
- });
-
- // Save assistant response to database
- let tool_calls_json = if all_tool_call_infos.is_empty() {
- None
- } else {
- serde_json::to_value(&all_tool_call_infos).ok()
- };
-
- let pending_questions_json = pending_questions.as_ref().and_then(|q| serde_json::to_value(q).ok());
-
- if let Err(e) = repository::add_contract_chat_message(
- pool,
- conversation.id,
- "assistant",
- &response_text,
- tool_calls_json,
- pending_questions_json,
- ).await {
- tracing::warn!("Failed to save assistant response to contract chat history: {}", e);
- }
-
- (
- StatusCode::OK,
- Json(ContractChatResponse {
- response: response_text,
- tool_calls: all_tool_call_infos,
- pending_questions,
- }),
- )
- .into_response()
-}
-
-/// Result from handling an async contract tool request
-struct ContractRequestResult {
- success: bool,
- message: String,
- data: Option<serde_json::Value>,
-}
-
-/// Handle async contract tool requests that require database access
-async fn handle_contract_request(
- pool: &sqlx::PgPool,
- daemon_connections: &dashmap::DashMap<String, crate::server::state::DaemonConnectionInfo>,
- request: ContractToolRequest,
- contract_id: Uuid,
- owner_id: Uuid,
-) -> ContractRequestResult {
- match request {
- ContractToolRequest::ListDaemonDirectories => {
- let mut directories = Vec::new();
-
- // Iterate over connected daemons belonging to this owner
- for entry in daemon_connections.iter() {
- let daemon = entry.value();
-
- // Only include daemons belonging to this owner
- if daemon.owner_id != owner_id {
- continue;
- }
-
- // Add working directory if available
- if let Some(ref working_dir) = daemon.working_directory {
- directories.push(json!({
- "path": working_dir,
- "label": "Working Directory",
- "type": "working",
- "hostname": daemon.hostname,
- }));
- }
-
- // Add home directory if available
- if let Some(ref home_dir) = daemon.home_directory {
- directories.push(json!({
- "path": home_dir,
- "label": "Makima Home",
- "type": "home",
- "hostname": daemon.hostname,
- }));
- }
- }
-
- if directories.is_empty() {
- ContractRequestResult {
- success: true,
- message: "No daemon directories available. Connect a daemon to get directory suggestions.".to_string(),
- data: Some(json!({ "directories": [] })),
- }
- } else {
- ContractRequestResult {
- success: true,
- message: format!("Found {} suggested directories from connected daemons", directories.len()),
- data: Some(json!({ "directories": directories })),
- }
- }
- }
-
- ContractToolRequest::GetContractStatus => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let c = &cwr.contract;
- ContractRequestResult {
- success: true,
- message: format!(
- "Contract '{}' is in '{}' phase with status '{}'",
- c.name, c.phase, c.status
- ),
- data: Some(json!({
- "name": c.name,
- "phase": c.phase,
- "status": c.status,
- "description": c.description,
- "fileCount": cwr.files.len(),
- "taskCount": cwr.tasks.len(),
- "repositoryCount": cwr.repositories.len(),
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::ListContractFiles => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let files: Vec<serde_json::Value> = cwr
- .files
- .iter()
- .map(|f| {
- json!({
- "fileId": f.id,
- "name": f.name,
- "description": f.description,
- "phase": f.contract_phase,
- })
- })
- .collect();
-
- ContractRequestResult {
- success: true,
- message: format!("Found {} files", files.len()),
- data: Some(json!({ "files": files })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::ListContractTasks => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let tasks: Vec<serde_json::Value> = cwr
- .tasks
- .iter()
- .map(|t| {
- json!({
- "taskId": t.id,
- "name": t.name,
- "status": t.status,
- "priority": t.priority,
- })
- })
- .collect();
-
- ContractRequestResult {
- success: true,
- message: format!("Found {} tasks", tasks.len()),
- data: Some(json!({ "tasks": tasks })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::ListContractRepositories => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let repos: Vec<serde_json::Value> = cwr
- .repositories
- .iter()
- .map(|r| {
- json!({
- "repositoryId": r.id,
- "name": r.name,
- "repositoryUrl": r.repository_url,
- "localPath": r.local_path,
- "isPrimary": r.is_primary,
- })
- })
- .collect();
-
- ContractRequestResult {
- success: true,
- message: format!("Found {} repositories", repos.len()),
- data: Some(json!({ "repositories": repos })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::ReadFile { file_id } => {
- match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(file)) => {
- // Verify file belongs to this contract
- if file.contract_id != Some(contract_id) {
- return ContractRequestResult {
- success: false,
- message: "File does not belong to this contract".to_string(),
- data: None,
- };
- }
-
- // Convert body to markdown for LLM consumption
- let markdown = body_to_markdown(&file.body);
-
- ContractRequestResult {
- success: true,
- message: format!("Read file '{}'", file.name),
- data: Some(json!({
- "fileId": file.id,
- "name": file.name,
- "description": file.description,
- "summary": file.summary,
- "plainText": markdown,
- "phase": file.contract_phase,
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::CreateEmptyFile { name, description } => {
- // Verify contract exists and get current phase
- let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- // Create the file with current contract phase
- let create_req = crate::db::models::CreateFileRequest {
- contract_id,
- name: Some(name.clone()),
- description,
- body: Vec::new(),
- transcript: Vec::new(),
- location: None,
- repo_file_path: None,
- contract_phase: Some(contract.phase.clone()),
- };
-
- match repository::create_file_for_owner(pool, owner_id, create_req).await {
- Ok(file) => ContractRequestResult {
- success: true,
- message: format!("Created empty file '{}'", name),
- data: Some(json!({
- "fileId": file.id,
- "name": file.name,
- })),
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to create file: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::MarkDeliverableComplete {
- deliverable_id,
- phase,
- } => {
- // Get the contract to determine current phase and contract type
- let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- // Use specified phase or default to current contract phase
- let target_phase = phase.unwrap_or_else(|| contract.phase.clone());
-
- // Validate the deliverable ID exists for this phase/contract type
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&target_phase, &contract.contract_type);
- let deliverable_exists = phase_deliverables.deliverables.iter().any(|d| d.id == deliverable_id);
-
- if !deliverable_exists {
- let valid_ids: Vec<&str> = phase_deliverables.deliverables.iter().map(|d| d.id.as_str()).collect();
- return ContractRequestResult {
- success: false,
- message: format!(
- "Invalid deliverable_id '{}' for {} phase. Valid IDs: {:?}",
- deliverable_id, target_phase, valid_ids
- ),
- data: None,
- };
- }
-
- // Check if already completed
- if contract.is_deliverable_complete(&target_phase, &deliverable_id) {
- return ContractRequestResult {
- success: true,
- message: format!("Deliverable '{}' is already marked complete for {} phase", deliverable_id, target_phase),
- data: Some(json!({
- "deliverableId": deliverable_id,
- "phase": target_phase,
- "alreadyComplete": true,
- })),
- };
- }
-
- // Mark the deliverable as complete
- match repository::mark_deliverable_complete(pool, contract_id, &target_phase, &deliverable_id).await {
- Ok(updated_contract) => {
- let completed = updated_contract.get_completed_deliverables(&target_phase);
- ContractRequestResult {
- success: true,
- message: format!("Marked deliverable '{}' as complete for {} phase", deliverable_id, target_phase),
- data: Some(json!({
- "deliverableId": deliverable_id,
- "phase": target_phase,
- "completedDeliverables": completed,
- })),
- }
- }
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to mark deliverable complete: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::CreateContractTask {
- name,
- plan,
- repository_url,
- base_branch,
- } => {
- // Get primary repository if not specified
- let repo_url = if repository_url.is_some() {
- repository_url
- } else {
- // Find primary repository
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(contract)) => {
- contract
- .repositories
- .iter()
- .find(|r| r.is_primary)
- .and_then(|r| r.repository_url.clone().or(r.local_path.clone()))
- }
- _ => None,
- }
- };
-
- let create_req = CreateTaskRequest {
- contract_id: Some(contract_id),
- name: name.clone(),
- description: None,
- plan,
- parent_task_id: None,
- repository_url: repo_url,
- base_branch,
- target_branch: None,
- merge_mode: None,
- priority: 0,
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- match repository::create_task_for_owner(pool, owner_id, create_req).await {
- Ok(task) => ContractRequestResult {
- success: true,
- message: format!("Created task '{}' in contract", name),
- data: Some(json!({
- "taskId": task.id,
- "name": task.name,
- "status": task.status,
- })),
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to create task: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::DelegateContentGeneration {
- file_id,
- instruction,
- context,
- } => {
- // Build a task plan that includes the content generation instruction
- let mut plan = format!(
- "Content Generation Task\n\n\
- ## Instruction\n{}\n\n",
- instruction
- );
-
- if let Some(ctx) = context {
- plan.push_str(&format!("## Context\n{}\n\n", ctx));
- }
-
- // If file_id is provided, get file details and include them
- let (file_name, file_info) = if let Some(fid) = file_id {
- match repository::get_file_for_owner(pool, fid, owner_id).await {
- Ok(Some(file)) => {
- let info = format!(
- "## Target File\n\
- - File ID: {}\n\
- - Name: {}\n\
- - Description: {}\n\n\
- The generated content should be structured to update this file.\n",
- fid,
- file.name,
- file.description.as_deref().unwrap_or("(no description)")
- );
- (Some(file.name.clone()), Some(info))
- }
- _ => (None, None),
- }
- } else {
- (None, None)
- };
-
- if let Some(info) = file_info {
- plan.push_str(&info);
- }
-
- // Get primary repository
- let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(contract)) => contract
- .repositories
- .iter()
- .find(|r| r.is_primary)
- .and_then(|r| r.repository_url.clone().or(r.local_path.clone())),
- _ => None,
- };
-
- let task_name = format!(
- "Generate content{}",
- file_name.map(|n| format!(": {}", n)).unwrap_or_default()
- );
-
- let create_req = CreateTaskRequest {
- contract_id: Some(contract_id),
- name: task_name.clone(),
- description: Some(instruction.clone()),
- plan,
- parent_task_id: None,
- repository_url: repo_url,
- base_branch: None,
- target_branch: None,
- merge_mode: None,
- priority: 0,
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- match repository::create_task_for_owner(pool, owner_id, create_req).await {
- Ok(task) => ContractRequestResult {
- success: true,
- message: format!(
- "Created content generation task '{}'. Start the task to generate the content.",
- task_name
- ),
- data: Some(json!({
- "taskId": task.id,
- "name": task.name,
- "status": task.status,
- "targetFileId": file_id,
- })),
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to create content generation task: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::StartTask { task_id } => {
- // Get the task
- let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
- Ok(Some(t)) => t,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Task not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to get task: {}", e),
- data: None,
- }
- }
- };
-
- // Check if task can be started
- let startable_statuses = ["pending", "failed", "interrupted", "done", "merged"];
- if !startable_statuses.contains(&task.status.as_str()) {
- return ContractRequestResult {
- success: false,
- message: format!("Task cannot be started from status: {}", task.status),
- data: None,
- };
- }
-
- // Find a connected daemon for this owner
- let daemon_entry = daemon_connections
- .iter()
- .find(|d| d.value().owner_id == owner_id);
-
- let (target_daemon_id, command_sender) = match daemon_entry {
- Some(entry) => {
- let daemon = entry.value();
- (daemon.id, daemon.command_sender.clone())
- }
- None => {
- return ContractRequestResult {
- success: false,
- message: "No daemon connected. Start a daemon to run tasks.".to_string(),
- data: None,
- };
- }
- };
-
- // Check if this is an orchestrator
- let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await {
- Ok(subtasks) => subtasks.len(),
- Err(_) => 0,
- };
- let is_orchestrator = task.depth == 0 && subtask_count > 0;
-
- // Update task status to 'starting' and assign daemon_id
- let update_req = crate::db::models::UpdateTaskRequest {
- status: Some("starting".to_string()),
- daemon_id: Some(target_daemon_id),
- version: Some(task.version),
- ..Default::default()
- };
-
- let _updated_task = match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
- Ok(Some(t)) => t,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Task not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to update task: {}", e),
- data: None,
- };
- }
- };
-
- // Get local_only and auto_merge_local from contract if task has one
- let (local_only, auto_merge_local) = if let Some(contract_id) = task.contract_id {
- match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
- Ok(Some(contract)) => (contract.local_only, contract.auto_merge_local),
- _ => (false, false),
- }
- } else {
- (false, false)
- };
-
- // Send SpawnTask command to daemon
- let command = DaemonCommand::SpawnTask {
- task_id,
- task_name: task.name.clone(),
- plan: task.plan.clone(),
- repo_url: task.repository_url.clone(),
- base_branch: task.base_branch.clone(),
- target_branch: task.target_branch.clone(),
- parent_task_id: task.parent_task_id,
- depth: task.depth,
- is_orchestrator,
- target_repo_path: task.target_repo_path.clone(),
- completion_action: task.completion_action.clone(),
- continue_from_task_id: task.continue_from_task_id,
- copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()),
- contract_id: task.contract_id,
- is_supervisor: task.is_supervisor,
- autonomous_loop: false,
- resume_session: false,
- conversation_history: None,
- patch_data: None,
- patch_base_sha: None,
- local_only,
- auto_merge_local,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: task.directive_id,
- };
-
- if let Err(e) = command_sender.send(command).await {
- // Rollback: reset status since command failed
- let rollback_req = crate::db::models::UpdateTaskRequest {
- status: Some("pending".to_string()),
- clear_daemon_id: true,
- ..Default::default()
- };
- let _ = repository::update_task_for_owner(pool, task_id, owner_id, rollback_req).await;
- return ContractRequestResult {
- success: false,
- message: format!("Failed to send task to daemon: {}", e),
- data: None,
- };
- }
-
- // Note: TaskUpdateNotification broadcast is handled by the mesh handler when daemon reports status
- ContractRequestResult {
- success: true,
- message: format!("Started task '{}'. The task is now running on a connected daemon.", task.name),
- data: Some(json!({
- "taskId": task_id,
- "name": task.name,
- "status": "starting",
- })),
- }
- }
-
- ContractToolRequest::GetPhaseInfo => {
- let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- let phase_info = get_phase_description(&contract.phase);
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type);
- let deliverable_names: Vec<String> = phase_deliverables.deliverables.iter().map(|d| d.name.clone()).collect();
-
- ContractRequestResult {
- success: true,
- message: format!("Contract is in '{}' phase", contract.phase),
- data: Some(json!({
- "phase": contract.phase,
- "description": phase_info.0,
- "activities": phase_info.1,
- "deliverables": deliverable_names,
- "guidance": phase_deliverables.guidance,
- "nextPhase": get_next_phase(&contract.phase),
- })),
- }
- }
-
- ContractToolRequest::SuggestPhaseTransition => {
- let contract = match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- let analysis = analyze_phase_readiness(&contract);
-
- ContractRequestResult {
- success: true,
- message: analysis.summary.clone(),
- data: Some(json!({
- "currentPhase": contract.contract.phase,
- "nextPhase": get_next_phase(&contract.contract.phase),
- "ready": analysis.ready,
- "summary": analysis.summary,
- "reasons": analysis.reasons,
- "suggestions": analysis.suggestions,
- })),
- }
- }
-
- ContractToolRequest::AdvancePhase { new_phase, confirmed, feedback } => {
- let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- }
- }
- };
-
- // Validate phase transition
- let current_phase = &contract.phase;
- let valid_next = get_next_phase(current_phase);
-
- if valid_next.as_deref() != Some(&new_phase) {
- return ContractRequestResult {
- success: false,
- message: format!(
- "Cannot transition from '{}' to '{}'. Next valid phase is: {:?}",
- current_phase, new_phase, valid_next
- ),
- data: None,
- };
- }
-
- // Check if deliverables are met before allowing transition
- let cwr = match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(c)) => c,
- Ok(None) | Err(_) => {
- // Fall through - we'll just skip the deliverables check
- return ContractRequestResult {
- success: false,
- message: "Failed to load contract for deliverables check".to_string(),
- data: None,
- };
- }
- };
-
- // Get completed deliverables for the current phase
- let completed_deliverables = cwr.contract.get_completed_deliverables(current_phase);
-
- let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo {
- name: t.name.clone(),
- status: t.status.clone(),
- }).collect();
-
- let has_repository = !cwr.repositories.is_empty();
-
- let check_result = crate::llm::check_deliverables_met(
- current_phase,
- &contract.contract_type,
- &completed_deliverables,
- &task_infos,
- has_repository,
- );
-
- // Block transition if deliverables are not met
- if !check_result.deliverables_met {
- return ContractRequestResult {
- success: false,
- message: format!(
- "Cannot advance to '{}' phase: deliverables not met. {}",
- new_phase, check_result.summary
- ),
- data: Some(json!({
- "status": "deliverables_not_met",
- "currentPhase": current_phase,
- "requestedPhase": new_phase,
- "deliverablesMet": false,
- "requiredDeliverables": check_result.required_deliverables,
- "missing": check_result.missing,
- "action": "Complete the missing deliverables before advancing to the next phase"
- })),
- };
- }
-
- // Check if phase_guard is enabled
- if contract.phase_guard {
- // If user provided feedback, return it for the task to address
- if let Some(ref user_feedback) = feedback {
- return ContractRequestResult {
- success: true,
- message: format!(
- "Phase transition to '{}' requires changes. User feedback: {}",
- new_phase, user_feedback
- ),
- data: Some(json!({
- "status": "changes_requested",
- "currentPhase": current_phase,
- "requestedPhase": new_phase,
- "feedback": user_feedback,
- "action": "Address the user feedback and try again when ready"
- })),
- };
- }
-
- // If not confirmed, return requires_confirmation with phase deliverables
- // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level
- if !confirmed {
- // Get files created in this phase
- let phase_files = match repository::list_files_in_contract(pool, contract_id, owner_id).await {
- Ok(files) => files
- .into_iter()
- .filter(|f| f.contract_phase.as_deref() == Some(current_phase))
- .map(|f| json!({
- "id": f.id,
- "name": f.name,
- "description": f.description
- }))
- .collect::<Vec<_>>(),
- Err(_) => Vec::new(),
- };
-
- // Get tasks completed in this contract
- let phase_tasks = match repository::list_tasks_in_contract(pool, contract_id, owner_id).await {
- Ok(tasks) => tasks
- .into_iter()
- .filter(|t| t.status == "done" || t.status == "completed")
- .map(|t| json!({
- "id": t.id,
- "name": t.name,
- "status": t.status
- }))
- .collect::<Vec<_>>(),
- Err(_) => Vec::new(),
- };
-
- // Get phase deliverables with completion status
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(current_phase, &contract.contract_type);
- let completed_deliverables = contract.get_completed_deliverables(current_phase);
-
- let deliverables: Vec<serde_json::Value> = phase_deliverables
- .deliverables
- .iter()
- .map(|d| json!({
- "id": d.id,
- "name": d.name,
- "completed": completed_deliverables.contains(&d.id)
- }))
- .collect();
-
- // Build deliverables summary
- let deliverables_summary = format!(
- "Phase '{}' deliverables: {} files created, {} tasks completed.",
- current_phase,
- phase_files.len(),
- phase_tasks.len()
- );
-
- let transition_id = uuid::Uuid::new_v4().to_string();
-
- return ContractRequestResult {
- success: true,
- message: format!(
- "Phase transition to '{}' requires user confirmation. Review the deliverables and call advance_phase again with confirmed=true to proceed, or provide feedback to request changes.",
- new_phase
- ),
- data: Some(json!({
- "status": "requires_confirmation",
- "transitionId": transition_id,
- "currentPhase": current_phase,
- "nextPhase": new_phase,
- "deliverablesSummary": deliverables_summary,
- "deliverables": deliverables,
- "phaseFiles": phase_files,
- "phaseTasks": phase_tasks,
- "requiresConfirmation": true,
- "message": "Phase guard is enabled. User confirmation required.",
- "instructions": "To proceed: call advance_phase with confirmed=true. To request changes: call advance_phase with feedback='your feedback here'"
- })),
- };
- }
- }
-
- // Update phase (either phase_guard is disabled, or user confirmed)
- match repository::change_contract_phase_for_owner(pool, contract_id, owner_id, &new_phase).await {
- Ok(Some(updated)) => {
- // Get deliverables for the new phase (using contract type)
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&new_phase, &contract.contract_type);
-
- // Build deliverables list
- let deliverables_list: Vec<serde_json::Value> = phase_deliverables
- .deliverables
- .iter()
- .map(|d| json!({
- "id": d.id,
- "name": d.name,
- "priority": format!("{:?}", d.priority).to_lowercase(),
- "description": d.description,
- }))
- .collect();
-
- ContractRequestResult {
- success: true,
- message: format!(
- "Advanced contract from '{}' to '{}' phase. {}",
- current_phase, new_phase, phase_deliverables.guidance
- ),
- data: Some(json!({
- "status": "advanced",
- "previousPhase": current_phase,
- "newPhase": updated.phase,
- "phaseGuidance": phase_deliverables.guidance,
- "deliverables": deliverables_list,
- "requiresRepository": phase_deliverables.requires_repository,
- "requiresTasks": phase_deliverables.requires_tasks,
- })),
- }
- },
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Failed to update phase".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to update phase: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::AddRepository {
- repo_type,
- name,
- url,
- is_primary,
- } => {
- let add_result = match repo_type.as_str() {
- "remote" => {
- let url = url.unwrap_or_default();
- repository::add_remote_repository(
- pool,
- contract_id,
- &name,
- &url,
- is_primary,
- )
- .await
- }
- "local" => {
- let path = url.unwrap_or_default();
- repository::add_local_repository(
- pool,
- contract_id,
- &name,
- &path,
- is_primary,
- )
- .await
- }
- "managed" => {
- repository::create_managed_repository(pool, contract_id, &name, is_primary)
- .await
- }
- _ => {
- return ContractRequestResult {
- success: false,
- message: format!("Invalid repository type: {}", repo_type),
- data: None,
- }
- }
- };
-
- match add_result {
- Ok(repo) => ContractRequestResult {
- success: true,
- message: format!("Added {} repository '{}'", repo_type, name),
- data: Some(json!({
- "repositoryId": repo.id,
- "name": repo.name,
- "isPrimary": repo.is_primary,
- })),
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to add repository: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::SetPrimaryRepository { repository_id } => {
- match repository::set_repository_primary(pool, repository_id, contract_id).await {
- Ok(true) => ContractRequestResult {
- success: true,
- message: "Set repository as primary".to_string(),
- data: Some(json!({
- "repositoryId": repository_id,
- })),
- },
- Ok(false) => ContractRequestResult {
- success: false,
- message: "Repository not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to set primary repository: {}", e),
- data: None,
- },
- }
- }
-
- // =============================================================================
- // Phase Guidance Handlers
- // =============================================================================
-
- ContractToolRequest::GetPhaseChecklist => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase);
-
- let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo {
- name: t.name.clone(),
- status: t.status.clone(),
- }).collect();
-
- let has_repository = !cwr.repositories.is_empty();
- let checklist = crate::llm::get_phase_checklist_for_type(&cwr.contract.phase, &completed_deliverables, &task_infos, has_repository, &cwr.contract.contract_type);
-
- ContractRequestResult {
- success: true,
- message: checklist.summary.clone(),
- data: Some(json!({
- "phase": checklist.phase,
- "completionPercentage": checklist.completion_percentage,
- "deliverables": checklist.deliverables,
- "hasRepository": checklist.has_repository,
- "repositoryRequired": checklist.repository_required,
- "taskStats": checklist.task_stats,
- "suggestions": checklist.suggestions,
- "summary": checklist.summary,
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::CheckDeliverablesMet => {
- match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(cwr)) => {
- let completed_deliverables = cwr.contract.get_completed_deliverables(&cwr.contract.phase);
-
- let task_infos: Vec<TaskInfo> = cwr.tasks.iter().map(|t| TaskInfo {
- name: t.name.clone(),
- status: t.status.clone(),
- }).collect();
-
- let has_repository = !cwr.repositories.is_empty();
-
- let check_result = crate::llm::check_deliverables_met(
- &cwr.contract.phase,
- &cwr.contract.contract_type,
- &completed_deliverables,
- &task_infos,
- has_repository,
- );
-
- // Check if we should auto-progress
- let auto_progress = crate::llm::should_auto_progress(
- &cwr.contract.phase,
- &cwr.contract.contract_type,
- &completed_deliverables,
- &task_infos,
- has_repository,
- cwr.contract.autonomous_loop,
- );
-
- ContractRequestResult {
- success: true,
- message: check_result.summary.clone(),
- data: Some(json!({
- "deliverablesMet": check_result.deliverables_met,
- "readyToAdvance": check_result.ready_to_advance,
- "phase": check_result.phase,
- "nextPhase": check_result.next_phase,
- "requiredDeliverables": check_result.required_deliverables,
- "missing": check_result.missing,
- "summary": check_result.summary,
- "autoProgressRecommended": check_result.auto_progress_recommended,
- "autoProgress": {
- "shouldProgress": auto_progress.should_progress,
- "nextPhase": auto_progress.next_phase,
- "reason": auto_progress.reason,
- "action": format!("{:?}", auto_progress.action),
- },
- "autonomousLoop": cwr.contract.autonomous_loop,
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Contract not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- // =============================================================================
- // Task Derivation Handlers
- // =============================================================================
-
- ContractToolRequest::DeriveTasksFromFile { file_id } => {
- // First get the file
- match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(file)) => {
- // Verify file belongs to this contract
- if file.contract_id != Some(contract_id) {
- return ContractRequestResult {
- success: false,
- message: "File does not belong to this contract".to_string(),
- data: None,
- };
- }
-
- // Convert body to markdown for task parsing
- let markdown = body_to_markdown(&file.body);
-
- // Parse tasks from the content
- let parse_result = parse_tasks_from_breakdown(&markdown);
-
- ContractRequestResult {
- success: true,
- message: format!("Found {} tasks in file '{}'", parse_result.total, file.name),
- data: Some(json!({
- "fileId": file_id,
- "fileName": file.name,
- "tasks": parse_result.tasks,
- "groups": parse_result.groups,
- "total": parse_result.total,
- "warnings": parse_result.warnings,
- "formatted": format_parsed_tasks(&parse_result),
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::CreateChainedTasks { tasks } => {
- // Get primary repository for tasks
- let repo_url = match get_contract_with_relations(pool, contract_id, owner_id).await {
- Ok(Some(contract)) => {
- contract
- .repositories
- .iter()
- .find(|r| r.is_primary)
- .and_then(|r| r.repository_url.clone().or(r.local_path.clone()))
- }
- _ => None,
- };
-
- let mut created_tasks = Vec::new();
- let mut previous_task_id: Option<Uuid> = None;
-
- for task_def in &tasks {
- let create_req = CreateTaskRequest {
- contract_id: Some(contract_id),
- name: task_def.name.clone(),
- description: None,
- plan: task_def.plan.clone(),
- parent_task_id: None,
- repository_url: repo_url.clone(),
- base_branch: None,
- target_branch: None,
- merge_mode: None,
- priority: 0,
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: previous_task_id,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- match repository::create_task_for_owner(pool, owner_id, create_req).await {
- Ok(task) => {
- created_tasks.push(json!({
- "taskId": task.id,
- "name": task.name,
- "status": task.status,
- "chainedFrom": previous_task_id,
- }));
- previous_task_id = Some(task.id);
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to create task '{}': {}", task_def.name, e),
- data: Some(json!({
- "createdSoFar": created_tasks,
- })),
- };
- }
- }
- }
-
- ContractRequestResult {
- success: true,
- message: format!("Created {} chained tasks", created_tasks.len()),
- data: Some(json!({
- "tasks": created_tasks,
- "total": created_tasks.len(),
- })),
- }
- }
-
- // =============================================================================
- // Task Completion Processing Handlers
- // =============================================================================
-
- ContractToolRequest::ProcessTaskCompletion { task_id } => {
- // Get the task
- match repository::get_task_for_owner(pool, task_id, owner_id).await {
- Ok(Some(task)) => {
- // Verify task belongs to this contract
- if task.contract_id != Some(contract_id) {
- return ContractRequestResult {
- success: false,
- message: "Task does not belong to this contract".to_string(),
- data: None,
- };
- }
-
- // Get contract for context
- let contract = get_contract_with_relations(pool, contract_id, owner_id).await.ok().flatten();
-
- let total_tasks = contract.as_ref().map(|c| c.tasks.len()).unwrap_or(0);
- let completed_tasks = contract.as_ref()
- .map(|c| c.tasks.iter().filter(|t| t.status == "done").count())
- .unwrap_or(0);
-
- // Note: Finding next chained task would require querying full Task objects
- // Since TaskSummary doesn't have continue_from_task_id, we skip this for now
- let next_task: Option<(Uuid, String)> = None;
-
- // Find Dev Notes file if exists
- let dev_notes = if let Some(ref c) = contract {
- c.files.iter()
- .find(|f| f.name.to_lowercase().contains("dev") && f.name.to_lowercase().contains("notes"))
- .map(|f| (f.id, f.name.clone()))
- } else {
- None
- };
-
- let contract_phase = contract.as_ref()
- .map(|c| c.contract.phase.clone())
- .unwrap_or_else(|| "execute".to_string());
-
- // Analyze the task output
- let analysis = analyze_task_output(
- task_id,
- &task.name,
- task.last_output.as_deref(),
- task.progress_summary.as_deref(),
- &contract_phase,
- total_tasks,
- completed_tasks,
- next_task,
- dev_notes,
- );
-
- ContractRequestResult {
- success: true,
- message: format!("Analyzed completion of task '{}'", task.name),
- data: Some(json!({
- "taskId": task_id,
- "taskName": task.name,
- "taskStatus": task.status,
- "summary": analysis.summary,
- "filesAffected": analysis.files_affected,
- "nextSteps": analysis.next_steps,
- "phaseImpact": analysis.phase_impact,
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Task not found".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- ContractToolRequest::UpdateFileFromTask { file_id, task_id, section_title } => {
- // Get the task
- let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
- Ok(Some(t)) => t,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "Task not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- };
- }
- };
-
- // Get the file
- let file = match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- };
- }
- };
-
- // Verify file belongs to this contract
- if file.contract_id != Some(contract_id) {
- return ContractRequestResult {
- success: false,
- message: "File does not belong to this contract".to_string(),
- data: None,
- };
- }
-
- // Build the section to add
- let title = section_title.unwrap_or_else(|| format!("Task: {}", task.name));
- let result_text = task.last_output.as_deref().unwrap_or("Task completed");
-
- // Create new body elements to append
- let mut new_body = file.body.clone();
- new_body.push(crate::db::models::BodyElement::Heading {
- level: 2,
- text: title,
- });
- new_body.push(crate::db::models::BodyElement::Paragraph {
- text: format!("Status: {}", task.status),
- });
- new_body.push(crate::db::models::BodyElement::Paragraph {
- text: result_text.to_string(),
- });
-
- // Update the file using UpdateFileRequest
- let update_req = UpdateFileRequest {
- name: None,
- description: None,
- transcript: None,
- summary: None,
- body: Some(new_body),
- version: None, // Don't require version for this update
- repo_file_path: None,
- };
-
- match repository::update_file_for_owner(pool, file_id, owner_id, update_req).await {
- Ok(Some(updated_file)) => {
- ContractRequestResult {
- success: true,
- message: format!("Updated file '{}' with task summary", file.name),
- data: Some(json!({
- "fileId": file_id,
- "fileName": updated_file.name,
- "taskId": task_id,
- "taskName": task.name,
- })),
- }
- }
- Ok(None) => ContractRequestResult {
- success: false,
- message: "Failed to update file".to_string(),
- data: None,
- },
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- },
- }
- }
-
- // =============================================================================
- // Transcript Analysis Handlers
- // =============================================================================
-
- ContractToolRequest::AnalyzeTranscript { file_id } => {
- // Get the file
- let file = match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- };
- }
- };
-
- if file.transcript.is_empty() {
- return ContractRequestResult {
- success: false,
- message: "File has no transcript to analyze".to_string(),
- data: None,
- };
- }
-
- // Format and analyze
- let transcript_text = format_transcript_for_analysis(&file.transcript);
- let speaker_stats = calculate_speaker_stats(&file.transcript);
- let prompt = build_analysis_prompt(&transcript_text);
-
- // Call Claude for analysis
- let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) {
- Ok(c) => c,
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to create Claude client: {}", e),
- data: None,
- };
- }
- };
-
- let claude_messages = vec![claude::Message {
- role: "user".to_string(),
- content: claude::MessageContent::Text(prompt),
- }];
-
- match client.chat_with_tools(claude_messages, &[]).await {
- Ok(result) => {
- let response_content = result.content.unwrap_or_default();
- match parse_analysis_response(&response_content, speaker_stats) {
- Ok(analysis) => {
- ContractRequestResult {
- success: true,
- message: format!(
- "Analysis complete: {} requirements, {} decisions, {} action items",
- analysis.requirements.len(),
- analysis.decisions.len(),
- analysis.action_items.len()
- ),
- data: Some(json!({
- "analysis": analysis
- })),
- }
- }
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Failed to parse analysis: {}", e),
- data: None,
- }
- }
- }
- Err(e) => ContractRequestResult {
- success: false,
- message: format!("Claude API error: {}", e),
- data: None,
- }
- }
- }
-
- ContractToolRequest::CreateContractFromTranscript {
- file_id, name, description, include_requirements, include_decisions, include_action_items
- } => {
- // Get file
- let file = match repository::get_file_for_owner(pool, file_id, owner_id).await {
- Ok(Some(f)) => f,
- Ok(None) => {
- return ContractRequestResult {
- success: false,
- message: "File not found".to_string(),
- data: None,
- };
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Database error: {}", e),
- data: None,
- };
- }
- };
-
- if file.transcript.is_empty() {
- return ContractRequestResult {
- success: false,
- message: "File has no transcript".to_string(),
- data: None,
- };
- }
-
- // Analyze transcript
- let transcript_text = format_transcript_for_analysis(&file.transcript);
- let speaker_stats = calculate_speaker_stats(&file.transcript);
- let prompt = build_analysis_prompt(&transcript_text);
-
- let client = match ClaudeClient::from_env(ClaudeModel::Sonnet) {
- Ok(c) => c,
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to create Claude client: {}", e),
- data: None,
- };
- }
- };
-
- let claude_messages = vec![claude::Message {
- role: "user".to_string(),
- content: claude::MessageContent::Text(prompt),
- }];
-
- let analysis = match client.chat_with_tools(claude_messages, &[]).await {
- Ok(result) => {
- let response_content = result.content.unwrap_or_default();
- match parse_analysis_response(&response_content, speaker_stats) {
- Ok(a) => a,
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to parse analysis: {}", e),
- data: None,
- };
- }
- }
- }
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Claude API error: {}", e),
- data: None,
- };
- }
- };
-
- // Create contract
- let contract_name = name
- .or(analysis.suggested_contract_name.clone())
- .unwrap_or_else(|| format!("Contract from {}", file.name));
- let contract_description = description.or(analysis.suggested_description.clone());
-
- let contract_req = crate::db::models::CreateContractRequest {
- name: contract_name.clone(),
- description: contract_description,
- contract_type: Some("specification".to_string()),
- initial_phase: Some("research".to_string()),
- autonomous_loop: None,
- phase_guard: None,
- local_only: None,
- auto_merge_local: None,
- template_id: None,
- };
-
- let contract = match repository::create_contract_for_owner(pool, owner_id, contract_req).await {
- Ok(c) => c,
- Err(e) => {
- return ContractRequestResult {
- success: false,
- message: format!("Failed to create contract: {}", e),
- data: None,
- };
- }
- };
-
- let mut files_created = 0;
- let mut tasks_created = 0;
-
- // Create requirements file if requested and there are requirements
- if include_requirements && !analysis.requirements.is_empty() {
- let requirements_items: Vec<String> = analysis.requirements
- .iter()
- .map(|req| format!("[{}] {}", req.speaker, req.text))
- .collect();
-
- let body: Vec<crate::db::models::BodyElement> = vec![
- crate::db::models::BodyElement::Heading {
- level: 1,
- text: "Requirements".to_string(),
- },
- crate::db::models::BodyElement::Paragraph {
- text: format!("Extracted {} requirements from transcript analysis.", analysis.requirements.len()),
- },
- crate::db::models::BodyElement::Heading {
- level: 2,
- text: "Extracted Requirements".to_string(),
- },
- crate::db::models::BodyElement::List {
- ordered: false,
- items: requirements_items,
- },
- ];
-
- let create_req = crate::db::models::CreateFileRequest {
- contract_id: contract.id,
- name: Some("Requirements".to_string()),
- description: Some("Requirements extracted from transcript analysis".to_string()),
- body,
- transcript: Vec::new(),
- location: None,
- repo_file_path: None,
- contract_phase: Some("specify".to_string()),
- };
-
- if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() {
- files_created += 1;
- }
- }
-
- // Create decisions file if requested and there are decisions
- if include_decisions && !analysis.decisions.is_empty() {
- let decisions_items: Vec<String> = analysis.decisions
- .iter()
- .map(|dec| format!("[{}] {}", dec.speaker, dec.text))
- .collect();
-
- let body: Vec<crate::db::models::BodyElement> = vec![
- crate::db::models::BodyElement::Heading {
- level: 1,
- text: "Decisions".to_string(),
- },
- crate::db::models::BodyElement::Paragraph {
- text: format!("Extracted {} decisions from transcript analysis.", analysis.decisions.len()),
- },
- crate::db::models::BodyElement::Heading {
- level: 2,
- text: "Recorded Decisions".to_string(),
- },
- crate::db::models::BodyElement::List {
- ordered: false,
- items: decisions_items,
- },
- ];
-
- let create_req = crate::db::models::CreateFileRequest {
- contract_id: contract.id,
- name: Some("Decisions".to_string()),
- description: Some("Decisions extracted from transcript analysis".to_string()),
- body,
- transcript: Vec::new(),
- location: None,
- repo_file_path: None,
- contract_phase: Some("research".to_string()),
- };
-
- if repository::create_file_for_owner(pool, owner_id, create_req).await.is_ok() {
- files_created += 1;
- }
- }
-
- // Create tasks from action items if requested
- if include_action_items && !analysis.action_items.is_empty() {
- for item in &analysis.action_items {
- let task_req = CreateTaskRequest {
- contract_id: Some(contract.id),
- name: item.text.chars().take(100).collect(),
- description: Some(format!("Action item from: {}", item.speaker)),
- plan: item.text.clone(),
- parent_task_id: None,
- repository_url: None,
- base_branch: None,
- target_branch: None,
- merge_mode: None,
- priority: match item.priority.as_deref() {
- Some("high") => 10,
- Some("medium") => 5,
- _ => 0,
- },
- target_repo_path: None,
- completion_action: None,
- continue_from_task_id: None,
- copy_files: None,
- is_supervisor: false,
- checkpoint_sha: None,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Not spawned by supervisor
- directive_id: None,
- directive_step_id: None,
- };
-
- if repository::create_task_for_owner(pool, owner_id, task_req).await.is_ok() {
- tasks_created += 1;
- }
- }
- }
-
- ContractRequestResult {
- success: true,
- message: format!(
- "Created contract '{}' with {} files and {} tasks from transcript analysis",
- contract_name, files_created, tasks_created
- ),
- data: Some(json!({
- "contractId": contract.id,
- "contractName": contract_name,
- "filesCreated": files_created,
- "tasksCreated": tasks_created,
- "analysis": {
- "requirementsCount": analysis.requirements.len(),
- "decisionsCount": analysis.decisions.len(),
- "actionItemsCount": analysis.action_items.len()
- }
- })),
- }
- }
-
-
- }
-}
-
-/// Get description and activities for a phase
-fn get_phase_description(phase: &str) -> (String, Vec<String>) {
- match phase {
- "research" => (
- "Gather information, analyze competitors, and understand user needs".to_string(),
- vec![
- "Conduct user research".to_string(),
- "Analyze competitors".to_string(),
- "Document findings".to_string(),
- "Identify opportunities".to_string(),
- ],
- ),
- "specify" => (
- "Define requirements, user stories, and acceptance criteria".to_string(),
- vec![
- "Write requirements".to_string(),
- "Create user stories".to_string(),
- "Define acceptance criteria".to_string(),
- "Document constraints".to_string(),
- ],
- ),
- "plan" => (
- "Design architecture, create task breakdowns, and technical designs".to_string(),
- vec![
- "Design system architecture".to_string(),
- "Create technical specifications".to_string(),
- "Break down into tasks".to_string(),
- "Plan implementation order".to_string(),
- ],
- ),
- "execute" => (
- "Implement features, write code, and run tasks".to_string(),
- vec![
- "Implement features".to_string(),
- "Write tests".to_string(),
- "Track progress".to_string(),
- "Document implementation details".to_string(),
- ],
- ),
- "review" => (
- "Review work, create release notes, and conduct retrospectives".to_string(),
- vec![
- "Review code and features".to_string(),
- "Create release notes".to_string(),
- "Conduct retrospective".to_string(),
- "Document learnings".to_string(),
- ],
- ),
- _ => (
- "Unknown phase".to_string(),
- vec![],
- ),
- }
-}
-
-/// Get the next phase in the lifecycle
-fn get_next_phase(current: &str) -> Option<String> {
- match current {
- "research" => Some("specify".to_string()),
- "specify" => Some("plan".to_string()),
- "plan" => Some("execute".to_string()),
- "execute" => Some("review".to_string()),
- "review" => None, // Final phase
- _ => None,
- }
-}
-
-/// Phase readiness analysis result
-struct PhaseReadinessAnalysis {
- ready: bool,
- summary: String,
- reasons: Vec<String>,
- suggestions: Vec<String>,
-}
-
-/// Analyze if the contract is ready to transition to the next phase
-fn analyze_phase_readiness(contract: &crate::db::models::ContractWithRelations) -> PhaseReadinessAnalysis {
- let mut reasons = Vec::new();
- let mut suggestions = Vec::new();
-
- match contract.contract.phase.as_str() {
- "research" => {
- // Check for research files
- let research_files = contract.files.iter()
- .filter(|f| f.contract_phase.as_deref() == Some("research"))
- .count();
-
- if research_files == 0 {
- reasons.push("No research documents created yet".to_string());
- suggestions.push("Create research notes or competitor analysis documents".to_string());
- } else {
- reasons.push(format!("{} research document(s) created", research_files));
- }
-
- let ready = research_files > 0;
- PhaseReadinessAnalysis {
- ready,
- summary: if ready {
- "Research phase has documentation. Consider transitioning to Specify phase.".to_string()
- } else {
- "Research phase needs more documentation before transitioning.".to_string()
- },
- reasons,
- suggestions,
- }
- }
- "specify" => {
- let spec_files = contract.files.iter()
- .filter(|f| f.contract_phase.as_deref() == Some("specify"))
- .count();
-
- if spec_files == 0 {
- reasons.push("No specification documents created yet".to_string());
- suggestions.push("Create requirements or user stories documents".to_string());
- } else {
- reasons.push(format!("{} specification document(s) created", spec_files));
- }
-
- let ready = spec_files > 0;
- PhaseReadinessAnalysis {
- ready,
- summary: if ready {
- "Specification phase has documentation. Consider transitioning to Plan phase.".to_string()
- } else {
- "Specification phase needs requirements or user stories.".to_string()
- },
- reasons,
- suggestions,
- }
- }
- "plan" => {
- let plan_files = contract.files.iter()
- .filter(|f| f.contract_phase.as_deref() == Some("plan"))
- .count();
-
- let has_repos = !contract.repositories.is_empty();
-
- if plan_files == 0 {
- reasons.push("No planning documents created yet".to_string());
- suggestions.push("Create architecture or task breakdown documents".to_string());
- } else {
- reasons.push(format!("{} planning document(s) created", plan_files));
- }
-
- if !has_repos {
- reasons.push("No repositories configured".to_string());
- suggestions.push("Add a repository for task execution".to_string());
- } else {
- reasons.push(format!("{} repository(ies) configured", contract.repositories.len()));
- }
-
- let ready = plan_files > 0 && has_repos;
- PhaseReadinessAnalysis {
- ready,
- summary: if ready {
- "Planning phase complete with documents and repositories. Ready for Execute phase.".to_string()
- } else {
- "Planning phase needs documentation and/or repository configuration.".to_string()
- },
- reasons,
- suggestions,
- }
- }
- "execute" => {
- let total_tasks = contract.tasks.len();
- let done_tasks = contract.tasks.iter().filter(|t| t.status == "done").count();
- let running_tasks = contract.tasks.iter().filter(|t| t.status == "running").count();
-
- if total_tasks == 0 {
- reasons.push("No tasks created yet".to_string());
- suggestions.push("Create tasks to implement the planned work".to_string());
- } else {
- reasons.push(format!("{} of {} tasks completed", done_tasks, total_tasks));
- }
-
- if running_tasks > 0 {
- reasons.push(format!("{} task(s) still running", running_tasks));
- suggestions.push("Wait for running tasks to complete".to_string());
- }
-
- let ready = total_tasks > 0 && done_tasks == total_tasks;
-
- // For simple contracts, execute is the terminal phase - suggest completion
- if ready && contract.contract.contract_type == "simple" {
- suggestions.push("Mark the contract as completed".to_string());
- }
-
- PhaseReadinessAnalysis {
- ready,
- summary: if ready {
- if contract.contract.contract_type == "simple" {
- "All tasks completed. Contract can be marked as completed.".to_string()
- } else {
- "All tasks completed. Ready for Review phase.".to_string()
- }
- } else if total_tasks == 0 {
- "No tasks created yet. Create and complete tasks before reviewing.".to_string()
- } else {
- format!("{}/{} tasks complete. Finish remaining tasks before review.", done_tasks, total_tasks)
- },
- reasons,
- suggestions,
- }
- }
- "review" => {
- let review_files = contract.files.iter()
- .filter(|f| f.contract_phase.as_deref() == Some("review"))
- .count();
-
- if review_files == 0 {
- suggestions.push("Create review checklist or release notes".to_string());
- } else {
- // Review documentation exists - suggest completion
- suggestions.push("Mark the contract as completed".to_string());
- }
-
- PhaseReadinessAnalysis {
- ready: review_files > 0,
- summary: if review_files > 0 {
- "Review documentation complete. Contract can be marked as completed.".to_string()
- } else {
- "Review phase needs documentation before completion.".to_string()
- },
- reasons: vec!["Review is the final phase".to_string()],
- suggestions,
- }
- }
- _ => PhaseReadinessAnalysis {
- ready: false,
- summary: "Unknown phase".to_string(),
- reasons: vec!["Phase not recognized".to_string()],
- suggestions: vec![],
- },
- }
-}
-
-// =============================================================================
-// Contract Chat History Endpoints
-// =============================================================================
-
-/// Get contract chat history
-#[utoipa::path(
- get,
- path = "/api/v1/contracts/{id}/chat/history",
- responses(
- (status = 200, description = "Chat history retrieved successfully", body = ContractChatHistoryResponse),
- (status = 401, description = "Unauthorized"),
- (status = 404, description = "Contract not found"),
- (status = 500, description = "Internal server error")
- ),
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn get_contract_chat_history(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(contract_id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "Database not configured" })),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
- Ok(Some(_)) => {}
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(json!({ "error": "Contract not found" })),
- )
- .into_response();
- }
- Err(e) => {
- tracing::error!("Database error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Database error: {}", e) })),
- )
- .into_response();
- }
- }
-
- // Get or create conversation
- let conversation = match repository::get_or_create_contract_conversation(pool, contract_id, auth.owner_id).await {
- Ok(conv) => conv,
- Err(e) => {
- tracing::error!("Failed to get contract conversation: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Failed to get conversation: {}", e) })),
- )
- .into_response();
- }
- };
-
- // Get messages
- let messages = match repository::list_contract_chat_messages(pool, conversation.id, Some(100)).await {
- Ok(msgs) => msgs,
- Err(e) => {
- tracing::error!("Failed to list contract chat messages: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Failed to list messages: {}", e) })),
- )
- .into_response();
- }
- };
-
- (
- StatusCode::OK,
- Json(ContractChatHistoryResponse {
- contract_id,
- conversation_id: conversation.id,
- messages,
- }),
- )
- .into_response()
-}
-
-/// Clear contract chat history (creates a new conversation)
-#[utoipa::path(
- delete,
- path = "/api/v1/contracts/{id}/chat/history",
- responses(
- (status = 200, description = "Chat history cleared successfully"),
- (status = 401, description = "Unauthorized"),
- (status = 404, description = "Contract not found"),
- (status = 500, description = "Internal server error")
- ),
- params(
- ("id" = Uuid, Path, description = "Contract ID")
- ),
- security(
- ("bearer_auth" = []),
- ("api_key" = [])
- ),
- tag = "Contracts"
-)]
-pub async fn clear_contract_chat_history(
- State(state): State<SharedState>,
- Authenticated(auth): Authenticated,
- Path(contract_id): Path<Uuid>,
-) -> impl IntoResponse {
- let Some(ref pool) = state.db_pool else {
- return (
- StatusCode::SERVICE_UNAVAILABLE,
- Json(json!({ "error": "Database not configured" })),
- )
- .into_response();
- };
-
- // Verify contract exists and belongs to owner
- match repository::get_contract_for_owner(pool, contract_id, auth.owner_id).await {
- Ok(Some(_)) => {}
- Ok(None) => {
- return (
- StatusCode::NOT_FOUND,
- Json(json!({ "error": "Contract not found" })),
- )
- .into_response();
- }
- Err(e) => {
- tracing::error!("Database error: {}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Database error: {}", e) })),
- )
- .into_response();
- }
- }
-
- // Clear conversation (archives existing and creates new)
- match repository::clear_contract_conversation(pool, contract_id, auth.owner_id).await {
- Ok(new_conversation) => {
- (
- StatusCode::OK,
- Json(json!({
- "message": "Chat history cleared",
- "newConversationId": new_conversation.id
- })),
- )
- .into_response()
- }
- Err(e) => {
- tracing::error!("Failed to clear contract conversation: {}", e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({ "error": format!("Failed to clear history: {}", e) })),
- )
- .into_response()
- }
- }
-}