//! HTTP handlers for contract CRUD operations. use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, Json, }; use uuid::Uuid; use crate::db::models::{ AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, CreateContractRequest, CreateManagedRepositoryRequest, UpdateContractRequest, UpdateTaskRequest, }; use crate::db::repository::{self, RepositoryError}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; /// 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: contract.id, target_repo_path: None, completion_action: None, continue_from_task_id: None, copy_files: None, is_supervisor: true, checkpoint_sha: None, priority: 0, merge_mode: None, }; 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" ); } } // 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, phase: contract.phase, status: contract.status, 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, phase: contract.phase, status: contract.status, 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 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" ); } }); } } } } // 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, phase: contract.phase, status: contract.status, 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(); }; 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; (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; (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 = 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 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(); }; match repository::change_contract_phase_for_owner(pool, id, auth.owner_id, &req.phase).await { Ok(Some(contract)) => { // Notify supervisor of phase change if let Some(supervisor_task_id) = 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 = contract.id; let new_phase = contract.phase.clone(); tokio::spawn(async move { state_clone.notify_supervisor_of_phase_change( supervisor.id, supervisor.daemon_id, contract_id, &new_phase, ).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, phase: contract.phase, status: contract.status, 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(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() } } } // ============================================================================= // 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() } } }