summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/contracts.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers/contracts.rs')
-rw-r--r--makima/src/server/handlers/contracts.rs1284
1 files changed, 1284 insertions, 0 deletions
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs
new file mode 100644
index 0000000..3d726df
--- /dev/null
+++ b/makima/src/server/handlers/contracts.rs
@@ -0,0 +1,1284 @@
+//! 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<SharedState>,
+ 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<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<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();
+ };
+
+ // 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<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(req): Json<CreateContractRequest>,
+) -> 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<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<UpdateContractRequest>,
+) -> 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<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<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();
+ };
+
+ 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<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<AddRemoteRepositoryRequest>,
+) -> 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<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<AddLocalRepositoryRequest>,
+) -> 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<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<CreateManagedRepositoryRequest>,
+) -> 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<SharedState>,
+ 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<SharedState>,
+ 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<SharedState>,
+ 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<SharedState>,
+ 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<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<ChangePhaseRequest>,
+) -> 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<crate::db::models::ContractEvent>),
+ (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<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<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::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()
+ }
+ }
+}