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.rs2376
1 files changed, 0 insertions, 2376 deletions
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs
deleted file mode 100644
index bdd4d40..0000000
--- a/makima/src/server/handlers/contracts.rs
+++ /dev/null
@@ -1,2376 +0,0 @@
-//! 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<String>) -> 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<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: Some(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,
- branched_from_task_id: None,
- conversation_history: None,
- supervisor_worktree_task_id: None, // Supervisor uses its own worktree
- directive_id: None,
- directive_step_id: 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"
- );
- }
- }
-
- // 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,
- 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,
- 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 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,
- 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();
- };
-
- // 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<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;
-
- // 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<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;
-
- // 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<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 = 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<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();
- };
-
- // 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::<Vec<_>>(),
- 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::<Vec<_>>(),
- 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<serde_json::Value> = 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)) => {
- // Save supervisor state on phase change (Task 3.3)
- // This is a key save point for restoration
- let new_phase_for_state = updated_contract.phase.clone();
- let contract_id_for_state = updated_contract.id;
- let pool_for_state = pool.clone();
- tokio::spawn(async move {
- if let Err(e) = repository::update_supervisor_phase(&pool_for_state, contract_id_for_state, &new_phase_for_state).await {
- tracing::warn!(
- contract_id = %contract_id_for_state,
- new_phase = %new_phase_for_state,
- error = %e,
- "Failed to save supervisor state on phase change"
- );
- }
- });
-
- // 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,
- 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<String>,
-}
-
-/// 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<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- Json(req): Json<MarkDeliverableRequest>,
-) -> 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<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()
- }
- }
-}
-
-// =============================================================================
-// 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<i32>,
- /// Offset for pagination (default: 0)
- pub offset: Option<i32>,
-}
-
-/// 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<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
- 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<i32>, Query, description = "Maximum number of heartbeats to return (default: 10)"),
- ("offset" = Option<i32>, 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<SharedState>,
- Authenticated(auth): Authenticated,
- Path(id): Path<Uuid>,
- axum::extract::Query(query): axum::extract::Query<HeartbeatHistoryQuery>,
-) -> 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<crate::db::models::SupervisorHeartbeatEntry> = 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<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
- 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());
- }
-}