//! HTTP handlers for contract CRUD operations.
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use uuid::Uuid;
use crate::db::models::{
AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest,
ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations,
CreateContractRequest, CreateManagedRepositoryRequest, UpdateContractRequest,
UpdateTaskRequest,
};
use crate::db::repository::{self, RepositoryError};
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::SharedState;
/// Helper function to update the supervisor task with repository info when a primary repo is added.
/// This ensures the supervisor has access to the repository when it starts.
async fn update_supervisor_with_repo_if_needed(
pool: &sqlx::PgPool,
contract_id: uuid::Uuid,
owner_id: uuid::Uuid,
repo: &ContractRepository,
) {
// Only update for primary repositories
if !repo.is_primary {
return;
}
// Get the supervisor task
let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await {
Ok(Some(s)) => s,
Ok(None) => {
tracing::debug!(contract_id = %contract_id, "No supervisor task found");
return;
}
Err(e) => {
tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task");
return;
}
};
// Only update if supervisor doesn't have a repository URL yet
if supervisor.repository_url.is_some() {
tracing::debug!(
supervisor_id = %supervisor.id,
"Supervisor already has repository URL"
);
return;
}
// Get repository URL (for remote repos) or local path (for local repos)
let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone());
if repo_url.is_none() && repo.source_type != "managed" {
tracing::debug!(
supervisor_id = %supervisor.id,
"Repository has no URL or path to assign"
);
return;
}
// Update supervisor task with repository info
let update_req = UpdateTaskRequest {
repository_url: repo_url,
version: Some(supervisor.version),
..Default::default()
};
match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await {
Ok(Some(updated)) => {
tracing::info!(
supervisor_id = %updated.id,
repository_url = ?updated.repository_url,
"Updated supervisor task with repository URL"
);
}
Ok(None) => {
tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update");
}
Err(e) => {
tracing::warn!(
supervisor_id = %supervisor.id,
error = %e,
"Failed to update supervisor with repository URL"
);
}
}
}
/// List all root contracts (no parent) for the authenticated user's owner.
#[utoipa::path(
get,
path = "/api/v1/contracts",
responses(
(status = 200, description = "List of root contracts", body = ContractListResponse),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(
("bearer_auth" = []),
("api_key" = [])
),
tag = "Contracts"
)]
pub async fn list_contracts(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match repository::list_contracts_for_owner(pool, auth.owner_id).await {
Ok(contracts) => {
let total = contracts.len() as i64;
Json(ContractListResponse { contracts, total }).into_response()
}
Err(e) => {
tracing::error!("Failed to list contracts: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
/// Get a contract by ID with all its relations (repositories, files, tasks).
#[utoipa::path(
get,
path = "/api/v1/contracts/{id}",
params(
("id" = Uuid, Path, description = "Contract ID")
),
responses(
(status = 200, description = "Contract details with relations", body = ContractWithRelations),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(
("bearer_auth" = []),
("api_key" = [])
),
tag = "Contracts"
)]
pub async fn get_contract(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// Get the contract
let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
Ok(Some(c)) => c,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Contract not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get contract {}: {}", id, e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
};
// Get repositories
let repositories = match repository::list_contract_repositories(pool, id).await {
Ok(r) => r,
Err(e) => {
tracing::warn!("Failed to get repositories for {}: {}", id, e);
Vec::new()
}
};
// Get files
let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await {
Ok(f) => f,
Err(e) => {
tracing::warn!("Failed to get files for contract {}: {}", id, e);
Vec::new()
}
};
// Get tasks
let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await {
Ok(t) => t,
Err(e) => {
tracing::warn!("Failed to get tasks for contract {}: {}", id, e);
Vec::new()
}
};
Json(ContractWithRelations {
contract,
repositories,
files,
tasks,
})
.into_response()
}
/// Create a new contract.
#[utoipa::path(
post,
path = "/api/v1/contracts",
request_body = CreateContractRequest,
responses(
(status = 201, description = "Contract created", body = ContractSummary),
(status = 400, description = "Invalid request", body = ApiError),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(
("bearer_auth" = []),
("api_key" = [])
),
tag = "Contracts"
)]
pub async fn create_contract(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Json(req): Json<CreateContractRequest>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await {
Ok(contract) => {
// Only create supervisor task for non-task type contracts
// Task type contracts are lightweight adhoc tasks that don't need supervisors
if contract.contract_type != crate::db::models::CONTRACT_TYPE_TASK {
// Create supervisor task for this contract
let supervisor_name = format!("{} Supervisor", contract.name);
let supervisor_plan = format!(
"You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}",
contract.name,
contract.description.as_deref().unwrap_or("No description provided.")
);
// Get repository info from contract if available
let repo_url = {
// Try to get the first repository associated with this contract
match repository::list_contract_repositories(pool, contract.id).await {
Ok(repos) if !repos.is_empty() => {
let repo = &repos[0];
repo.repository_url.clone()
}
_ => None,
}
};
let supervisor_req = crate::db::models::CreateTaskRequest {
name: supervisor_name,
description: None,
plan: supervisor_plan,
repository_url: repo_url,
base_branch: None,
target_branch: None,
parent_task_id: None,
contract_id: contract.id,
target_repo_path: None,
completion_action: None,
continue_from_task_id: None,
copy_files: None,
is_supervisor: true,
checkpoint_sha: None,
priority: 0,
merge_mode: None,
};
match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await {
Ok(supervisor_task) => {
tracing::info!(
contract_id = %contract.id,
supervisor_task_id = %supervisor_task.id,
is_supervisor = supervisor_task.is_supervisor,
"Created supervisor task for contract"
);
// Update contract with supervisor_task_id
let update_req = crate::db::models::UpdateContractRequest {
supervisor_task_id: Some(supervisor_task.id),
version: Some(contract.version),
..Default::default()
};
if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await {
tracing::warn!(
contract_id = %contract.id,
error = %e,
"Failed to link supervisor task to contract"
);
}
}
Err(e) => {
tracing::warn!(
contract_id = %contract.id,
error = %e,
"Failed to create supervisor task for contract"
);
}
}
} else {
tracing::info!(
contract_id = %contract.id,
contract_type = %contract.contract_type,
"Skipping supervisor creation for task-type 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,
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,
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,
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 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 = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(
("bearer_auth" = []),
("api_key" = [])
),
tag = "Contracts"
)]
pub async fn change_phase(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<ChangePhaseRequest>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// 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
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(),
};
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": "pending_confirmation",
"transitionId": transition_id,
"currentPhase": contract.phase,
"nextPhase": req.phase,
"deliverablesSummary": deliverables_summary,
"phaseFiles": phase_files,
"phaseTasks": phase_tasks,
"requiresConfirmation": true,
"message": "Phase transition requires confirmation. Set confirmed=true in the request to proceed."
}))
.into_response();
}
// Phase guard is disabled or user confirmed - proceed with phase change
match repository::change_contract_phase_for_owner(pool, id, auth.owner_id, &req.phase).await {
Ok(Some(updated_contract)) => {
// 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": &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,
file_count: 0,
task_count: 0,
repository_count: 0,
version: updated_contract.version,
created_at: updated_contract.created_at,
})
.into_response(),
}
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Contract not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to change phase for contract {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
// =============================================================================
// Events
// =============================================================================
/// Get contract event history.
#[utoipa::path(
get,
path = "/api/v1/contracts/{id}/events",
params(
("id" = Uuid, Path, description = "Contract ID")
),
responses(
(status = 200, description = "Event history", body = Vec<crate::db::models::ContractEvent>),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "Contract not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
security(
("bearer_auth" = []),
("api_key" = [])
),
tag = "Contracts"
)]
pub async fn get_events(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// Verify contract exists and belongs to owner
match repository::get_contract_for_owner(pool, id, auth.owner_id).await {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Contract not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get contract {}: {}", id, e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response();
}
}
match repository::list_contract_events(pool, id).await {
Ok(events) => Json(events).into_response(),
Err(e) => {
tracing::error!("Failed to get events for contract {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DB_ERROR", e.to_string())),
)
.into_response()
}
}
}
// =============================================================================
// 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
for task in tasks {
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"
);
}
}
}