//! HTTP handlers for contract CRUD operations. use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, Json, }; use serde::Deserialize; use utoipa::ToSchema; use uuid::Uuid; use crate::db::models::{ AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, CreateContractRequest, CreateManagedRepositoryRequest, PhaseChangeResult, UpdateContractRequest, UpdateTaskRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::llm::PhaseDeliverables; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; // ============================================================================= // Deliverable Validation // ============================================================================= /// Error type for deliverable validation failures #[derive(Debug, Clone)] pub struct DeliverableValidationError { /// The error message with details about valid deliverables pub message: String, } impl DeliverableValidationError { pub fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl std::fmt::Display for DeliverableValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.message) } } impl std::error::Error for DeliverableValidationError {} /// Validates that a deliverable ID is valid for the given phase deliverables. /// /// # Arguments /// * `deliverable_id` - The deliverable ID to validate /// * `phase_deliverables` - The phase deliverables configuration to validate against /// /// # Returns /// * `Ok(())` if the deliverable is valid /// * `Err(DeliverableValidationError)` if the deliverable is not valid pub fn validate_deliverable( deliverable_id: &str, phase_deliverables: &PhaseDeliverables, ) -> Result<(), DeliverableValidationError> { let valid_deliverable = phase_deliverables .deliverables .iter() .any(|d| d.id == deliverable_id); if valid_deliverable { Ok(()) } else { let valid_ids: Vec<&str> = phase_deliverables .deliverables .iter() .map(|d| d.id.as_str()) .collect(); Err(DeliverableValidationError::new(format!( "Invalid deliverable '{}' for {} phase. Valid IDs: [{}]", deliverable_id, phase_deliverables.phase, valid_ids.join(", ") ))) } } // ============================================================================= // Supervisor Repository Update Helper // ============================================================================= /// Helper function to update the supervisor task with repository info when a primary repo is added. /// This ensures the supervisor has access to the repository when it starts. async fn update_supervisor_with_repo_if_needed( pool: &sqlx::PgPool, contract_id: uuid::Uuid, owner_id: uuid::Uuid, repo: &ContractRepository, ) { // Only update for primary repositories if !repo.is_primary { return; } // Get the supervisor task let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await { Ok(Some(s)) => s, Ok(None) => { tracing::debug!(contract_id = %contract_id, "No supervisor task found"); return; } Err(e) => { tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task"); return; } }; // Only update if supervisor doesn't have a repository URL yet if supervisor.repository_url.is_some() { tracing::debug!( supervisor_id = %supervisor.id, "Supervisor already has repository URL" ); return; } // Get repository URL (for remote repos) or local path (for local repos) let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone()); if repo_url.is_none() && repo.source_type != "managed" { tracing::debug!( supervisor_id = %supervisor.id, "Repository has no URL or path to assign" ); return; } // Update supervisor task with repository info let update_req = UpdateTaskRequest { repository_url: repo_url, version: Some(supervisor.version), ..Default::default() }; match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await { Ok(Some(updated)) => { tracing::info!( supervisor_id = %updated.id, repository_url = ?updated.repository_url, "Updated supervisor task with repository URL" ); } Ok(None) => { tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update"); } Err(e) => { tracing::warn!( supervisor_id = %supervisor.id, error = %e, "Failed to update supervisor with repository URL" ); } } } /// List all root contracts (no parent) for the authenticated user's owner. #[utoipa::path( get, path = "/api/v1/contracts", responses( (status = 200, description = "List of root contracts", body = ContractListResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn list_contracts( State(state): State, Authenticated(auth): Authenticated, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::list_contracts_for_owner(pool, auth.owner_id).await { Ok(contracts) => { let total = contracts.len() as i64; Json(ContractListResponse { contracts, total }).into_response() } Err(e) => { tracing::error!("Failed to list contracts: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get a contract by ID with all its relations (repositories, files, tasks). #[utoipa::path( get, path = "/api/v1/contracts/{id}", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 200, description = "Contract details with relations", body = ContractWithRelations), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn get_contract( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Get the contract let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(c)) => c, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Get repositories let repositories = match repository::list_contract_repositories(pool, id).await { Ok(r) => r, Err(e) => { tracing::warn!("Failed to get repositories for {}: {}", id, e); Vec::new() } }; // Get files let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { Ok(f) => f, Err(e) => { tracing::warn!("Failed to get files for contract {}: {}", id, e); Vec::new() } }; // Get tasks let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { Ok(t) => t, Err(e) => { tracing::warn!("Failed to get tasks for contract {}: {}", id, e); Vec::new() } }; Json(ContractWithRelations { contract, repositories, files, tasks, }) .into_response() } /// Create a new contract. #[utoipa::path( post, path = "/api/v1/contracts", request_body = CreateContractRequest, responses( (status = 201, description = "Contract created", body = ContractSummary), (status = 400, description = "Invalid request", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn create_contract( State(state): State, Authenticated(auth): Authenticated, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await { Ok(contract) => { // Create supervisor task for this contract let supervisor_name = format!("{} Supervisor", contract.name); let supervisor_plan = format!( "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", contract.name, contract.description.as_deref().unwrap_or("No description provided.") ); // Get repository info from contract if available let repo_url = { // Try to get the first repository associated with this contract match repository::list_contract_repositories(pool, contract.id).await { Ok(repos) if !repos.is_empty() => { let repo = &repos[0]; repo.repository_url.clone() } _ => None, } }; let supervisor_req = crate::db::models::CreateTaskRequest { name: supervisor_name, description: None, plan: supervisor_plan, repository_url: repo_url, base_branch: None, target_branch: None, parent_task_id: None, contract_id: Some(contract.id), target_repo_path: None, completion_action: None, continue_from_task_id: None, copy_files: None, is_supervisor: true, is_red_team: false, checkpoint_sha: None, priority: 0, merge_mode: None, branched_from_task_id: None, conversation_history: None, supervisor_worktree_task_id: None, // Supervisor uses its own worktree }; match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { Ok(supervisor_task) => { tracing::info!( contract_id = %contract.id, supervisor_task_id = %supervisor_task.id, is_supervisor = supervisor_task.is_supervisor, "Created supervisor task for contract" ); // Update contract with supervisor_task_id let update_req = crate::db::models::UpdateContractRequest { supervisor_task_id: Some(supervisor_task.id), version: Some(contract.version), ..Default::default() }; if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await { tracing::warn!( contract_id = %contract.id, error = %e, "Failed to link supervisor task to contract" ); } } Err(e) => { tracing::warn!( contract_id = %contract.id, error = %e, "Failed to create supervisor task for contract" ); } } // Record history event for contract creation let _ = repository::record_history_event( pool, auth.owner_id, Some(contract.id), None, "contract", Some("created"), Some(&contract.phase), serde_json::json!({ "name": &contract.name, "type": &contract.contract_type, "description": &contract.description, }), ).await; // Get the summary version with counts match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await { Ok(Some(summary)) => (StatusCode::CREATED, Json(summary)).into_response(), Ok(None) => { // Shouldn't happen, but return basic info if it does ( StatusCode::CREATED, Json(ContractSummary { id: contract.id, name: contract.name, description: contract.description, contract_type: contract.contract_type, phase: contract.phase, status: contract.status, supervisor_task_id: contract.supervisor_task_id, local_only: contract.local_only, auto_merge_local: contract.auto_merge_local, red_team_enabled: contract.red_team_enabled, file_count: 0, task_count: 0, repository_count: 0, version: contract.version, created_at: contract.created_at, }), ) .into_response() } Err(e) => { tracing::warn!("Failed to get contract summary: {}", e); ( StatusCode::CREATED, Json(ContractSummary { id: contract.id, name: contract.name, description: contract.description, contract_type: contract.contract_type, phase: contract.phase, status: contract.status, supervisor_task_id: contract.supervisor_task_id, local_only: contract.local_only, auto_merge_local: contract.auto_merge_local, red_team_enabled: contract.red_team_enabled, file_count: 0, task_count: 0, repository_count: 0, version: contract.version, created_at: contract.created_at, }), ) .into_response() } } } Err(e) => { tracing::error!("Failed to create contract: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Update a contract. #[utoipa::path( put, path = "/api/v1/contracts/{id}", params( ("id" = Uuid, Path, description = "Contract ID") ), request_body = UpdateContractRequest, responses( (status = 200, description = "Contract updated", body = ContractSummary), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 409, description = "Version conflict", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn update_contract( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::update_contract_for_owner(pool, id, auth.owner_id, req).await { Ok(Some(contract)) => { // If contract is completed, stop the supervisor task and clean up worktrees if contract.status == "completed" { if let Some(supervisor_task_id) = contract.supervisor_task_id { // Get the supervisor task to find its daemon if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { if let Some(daemon_id) = supervisor.daemon_id { let state_clone = state.clone(); tokio::spawn(async move { // Gracefully interrupt the supervisor let cmd = crate::server::state::DaemonCommand::InterruptTask { task_id: supervisor_task_id, graceful: true, }; if let Err(e) = state_clone.send_daemon_command(daemon_id, cmd).await { tracing::warn!( supervisor_task_id = %supervisor_task_id, daemon_id = %daemon_id, error = %e, "Failed to stop supervisor task on contract completion" ); } else { tracing::info!( supervisor_task_id = %supervisor_task_id, contract_id = %id, "Stopped supervisor task on contract completion" ); } }); } } } // Clean up all task worktrees for this contract let pool_clone = pool.clone(); let state_clone = state.clone(); let contract_id = id; tokio::spawn(async move { cleanup_contract_worktrees(&pool_clone, &state_clone, contract_id).await; }); // Record history event for contract completion let _ = repository::record_history_event( pool, auth.owner_id, Some(contract.id), None, "contract", Some("completed"), Some(&contract.phase), serde_json::json!({ "name": &contract.name, "status": &contract.status, }), ).await; } // Get summary with counts match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await { Ok(Some(summary)) => Json(summary).into_response(), _ => Json(ContractSummary { id: contract.id, name: contract.name, description: contract.description, contract_type: contract.contract_type, phase: contract.phase, status: contract.status, supervisor_task_id: contract.supervisor_task_id, local_only: contract.local_only, auto_merge_local: contract.auto_merge_local, red_team_enabled: contract.red_team_enabled, file_count: 0, task_count: 0, repository_count: 0, version: contract.version, created_at: contract.created_at, }) .into_response(), } } Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(), Err(RepositoryError::VersionConflict { expected, actual }) => { tracing::info!( "Version conflict on contract {}: expected {}, actual {}", id, expected, actual ); ( StatusCode::CONFLICT, Json(serde_json::json!({ "code": "VERSION_CONFLICT", "message": format!( "Contract was modified. Expected version {}, actual version {}", expected, actual ), "expectedVersion": expected, "actualVersion": actual, })), ) .into_response() } Err(RepositoryError::Database(e)) => { tracing::error!("Failed to update contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Delete a contract. #[utoipa::path( delete, path = "/api/v1/contracts/{id}", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 204, description = "Contract deleted"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn delete_contract( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // First, verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } // Clean up any pending supervisor questions for this contract state.remove_pending_questions_for_contract(id); // Clean up all task worktrees BEFORE deleting the contract // (because CASCADE delete will remove tasks from DB) cleanup_contract_worktrees(pool, &state, id).await; match repository::delete_contract_for_owner(pool, id, auth.owner_id).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to delete contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } // ============================================================================= // Repository Management // ============================================================================= /// Add a remote repository to a contract. #[utoipa::path( post, path = "/api/v1/contracts/{id}/repositories/remote", params( ("id" = Uuid, Path, description = "Contract ID") ), request_body = AddRemoteRepositoryRequest, responses( (status = 201, description = "Repository added", body = ContractRepository), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn add_remote_repository( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::add_remote_repository(pool, id, &req.name, &req.repository_url, req.is_primary) .await { Ok(repo) => { // Update supervisor task with repository info if this is a primary repo update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; // Track repository in history for future suggestions if let Err(e) = repository::add_or_update_repository_history( pool, auth.owner_id, &req.name, Some(&req.repository_url), None, "remote", ) .await { // Log but don't fail the request if history tracking fails tracing::warn!("Failed to track repository in history: {}", e); } (StatusCode::CREATED, Json(repo)).into_response() } Err(e) => { tracing::error!("Failed to add remote repository to contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Add a local repository to a contract. #[utoipa::path( post, path = "/api/v1/contracts/{id}/repositories/local", params( ("id" = Uuid, Path, description = "Contract ID") ), request_body = AddLocalRepositoryRequest, responses( (status = 201, description = "Repository added", body = ContractRepository), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn add_local_repository( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::add_local_repository(pool, id, &req.name, &req.local_path, req.is_primary) .await { Ok(repo) => { // Update supervisor task with repository info if this is a primary repo update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; // Track repository in history for future suggestions if let Err(e) = repository::add_or_update_repository_history( pool, auth.owner_id, &req.name, None, Some(&req.local_path), "local", ) .await { // Log but don't fail the request if history tracking fails tracing::warn!("Failed to track repository in history: {}", e); } (StatusCode::CREATED, Json(repo)).into_response() } Err(e) => { tracing::error!("Failed to add local repository to contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Create a managed repository (daemon will create it). #[utoipa::path( post, path = "/api/v1/contracts/{id}/repositories/managed", params( ("id" = Uuid, Path, description = "Contract ID") ), request_body = CreateManagedRepositoryRequest, responses( (status = 201, description = "Repository creation requested", body = ContractRepository), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn create_managed_repository( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::create_managed_repository(pool, id, &req.name, req.is_primary).await { Ok(repo) => { // For managed repos, the daemon will create the repo and we'll update later // For now, just mark that this is a managed repo configuration // The helper handles the case where repo has no URL yet update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; (StatusCode::CREATED, Json(repo)).into_response() } Err(e) => { tracing::error!( "Failed to create managed repository for contract {}: {}", id, e ); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Delete a repository from a contract. #[utoipa::path( delete, path = "/api/v1/contracts/{id}/repositories/{repo_id}", params( ("id" = Uuid, Path, description = "Contract ID"), ("repo_id" = Uuid, Path, description = "Repository ID") ), responses( (status = 204, description = "Repository removed"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract or repository not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn delete_repository( State(state): State, Authenticated(auth): Authenticated, Path((id, repo_id)): Path<(Uuid, Uuid)>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::delete_contract_repository(pool, repo_id, id).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Repository not found")), ) .into_response(), Err(e) => { tracing::error!( "Failed to delete repository {} from contract {}: {}", repo_id, id, e ); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Set a repository as primary for a contract. #[utoipa::path( put, path = "/api/v1/contracts/{id}/repositories/{repo_id}/primary", params( ("id" = Uuid, Path, description = "Contract ID"), ("repo_id" = Uuid, Path, description = "Repository ID") ), responses( (status = 204, description = "Repository set as primary"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract or repository not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn set_repository_primary( State(state): State, Authenticated(auth): Authenticated, Path((id, repo_id)): Path<(Uuid, Uuid)>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::set_repository_primary(pool, repo_id, id).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Repository not found")), ) .into_response(), Err(e) => { tracing::error!( "Failed to set repository {} as primary for contract {}: {}", repo_id, id, e ); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } // ============================================================================= // Task Association // ============================================================================= /// Add a task to a contract. #[utoipa::path( post, path = "/api/v1/contracts/{id}/tasks/{task_id}", params( ("id" = Uuid, Path, description = "Contract ID"), ("task_id" = Uuid, Path, description = "Task ID") ), responses( (status = 204, description = "Task added to contract"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract or task not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn add_task_to_contract( State(state): State, Authenticated(auth): Authenticated, Path((id, task_id)): Path<(Uuid, Uuid)>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } // Verify task exists and belongs to owner match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Task not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get task {}: {}", task_id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::add_task_to_contract(pool, id, task_id, auth.owner_id).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Task not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to add task {} to contract {}: {}", task_id, id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Remove a task from a contract. #[utoipa::path( delete, path = "/api/v1/contracts/{id}/tasks/{task_id}", params( ("id" = Uuid, Path, description = "Contract ID"), ("task_id" = Uuid, Path, description = "Task ID") ), responses( (status = 204, description = "Task removed from contract"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract or task not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn remove_task_from_contract( State(state): State, Authenticated(auth): Authenticated, Path((id, task_id)): Path<(Uuid, Uuid)>, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::remove_task_from_contract(pool, id, task_id, auth.owner_id).await { Ok(true) => StatusCode::NO_CONTENT.into_response(), Ok(false) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Task not found in this contract")), ) .into_response(), Err(e) => { tracing::error!( "Failed to remove task {} from contract {}: {}", task_id, id, e ); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } // ============================================================================= // Phase Management // ============================================================================= /// Change contract phase. #[utoipa::path( post, path = "/api/v1/contracts/{id}/phase", params( ("id" = Uuid, Path, description = "Contract ID") ), request_body = ChangePhaseRequest, responses( (status = 200, description = "Phase changed", body = ContractSummary), (status = 400, description = "Validation failed", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 409, description = "Version conflict", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn change_phase( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // First, get the contract to check phase_guard let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(c)) => c, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // If phase_guard is enabled and not confirmed, return phase deliverables for review // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level if contract.phase_guard && !req.confirmed.unwrap_or(false) { // If user provided feedback, return it if let Some(ref feedback) = req.feedback { return Json(serde_json::json!({ "status": "changes_requested", "currentPhase": contract.phase, "requestedPhase": req.phase, "feedback": feedback, "message": "Feedback has been noted. Address the changes and try again." })) .into_response(); } // Get files created in this phase let phase_files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { Ok(files) => files .into_iter() .filter(|f| f.contract_phase.as_deref() == Some(&contract.phase)) .map(|f| serde_json::json!({ "id": f.id, "name": f.name, "description": f.description })) .collect::>(), Err(_) => Vec::new(), }; // Get tasks completed in this contract let phase_tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { Ok(tasks) => tasks .into_iter() .filter(|t| t.status == "done" || t.status == "completed") .map(|t| serde_json::json!({ "id": t.id, "name": t.name, "status": t.status })) .collect::>(), Err(_) => Vec::new(), }; // Get phase deliverables with completion status let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); let completed_deliverables = contract.get_completed_deliverables(&contract.phase); let deliverables: Vec = phase_deliverables .deliverables .iter() .map(|d| serde_json::json!({ "id": d.id, "name": d.name, "completed": completed_deliverables.contains(&d.id) })) .collect(); let deliverables_summary = format!( "Phase '{}' deliverables: {} files created, {} tasks completed.", contract.phase, phase_files.len(), phase_tasks.len() ); let transition_id = uuid::Uuid::new_v4().to_string(); return Json(serde_json::json!({ "status": "requires_confirmation", "transitionId": transition_id, "currentPhase": contract.phase, "nextPhase": req.phase, "deliverablesSummary": deliverables_summary, "deliverables": deliverables, "phaseFiles": phase_files, "phaseTasks": phase_tasks, "requiresConfirmation": true, "message": "Phase guard is enabled. User confirmation required." })) .into_response(); } // Phase guard is disabled or user confirmed - proceed with phase change // Use the version-checking function for explicit conflict detection match repository::change_contract_phase_with_version( pool, id, auth.owner_id, &req.phase, req.expected_version, ) .await { Ok(PhaseChangeResult::Success(updated_contract)) => { // Notify supervisor of phase change if let Some(supervisor_task_id) = updated_contract.supervisor_task_id { if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { let state_clone = state.clone(); let contract_id = updated_contract.id; let new_phase = updated_contract.phase.clone(); tokio::spawn(async move { state_clone.notify_supervisor_of_phase_change( supervisor.id, supervisor.daemon_id, contract_id, &new_phase, ).await; }); } } // Record history event for phase change let _ = repository::record_history_event( pool, auth.owner_id, Some(contract.id), None, "phase", Some("changed"), Some(&contract.phase), serde_json::json!({ "contractName": &contract.name, "newPhase": &updated_contract.phase, }), ).await; // Get summary with counts match repository::get_contract_summary_for_owner(pool, updated_contract.id, auth.owner_id).await { Ok(Some(summary)) => Json(summary).into_response(), _ => Json(ContractSummary { id: updated_contract.id, name: updated_contract.name, description: updated_contract.description, contract_type: updated_contract.contract_type, phase: updated_contract.phase, status: updated_contract.status, supervisor_task_id: updated_contract.supervisor_task_id, local_only: updated_contract.local_only, auto_merge_local: updated_contract.auto_merge_local, red_team_enabled: updated_contract.red_team_enabled, file_count: 0, task_count: 0, repository_count: 0, version: updated_contract.version, created_at: updated_contract.created_at, }) .into_response(), } } Ok(PhaseChangeResult::VersionConflict { expected, actual, current_phase }) => { tracing::info!( contract_id = %id, expected_version = expected, actual_version = actual, current_phase = %current_phase, "Phase change failed due to version conflict" ); ( StatusCode::CONFLICT, Json(serde_json::json!({ "code": "VERSION_CONFLICT", "message": "Phase change failed due to concurrent modification", "details": { "expected_version": expected, "actual_version": actual, "current_phase": current_phase } })), ) .into_response() } Ok(PhaseChangeResult::ValidationFailed { reason, missing_requirements }) => { tracing::warn!( contract_id = %id, reason = %reason, "Phase change validation failed" ); ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "code": "VALIDATION_FAILED", "message": reason, "details": { "missing_requirements": missing_requirements } })), ) .into_response() } Ok(PhaseChangeResult::NotFound) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(), Ok(PhaseChangeResult::Unauthorized) => ( StatusCode::UNAUTHORIZED, Json(ApiError::new("UNAUTHORIZED", "Not authorized to change this contract's phase")), ) .into_response(), Err(e) => { tracing::error!("Failed to change phase for contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } // ============================================================================= // Deliverables // ============================================================================= /// Request body for marking a deliverable complete #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct MarkDeliverableRequest { /// The deliverable ID to mark as complete (e.g., 'plan-document', 'pull-request') pub deliverable_id: String, /// Phase the deliverable belongs to. Defaults to current contract phase if not specified. pub phase: Option, } /// Mark a deliverable as complete for a contract phase. #[utoipa::path( post, path = "/api/v1/contracts/{id}/deliverables/complete", params( ("id" = Uuid, Path, description = "Contract ID") ), request_body = MarkDeliverableRequest, responses( (status = 200, description = "Deliverable marked complete", body = serde_json::Value), (status = 400, description = "Invalid deliverable ID", body = ApiError), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn mark_deliverable_complete( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Get contract let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(c)) => c, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Use specified phase or default to current contract phase let target_phase = req.phase.unwrap_or_else(|| contract.phase.clone()); // Validate the deliverable ID exists for this phase/contract type // Use custom phase_config if present, otherwise fall back to built-in contract types let phase_config = contract.get_phase_config(); let phase_deliverables = crate::llm::get_phase_deliverables_with_config( &target_phase, &contract.contract_type, phase_config.as_ref(), ); // Validate deliverable exists if let Err(validation_error) = validate_deliverable(&req.deliverable_id, &phase_deliverables) { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "code": "INVALID_DELIVERABLE", "message": validation_error.message, })), ) .into_response(); } // Check if already completed if contract.is_deliverable_complete(&target_phase, &req.deliverable_id) { return Json(serde_json::json!({ "success": true, "message": format!("Deliverable '{}' is already marked complete for {} phase", req.deliverable_id, target_phase), "deliverableId": req.deliverable_id, "phase": target_phase, "alreadyComplete": true, })) .into_response(); } // Mark the deliverable as complete match repository::mark_deliverable_complete(pool, id, &target_phase, &req.deliverable_id).await { Ok(updated_contract) => { let completed = updated_contract.get_completed_deliverables(&target_phase); Json(serde_json::json!({ "success": true, "message": format!("Marked deliverable '{}' as complete for {} phase", req.deliverable_id, target_phase), "deliverableId": req.deliverable_id, "phase": target_phase, "completedDeliverables": completed, })) .into_response() } Err(e) => { tracing::error!("Failed to mark deliverable complete for contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } // ============================================================================= // Events // ============================================================================= /// Get contract event history. #[utoipa::path( get, path = "/api/v1/contracts/{id}/events", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 200, description = "Event history", body = Vec), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn get_events( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } match repository::list_contract_events(pool, id).await { Ok(events) => Json(events).into_response(), Err(e) => { tracing::error!("Failed to get events for contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } // ============================================================================= // Internal Helper Functions // ============================================================================= /// Clean up all worktrees for tasks in a contract. /// /// This is called when a contract is completed or deleted to remove /// all associated task worktrees from connected daemons. async fn cleanup_contract_worktrees( pool: &sqlx::PgPool, state: &SharedState, contract_id: Uuid, ) { tracing::info!( contract_id = %contract_id, "Cleaning up worktrees for contract tasks" ); // Get all tasks with worktree info for this contract let tasks = match repository::list_contract_tasks_with_worktree_info(pool, contract_id).await { Ok(tasks) => tasks, Err(e) => { tracing::error!( contract_id = %contract_id, error = %e, "Failed to list tasks for worktree cleanup" ); return; } }; if tasks.is_empty() { tracing::debug!( contract_id = %contract_id, "No tasks with worktrees to clean up" ); return; } tracing::info!( contract_id = %contract_id, task_count = tasks.len(), "Found tasks with worktrees to clean up" ); // Send cleanup command to each task's daemon // Skip tasks that share a supervisor's worktree (they don't own the worktree) for task in tasks { // Skip tasks that reuse the supervisor's worktree - the supervisor owns it if task.supervisor_worktree_task_id.is_some() { tracing::debug!( task_id = %task.id, supervisor_worktree_task_id = ?task.supervisor_worktree_task_id, contract_id = %contract_id, "Task shares supervisor worktree, skipping worktree cleanup" ); continue; } if let Some(daemon_id) = task.daemon_id { let cmd = crate::server::state::DaemonCommand::CleanupWorktree { task_id: task.id, delete_branch: true, // Delete the branch when contract is done }; match state.send_daemon_command(daemon_id, cmd).await { Ok(()) => { tracing::info!( task_id = %task.id, daemon_id = %daemon_id, contract_id = %contract_id, "Sent worktree cleanup command" ); } Err(e) => { tracing::warn!( task_id = %task.id, daemon_id = %daemon_id, contract_id = %contract_id, error = %e, "Failed to send worktree cleanup command (daemon may be offline)" ); } } } else { tracing::debug!( task_id = %task.id, contract_id = %contract_id, "Task has no daemon assigned, skipping worktree cleanup" ); } } } // ============================================================================= // Supervisor Status API // ============================================================================= /// Query parameters for supervisor heartbeat history #[derive(Debug, Deserialize)] pub struct HeartbeatHistoryQuery { /// Maximum number of heartbeats to return (default: 10) pub limit: Option, /// Offset for pagination (default: 0) pub offset: Option, } /// Get supervisor status for a contract. #[utoipa::path( get, path = "/api/v1/contracts/{id}/supervisor/status", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 200, description = "Supervisor status", body = crate::db::models::SupervisorStatusResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract or supervisor not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn get_supervisor_status( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(c)) => c, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Check if contract has a supervisor let supervisor_task_id = match contract.supervisor_task_id { Some(task_id) => task_id, None => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), ) .into_response(); } }; // Get supervisor status from supervisor_states table match repository::get_supervisor_status(pool, id, auth.owner_id).await { Ok(Some(status_info)) => { // Determine if supervisor is actively running let is_running = status_info.is_running && status_info.task_status == "running"; let response = crate::db::models::SupervisorStatusResponse { task_id: status_info.task_id, state: status_info.supervisor_state, phase: status_info.phase, current_activity: status_info.current_activity, progress: None, // We don't track progress percentage yet last_heartbeat: status_info.last_heartbeat, pending_task_ids: status_info.pending_task_ids, is_running, }; Json(response).into_response() } Ok(None) => { // No supervisor state record exists, but supervisor task might exist // Try to get info from the task itself match repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { Ok(Some(task)) => { let is_running = task.daemon_id.is_some() && task.status == "running"; let response = crate::db::models::SupervisorStatusResponse { task_id: task.id, state: task.status.clone(), phase: contract.phase.clone(), current_activity: task.progress_summary.clone(), progress: None, last_heartbeat: task.updated_at, pending_task_ids: Vec::new(), is_running, }; Json(response).into_response() } Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("SUPERVISOR_NOT_FOUND", "Supervisor task not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to get supervisor task {}: {}", supervisor_task_id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } Err(e) => { tracing::error!("Failed to get supervisor status for contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get supervisor heartbeat history for a contract. #[utoipa::path( get, path = "/api/v1/contracts/{id}/supervisor/heartbeats", params( ("id" = Uuid, Path, description = "Contract ID"), ("limit" = Option, Query, description = "Maximum number of heartbeats to return (default: 10)"), ("offset" = Option, Query, description = "Offset for pagination (default: 0)") ), responses( (status = 200, description = "Supervisor heartbeat history", body = crate::db::models::SupervisorHeartbeatHistoryResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn get_supervisor_heartbeats( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, axum::extract::Query(query): axum::extract::Query, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(_)) => {} Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } } let limit = query.limit.unwrap_or(10).min(100); // Cap at 100 let offset = query.offset.unwrap_or(0); // Get activity history as heartbeats let activities = match repository::get_supervisor_activity_history(pool, id, limit, offset).await { Ok(activities) => activities, Err(e) => { tracing::error!("Failed to get supervisor heartbeats for contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Get total count for pagination let total = match repository::count_supervisor_activity_history(pool, id).await { Ok(count) => count, Err(e) => { tracing::warn!("Failed to count supervisor heartbeats: {}", e); activities.len() as i64 } }; // Convert to heartbeat entries let heartbeats: Vec = activities .into_iter() .map(|a| crate::db::models::SupervisorHeartbeatEntry { timestamp: a.timestamp, state: a.state, activity: a.activity, progress: a.progress.map(|p| p as u8), phase: a.phase, pending_task_ids: a.pending_task_ids, }) .collect(); Json(crate::db::models::SupervisorHeartbeatHistoryResponse { heartbeats, total, }) .into_response() } /// Sync supervisor state (refresh last_activity timestamp). #[utoipa::path( post, path = "/api/v1/contracts/{id}/supervisor/sync", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 200, description = "Supervisor synced", body = crate::db::models::SupervisorSyncResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract or supervisor not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), (status = 500, description = "Internal server error", body = ApiError), ), security( ("bearer_auth" = []), ("api_key" = []) ), tag = "Contracts" )] pub async fn sync_supervisor( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists and belongs to owner let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(c)) => c, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Check if contract has a supervisor if contract.supervisor_task_id.is_none() { return ( StatusCode::NOT_FOUND, Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), ) .into_response(); } // Sync supervisor state (update last_activity) match repository::sync_supervisor_state(pool, id).await { Ok(Some(_state)) => { // Get task status to determine current state let task_status = if let Some(task_id) = contract.supervisor_task_id { match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { Ok(Some(task)) => task.status, _ => "unknown".to_string(), } } else { "unknown".to_string() }; Json(crate::db::models::SupervisorSyncResponse { synced: true, state: task_status, message: Some("Supervisor state synced successfully".to_string()), }) .into_response() } Ok(None) => { // No supervisor state exists, return not found ( StatusCode::NOT_FOUND, Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor state found for this contract")), ) .into_response() } Err(e) => { tracing::error!("Failed to sync supervisor state for contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } // ============================================================================= // Tests // ============================================================================= #[cfg(test)] mod tests { use super::*; use crate::db::models::{DeliverableDefinition, PhaseConfig, PhaseDefinition}; use crate::llm::{get_phase_deliverables_for_type, get_phase_deliverables_with_config}; use std::collections::HashMap; #[test] fn test_validate_deliverable_valid_simple_plan() { let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); let result = validate_deliverable("plan-document", &phase_deliverables); assert!(result.is_ok()); } #[test] fn test_validate_deliverable_valid_simple_execute() { let phase_deliverables = get_phase_deliverables_for_type("execute", "simple"); let result = validate_deliverable("pull-request", &phase_deliverables); assert!(result.is_ok()); } #[test] fn test_validate_deliverable_invalid_id() { let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); let result = validate_deliverable("nonexistent-deliverable", &phase_deliverables); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.message.contains("Invalid deliverable")); assert!(err.message.contains("nonexistent-deliverable")); assert!(err.message.contains("plan-document")); } #[test] fn test_validate_deliverable_specification_phases() { // Research phase let phase_deliverables = get_phase_deliverables_for_type("research", "specification"); assert!(validate_deliverable("research-notes", &phase_deliverables).is_ok()); assert!(validate_deliverable("invalid", &phase_deliverables).is_err()); // Specify phase let phase_deliverables = get_phase_deliverables_for_type("specify", "specification"); assert!(validate_deliverable("requirements-document", &phase_deliverables).is_ok()); assert!(validate_deliverable("plan-document", &phase_deliverables).is_err()); // Review phase let phase_deliverables = get_phase_deliverables_for_type("review", "specification"); assert!(validate_deliverable("release-notes", &phase_deliverables).is_ok()); } #[test] fn test_validate_deliverable_execute_type_no_deliverables() { // Execute-only contracts have no deliverables let phase_deliverables = get_phase_deliverables_for_type("execute", "execute"); // Any deliverable should fail since there are none let result = validate_deliverable("pull-request", &phase_deliverables); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.message.contains("Valid IDs: []")); } #[test] fn test_validate_deliverable_with_custom_phase_config() { // Create a custom phase config let mut deliverables = HashMap::new(); deliverables.insert( "design".to_string(), vec![ DeliverableDefinition { id: "architecture-doc".to_string(), name: "Architecture Document".to_string(), priority: "required".to_string(), }, DeliverableDefinition { id: "api-spec".to_string(), name: "API Specification".to_string(), priority: "recommended".to_string(), }, ], ); let phase_config = PhaseConfig { phases: vec![ PhaseDefinition { id: "design".to_string(), name: "Design".to_string(), order: 0, }, PhaseDefinition { id: "build".to_string(), name: "Build".to_string(), order: 1, }, ], default_phase: "design".to_string(), deliverables, }; // Validate against custom config let phase_deliverables = get_phase_deliverables_with_config("design", "custom", Some(&phase_config)); // Valid custom deliverables assert!(validate_deliverable("architecture-doc", &phase_deliverables).is_ok()); assert!(validate_deliverable("api-spec", &phase_deliverables).is_ok()); // Invalid deliverable for custom config let result = validate_deliverable("plan-document", &phase_deliverables); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.message.contains("Invalid deliverable")); assert!(err.message.contains("plan-document")); assert!(err.message.contains("architecture-doc")); assert!(err.message.contains("api-spec")); } #[test] fn test_validate_deliverable_error_message_format() { let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); let result = validate_deliverable("xyz", &phase_deliverables); let err = result.unwrap_err(); // Check error message format matches the specification assert!(err.message.contains("Invalid deliverable 'xyz'")); assert!(err.message.contains("plan phase")); assert!(err.message.contains("Valid IDs:")); assert!(err.message.contains("plan-document")); } #[test] fn test_deliverable_validation_error_display() { let err = DeliverableValidationError::new("Test error message"); assert_eq!(format!("{}", err), "Test error message"); } #[test] fn test_validate_deliverable_unknown_phase() { // Unknown phase should return empty deliverables let phase_deliverables = get_phase_deliverables_for_type("unknown", "simple"); let result = validate_deliverable("any-id", &phase_deliverables); assert!(result.is_err()); } }