//! HTTP handlers for daemon-to-contract interaction. //! //! These endpoints allow tasks running in daemons to interact with their //! associated contracts via the contract.sh script. Authentication is via //! tool keys registered by the daemon when starting a task. use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, Json, }; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use crate::db::{models::FileSummary, repository}; use crate::llm::phase_guidance::{self, PhaseChecklist, TaskInfo}; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; // ============================================================================= // Request/Response Types // ============================================================================= /// Contract status response for daemon. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ContractStatusResponse { pub id: Uuid, pub name: String, pub phase: String, pub status: String, pub description: Option, } /// Contract goals response. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ContractGoalsResponse { /// Description serves as goals for the contract pub description: Option, pub phase: String, pub phase_guidance: String, } /// Progress report request from daemon. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProgressReportRequest { pub message: String, #[serde(default)] pub task_id: Option, } /// Suggested action from server. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct SuggestedActionResponse { pub action: String, pub description: String, #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, } /// Completion action request. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CompletionActionRequest { #[serde(default)] pub task_id: Option, #[serde(default)] pub files_modified: Vec, #[serde(default)] pub lines_added: i32, #[serde(default)] pub lines_removed: i32, #[serde(default)] pub has_code_changes: bool, } /// Recommended completion action. #[derive(Debug, Clone, Serialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum CompletionAction { Branch, Merge, Pr, None, } impl std::fmt::Display for CompletionAction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CompletionAction::Branch => write!(f, "branch"), CompletionAction::Merge => write!(f, "merge"), CompletionAction::Pr => write!(f, "pr"), CompletionAction::None => write!(f, "none"), } } } /// Completion action response. #[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CompletionActionResponse { pub action: String, pub reason: String, #[serde(skip_serializing_if = "Option::is_none")] pub branch_name: Option, } /// Create file request from daemon. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct CreateFileRequest { pub name: String, pub content: String, #[serde(default)] pub template_id: Option, } /// Update file request from daemon. #[derive(Debug, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct DaemonUpdateFileRequest { /// Content to update in the file (as markdown body element) pub content: String, } // ============================================================================= // Handlers // ============================================================================= /// Get contract status for daemon. #[utoipa::path( get, path = "/api/v1/contracts/{id}/daemon/status", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 200, description = "Contract status", body = ContractStatusResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn get_contract_status( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(contract)) => Json(ContractStatusResponse { id: contract.id, name: contract.name, phase: contract.phase, status: contract.status, description: contract.description, }) .into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get phase deliverables checklist. #[utoipa::path( get, path = "/api/v1/contracts/{id}/daemon/checklist", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 200, description = "Phase checklist", body = PhaseChecklist), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn get_contract_checklist( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Get 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 completed deliverables for the current phase let completed_deliverables = contract.get_completed_deliverables(&contract.phase); // Get tasks for this contract let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { Ok(t) => t .into_iter() .map(|t| TaskInfo { name: t.name, status: t.status, }) .collect::>(), Err(e) => { tracing::warn!("Failed to get tasks for contract {}: {}", id, e); Vec::new() } }; // Check if repository is configured let has_repository = match repository::list_contract_repositories(pool, id).await { Ok(repos) => !repos.is_empty(), Err(_) => false, }; let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type); Json(checklist).into_response() } /// Get contract goals. #[utoipa::path( get, path = "/api/v1/contracts/{id}/daemon/goals", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 200, description = "Contract goals", body = ContractGoalsResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn get_contract_goals( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(contract)) => { let deliverables = phase_guidance::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); Json(ContractGoalsResponse { description: contract.description, phase: contract.phase, phase_guidance: deliverables.guidance, }) .into_response() } Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Post progress report to contract. #[utoipa::path( post, path = "/api/v1/contracts/{id}/daemon/report", params( ("id" = Uuid, Path, description = "Contract ID") ), request_body = ProgressReportRequest, responses( (status = 200, description = "Report received"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn post_progress_report( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists 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(); } } // Log the report as a contract event let event_type = "progress_report"; let payload = serde_json::json!({ "message": req.message, "task_id": req.task_id, }); if let Err(e) = repository::record_contract_event(pool, id, event_type, Some(payload)).await { tracing::warn!("Failed to create contract event: {}", e); } Json(serde_json::json!({"status": "received"})).into_response() } /// Get suggested action based on contract state. #[utoipa::path( post, path = "/api/v1/contracts/{id}/daemon/suggest-action", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 200, description = "Suggested action", body = SuggestedActionResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn get_suggest_action( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Get 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 completed deliverables and tasks for checklist let completed_deliverables = contract.get_completed_deliverables(&contract.phase); let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id) .await .unwrap_or_default() .into_iter() .map(|t| TaskInfo { name: t.name, status: t.status, }) .collect::>(); let has_repository = repository::list_contract_repositories(pool, id) .await .map(|r| !r.is_empty()) .unwrap_or(false); let checklist = phase_guidance::get_phase_checklist_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type); // Determine suggested action based on checklist let (action, description) = if !checklist.suggestions.is_empty() { ("follow_suggestion", checklist.suggestions.first().unwrap().clone()) } else if checklist.completion_percentage >= 100 { ("advance_phase", format!("Phase {} is complete, consider advancing to next phase", contract.phase)) } else { ("continue", format!("Continue working on {} phase ({}% complete)", contract.phase, checklist.completion_percentage)) }; Json(SuggestedActionResponse { action: action.to_string(), description, data: None, }) .into_response() } /// Get recommended completion action. #[utoipa::path( post, path = "/api/v1/contracts/{id}/daemon/completion-action", params( ("id" = Uuid, Path, description = "Contract ID") ), request_body = CompletionActionRequest, responses( (status = 200, description = "Recommended completion action", body = CompletionActionResponse), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn get_completion_action( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Get contract let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { Ok(Some(c)) => c, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "Contract not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get contract {}: {}", id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; // Determine completion action based on phase and changes let has_changes = !req.files_modified.is_empty() || req.lines_added > 0 || req.lines_removed > 0; let has_significant_changes = req.lines_added + req.lines_removed > 50; let (action, reason) = match contract.phase.as_str() { "research" | "specify" => { if has_changes { (CompletionAction::Merge, "Early phase changes can be merged directly".to_string()) } else { (CompletionAction::None, "No changes to commit".to_string()) } } "plan" => { if has_significant_changes { (CompletionAction::Pr, "Significant planning changes require review".to_string()) } else if has_changes { (CompletionAction::Merge, "Minor planning changes can be merged".to_string()) } else { (CompletionAction::None, "No changes to commit".to_string()) } } "execute" => { if req.has_code_changes { (CompletionAction::Pr, "Code changes in execute phase require review".to_string()) } else if has_changes { (CompletionAction::Branch, "Documentation changes can be branched".to_string()) } else { (CompletionAction::None, "No changes to commit".to_string()) } } "review" => { if has_changes { (CompletionAction::Pr, "Review phase changes should be reviewed".to_string()) } else { (CompletionAction::None, "No changes to commit".to_string()) } } _ => (CompletionAction::None, "Unknown phase".to_string()), }; // Generate branch name based on contract let branch_name = if matches!(action, CompletionAction::Branch | CompletionAction::Pr) { let slug = contract.name.to_lowercase().replace(' ', "-"); Some(format!("contract/{}", slug)) } else { None }; Json(CompletionActionResponse { action: action.to_string(), reason, branch_name, }) .into_response() } /// List contract files for daemon. #[utoipa::path( get, path = "/api/v1/contracts/{id}/daemon/files", params( ("id" = Uuid, Path, description = "Contract ID") ), responses( (status = 200, description = "List of contract files", body = Vec), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn list_contract_files( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists 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_files_in_contract(pool, id, auth.owner_id).await { Ok(files) => Json(files).into_response(), Err(e) => { tracing::error!("Failed to list files for contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Get a specific contract file. #[utoipa::path( get, path = "/api/v1/contracts/{id}/daemon/files/{file_id}", params( ("id" = Uuid, Path, description = "Contract ID"), ("file_id" = Uuid, Path, description = "File ID") ), responses( (status = 200, description = "File content"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract or file not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn get_contract_file( State(state): State, Authenticated(auth): Authenticated, Path((id, file_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 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(); } } // Get file and verify it belongs to this contract match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { Ok(Some(file)) => { if file.contract_id != Some(id) { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "File not found in this contract")), ) .into_response(); } Json(file).into_response() } Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "File not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to get file {}: {}", file_id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } } /// Update a contract file. #[utoipa::path( put, path = "/api/v1/contracts/{id}/daemon/files/{file_id}", params( ("id" = Uuid, Path, description = "Contract ID"), ("file_id" = Uuid, Path, description = "File ID") ), request_body = DaemonUpdateFileRequest, responses( (status = 200, description = "File updated"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract or file not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn update_contract_file( State(state): State, Authenticated(auth): Authenticated, Path((id, file_id)): Path<(Uuid, Uuid)>, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists 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(); } } // Get file and verify it belongs to this contract let file = match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { Ok(Some(f)) => f, Ok(None) => { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "File not found")), ) .into_response(); } Err(e) => { tracing::error!("Failed to get file {}: {}", file_id, e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response(); } }; if file.contract_id != Some(id) { return ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "File not found in this contract")), ) .into_response(); } // Update the file with content parsed as markdown let body = crate::llm::markdown_to_body(&req.content); let update_req = crate::db::models::UpdateFileRequest { name: None, description: None, transcript: None, summary: None, body: Some(body), version: None, repo_file_path: None, }; match repository::update_file_for_owner(pool, file_id, auth.owner_id, update_req).await { Ok(Some(updated)) => Json(updated).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, Json(ApiError::new("NOT_FOUND", "File not found")), ) .into_response(), Err(e) => { tracing::error!("Failed to update file {}: {}", file_id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", format!("{}", e))), ) .into_response() } } } /// Create a new contract file. #[utoipa::path( post, path = "/api/v1/contracts/{id}/daemon/files", params( ("id" = Uuid, Path, description = "Contract ID") ), request_body = CreateFileRequest, responses( (status = 201, description = "File created"), (status = 401, description = "Unauthorized", body = ApiError), (status = 404, description = "Contract not found", body = ApiError), (status = 503, description = "Database not configured", body = ApiError), ), security( ("tool_key" = []), ("api_key" = []) ), tag = "Contract Daemon" )] pub async fn create_contract_file( State(state): State, Authenticated(auth): Authenticated, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let Some(ref pool) = state.db_pool else { return ( StatusCode::SERVICE_UNAVAILABLE, Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), ) .into_response(); }; // Verify contract exists 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(); } }; // Create the file with content parsed as markdown let body = crate::llm::markdown_to_body(&req.content); let create_req = crate::db::models::CreateFileRequest { contract_id: id, name: Some(req.name), description: None, transcript: vec![], location: None, body, repo_file_path: None, contract_phase: None, // Will be looked up from contract's current phase }; match repository::create_file_for_owner(pool, auth.owner_id, create_req).await { Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), Err(e) => { tracing::error!("Failed to create file for contract {}: {}", id, e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError::new("DB_ERROR", e.to_string())), ) .into_response() } } }