//! 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());
}
}