diff options
| author | soryu <soryu@soryu.co> | 2026-05-01 23:56:51 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-05-01 23:56:51 +0100 |
| commit | e11759447b1ac00becfb1e979e488f7f9c9cf478 (patch) | |
| tree | f8a58368de3f6dda3f2f5c1af34e869a0e714205 /makima/src/server/handlers/contracts.rs | |
| parent | 80085c7cfa9d679ed3e3fd54a7d55fa8ab1addef (diff) | |
| download | soryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.tar.gz soryu-e11759447b1ac00becfb1e979e488f7f9c9cf478.zip | |
chore(cleanup): Phase 5 contracts removal + tmp directive + 30-day expiry + scroll fix (#118)
Sweeping cleanup across the surface and the wire. Net: -14k LOC of legacy
contracts code, plus the tmp/scroll/UX fixes the user asked for.
## Sidebar/editor independent scroll
Replace `height: calc(100vh - 80px)` (which assumed an 80px masthead and
quietly clipped or pushed the whole page below the fold when the masthead
was taller) with `h-screen + overflow-hidden` on the page root and proper
`flex-1 min-h-0` sizing on `<main>`. Sidebar and editor pane now manage
their own scroll independently; the page itself never scrolls.
Same fix in /tmp/:taskId.
## tmp directive — real backing for orphans/ephemerals
New migration `20260501100000_tmp_directive_and_clear_orphans.sql`:
* Adds `directives.is_tmp` BOOLEAN NOT NULL DEFAULT false.
* Partial unique index `(owner_id) WHERE is_tmp` — at most ONE tmp
directive per owner.
* Hard-deletes every existing orphan task (`directive_id IS NULL`).
Per the user spec: "ALSO there are TOO MANY old tasks in tmp, we
need to remove all of them as well."
New repository helpers:
* `get_or_create_tmp_directive(pool, owner_id) -> Directive`
INSERT ON CONFLICT DO NOTHING + fallback SELECT, race-safe.
* `list_all_tmp_directives` — drives the expiry sweep.
* `delete_expired_tmp_tasks(tmp_directive_id) -> u64`.
* `list_tmp_tasks_for_owner` (replaces `list_orphan_tasks_for_owner`).
`mesh::create_task`: every top-level task must have a directive. If a
caller doesn't supply `directive_id` and isn't a subtask, attach to the
caller's tmp directive (auto-creating it on first use).
`list_directives_for_owner` filters out `is_tmp=true` so the scratchpad
directive doesn't pollute the contract list — surfaced via the sidebar's
`tmp/` folder instead.
## 30-day expiry on tmp tasks
New `phase_tmp_expiry` in the directive reconciler. Throttled to once per
hour: enumerates every tmp directive, calls `delete_expired_tmp_tasks`,
logs the count. The actual delete is `WHERE created_at < NOW() - INTERVAL
'30 days'` and is fast on the existing index. Subtasks die via FK cascade.
## Phase 5 — contracts removed
### Frontend
Deleted entire `/contracts` surface:
* routes: `contracts.tsx`, `contract-file.tsx`
* components/contracts: ContractList, ContractDetail, ContractCliInput,
ContractContextMenu, CommandModePanel, PhaseBadge, PhaseHint,
PhaseDeliverablesPanel, PhaseProgressBar, QuickActionButtons,
RepositoryPanel, TaskDerivationPreview
* (Kept `PhaseConfirmationModal` — used outside the contracts surface
by `TaskOutput` and `PhaseConfirmationNotification`.)
* Routes deregistered from `main.tsx`; nav entry removed from
`NavStrip`.
### Backend handlers
Deleted: `contracts.rs` (2.4k LOC), `contract_chat.rs` (3.2k LOC),
`contract_daemon.rs` (~940 LOC), `contract_discuss.rs` (~590 LOC),
`transcript_analysis.rs` (~690 LOC). All `/api/v1/contracts/*` routes
deregistered. OpenAPI entries dropped. Module declarations removed from
`server/handlers/mod.rs`.
### CLI
Removed `makima contract` and `makima supervisor` subcommands. Deleted
`daemon/cli/contract.rs` and `daemon/cli/supervisor.rs`. Bin dispatch
trimmed (~377 LOC).
### Orchestrator
Removed the contract-spawn path from `phase_execution`
(`spawn_step_contract` and its caller). `directive_steps.contract_type`
now logs a warning and falls through to standalone-task spawn. Column
itself stays — old data still reads, just no longer triggers a
contract+supervisor spawn.
### TUI
`Action::PerformCreateContract` is now a no-op that surfaces a status
message: "Contracts have been removed. Use directives instead." The TUI
form is dead code pending a wider refresh.
## Out of scope (deliberately left)
* Contracts DB tables (`contracts`, `contract_repositories`,
`contract_chat_history`, `contract_events`, `contract_templates`) are
retained for historical data + because some peripheral code still
joins to them in TaskSummary queries.
* `mesh_supervisor` handlers are retained — they aren't only used by
contracts (some mesh-level supervisor behaviour persists), and the
cross-cutting cleanup is bigger than this PR.
* `directive_steps.contract_type` column itself isn't dropped; just no
longer functional.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'makima/src/server/handlers/contracts.rs')
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 2376 |
1 files changed, 0 insertions, 2376 deletions
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs deleted file mode 100644 index bdd4d40..0000000 --- a/makima/src/server/handlers/contracts.rs +++ /dev/null @@ -1,2376 +0,0 @@ -//! HTTP handlers for contract CRUD operations. - -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::Deserialize; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::models::{ - AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest, - ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, - CreateContractRequest, CreateManagedRepositoryRequest, PhaseChangeResult, - UpdateContractRequest, UpdateTaskRequest, -}; -use crate::db::repository::{self, RepositoryError}; -use crate::llm::PhaseDeliverables; -use crate::server::auth::Authenticated; -use crate::server::messages::ApiError; -use crate::server::state::SharedState; - -// ============================================================================= -// Deliverable Validation -// ============================================================================= - -/// Error type for deliverable validation failures -#[derive(Debug, Clone)] -pub struct DeliverableValidationError { - /// The error message with details about valid deliverables - pub message: String, -} - -impl DeliverableValidationError { - pub fn new(message: impl Into<String>) -> Self { - Self { - message: message.into(), - } - } -} - -impl std::fmt::Display for DeliverableValidationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.message) - } -} - -impl std::error::Error for DeliverableValidationError {} - -/// Validates that a deliverable ID is valid for the given phase deliverables. -/// -/// # Arguments -/// * `deliverable_id` - The deliverable ID to validate -/// * `phase_deliverables` - The phase deliverables configuration to validate against -/// -/// # Returns -/// * `Ok(())` if the deliverable is valid -/// * `Err(DeliverableValidationError)` if the deliverable is not valid -pub fn validate_deliverable( - deliverable_id: &str, - phase_deliverables: &PhaseDeliverables, -) -> Result<(), DeliverableValidationError> { - let valid_deliverable = phase_deliverables - .deliverables - .iter() - .any(|d| d.id == deliverable_id); - - if valid_deliverable { - Ok(()) - } else { - let valid_ids: Vec<&str> = phase_deliverables - .deliverables - .iter() - .map(|d| d.id.as_str()) - .collect(); - - Err(DeliverableValidationError::new(format!( - "Invalid deliverable '{}' for {} phase. Valid IDs: [{}]", - deliverable_id, - phase_deliverables.phase, - valid_ids.join(", ") - ))) - } -} - -// ============================================================================= -// Supervisor Repository Update Helper -// ============================================================================= - -/// Helper function to update the supervisor task with repository info when a primary repo is added. -/// This ensures the supervisor has access to the repository when it starts. -async fn update_supervisor_with_repo_if_needed( - pool: &sqlx::PgPool, - contract_id: uuid::Uuid, - owner_id: uuid::Uuid, - repo: &ContractRepository, -) { - // Only update for primary repositories - if !repo.is_primary { - return; - } - - // Get the supervisor task - let supervisor = match repository::get_contract_supervisor_task(pool, contract_id).await { - Ok(Some(s)) => s, - Ok(None) => { - tracing::debug!(contract_id = %contract_id, "No supervisor task found"); - return; - } - Err(e) => { - tracing::warn!(contract_id = %contract_id, error = %e, "Failed to get supervisor task"); - return; - } - }; - - // Only update if supervisor doesn't have a repository URL yet - if supervisor.repository_url.is_some() { - tracing::debug!( - supervisor_id = %supervisor.id, - "Supervisor already has repository URL" - ); - return; - } - - // Get repository URL (for remote repos) or local path (for local repos) - let repo_url = repo.repository_url.clone().or_else(|| repo.local_path.clone()); - - if repo_url.is_none() && repo.source_type != "managed" { - tracing::debug!( - supervisor_id = %supervisor.id, - "Repository has no URL or path to assign" - ); - return; - } - - // Update supervisor task with repository info - let update_req = UpdateTaskRequest { - repository_url: repo_url, - version: Some(supervisor.version), - ..Default::default() - }; - - match repository::update_task_for_owner(pool, supervisor.id, owner_id, update_req).await { - Ok(Some(updated)) => { - tracing::info!( - supervisor_id = %updated.id, - repository_url = ?updated.repository_url, - "Updated supervisor task with repository URL" - ); - } - Ok(None) => { - tracing::warn!(supervisor_id = %supervisor.id, "Supervisor task not found during update"); - } - Err(e) => { - tracing::warn!( - supervisor_id = %supervisor.id, - error = %e, - "Failed to update supervisor with repository URL" - ); - } - } -} - -/// List all root contracts (no parent) for the authenticated user's owner. -#[utoipa::path( - get, - path = "/api/v1/contracts", - responses( - (status = 200, description = "List of root contracts", body = ContractListResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn list_contracts( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::list_contracts_for_owner(pool, auth.owner_id).await { - Ok(contracts) => { - let total = contracts.len() as i64; - Json(ContractListResponse { contracts, total }).into_response() - } - Err(e) => { - tracing::error!("Failed to list contracts: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get a contract by ID with all its relations (repositories, files, tasks). -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Contract details with relations", body = ContractWithRelations), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get the contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get repositories - let repositories = match repository::list_contract_repositories(pool, id).await { - Ok(r) => r, - Err(e) => { - tracing::warn!("Failed to get repositories for {}: {}", id, e); - Vec::new() - } - }; - - // Get files - let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(f) => f, - Err(e) => { - tracing::warn!("Failed to get files for contract {}: {}", id, e); - Vec::new() - } - }; - - // Get tasks - let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(t) => t, - Err(e) => { - tracing::warn!("Failed to get tasks for contract {}: {}", id, e); - Vec::new() - } - }; - - Json(ContractWithRelations { - contract, - repositories, - files, - tasks, - }) - .into_response() -} - -/// Create a new contract. -#[utoipa::path( - post, - path = "/api/v1/contracts", - request_body = CreateContractRequest, - responses( - (status = 201, description = "Contract created", body = ContractSummary), - (status = 400, description = "Invalid request", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn create_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(req): Json<CreateContractRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::create_contract_for_owner(pool, auth.owner_id, req.clone()).await { - Ok(contract) => { - // Create supervisor task for this contract - let supervisor_name = format!("{} Supervisor", contract.name); - let supervisor_plan = format!( - "You are the supervisor for contract '{}'. Your goal is to drive this contract to completion.\n\n{}", - contract.name, - contract.description.as_deref().unwrap_or("No description provided.") - ); - - // Get repository info from contract if available - let repo_url = { - // Try to get the first repository associated with this contract - match repository::list_contract_repositories(pool, contract.id).await { - Ok(repos) if !repos.is_empty() => { - let repo = &repos[0]; - repo.repository_url.clone() - } - _ => None, - } - }; - - let supervisor_req = crate::db::models::CreateTaskRequest { - name: supervisor_name, - description: None, - plan: supervisor_plan, - repository_url: repo_url, - base_branch: None, - target_branch: None, - parent_task_id: None, - contract_id: Some(contract.id), - target_repo_path: None, - completion_action: None, - continue_from_task_id: None, - copy_files: None, - is_supervisor: true, - checkpoint_sha: None, - priority: 0, - merge_mode: None, - branched_from_task_id: None, - conversation_history: None, - supervisor_worktree_task_id: None, // Supervisor uses its own worktree - directive_id: None, - directive_step_id: None, - }; - - match repository::create_task_for_owner(pool, auth.owner_id, supervisor_req).await { - Ok(supervisor_task) => { - tracing::info!( - contract_id = %contract.id, - supervisor_task_id = %supervisor_task.id, - is_supervisor = supervisor_task.is_supervisor, - "Created supervisor task for contract" - ); - - // Update contract with supervisor_task_id - let update_req = crate::db::models::UpdateContractRequest { - supervisor_task_id: Some(supervisor_task.id), - version: Some(contract.version), - ..Default::default() - }; - if let Err(e) = repository::update_contract_for_owner(pool, contract.id, auth.owner_id, update_req).await { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to link supervisor task to contract" - ); - } - } - Err(e) => { - tracing::warn!( - contract_id = %contract.id, - error = %e, - "Failed to create supervisor task for contract" - ); - } - } - - // Record history event for contract creation - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "contract", - Some("created"), - Some(&contract.phase), - serde_json::json!({ - "name": &contract.name, - "type": &contract.contract_type, - "description": &contract.description, - }), - ).await; - - // Get the summary version with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await - { - Ok(Some(summary)) => (StatusCode::CREATED, Json(summary)).into_response(), - Ok(None) => { - // Shouldn't happen, but return basic info if it does - ( - StatusCode::CREATED, - Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }), - ) - .into_response() - } - Err(e) => { - tracing::warn!("Failed to get contract summary: {}", e); - ( - StatusCode::CREATED, - Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }), - ) - .into_response() - } - } - } - Err(e) => { - tracing::error!("Failed to create contract: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Update a contract. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = UpdateContractRequest, - responses( - (status = 200, description = "Contract updated", body = ContractSummary), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn update_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<UpdateContractRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - match repository::update_contract_for_owner(pool, id, auth.owner_id, req).await { - Ok(Some(contract)) => { - // If contract is completed, stop the supervisor task and clean up worktrees - if contract.status == "completed" { - if let Some(supervisor_task_id) = contract.supervisor_task_id { - // Get the supervisor task to find its daemon - if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - if let Some(daemon_id) = supervisor.daemon_id { - let state_clone = state.clone(); - tokio::spawn(async move { - // Gracefully interrupt the supervisor - let cmd = crate::server::state::DaemonCommand::InterruptTask { - task_id: supervisor_task_id, - graceful: true, - }; - if let Err(e) = state_clone.send_daemon_command(daemon_id, cmd).await { - tracing::warn!( - supervisor_task_id = %supervisor_task_id, - daemon_id = %daemon_id, - error = %e, - "Failed to stop supervisor task on contract completion" - ); - } else { - tracing::info!( - supervisor_task_id = %supervisor_task_id, - contract_id = %id, - "Stopped supervisor task on contract completion" - ); - } - }); - } - } - } - - // Clean up all task worktrees for this contract - let pool_clone = pool.clone(); - let state_clone = state.clone(); - let contract_id = id; - tokio::spawn(async move { - cleanup_contract_worktrees(&pool_clone, &state_clone, contract_id).await; - }); - - // Record history event for contract completion - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "contract", - Some("completed"), - Some(&contract.phase), - serde_json::json!({ - "name": &contract.name, - "status": &contract.status, - }), - ).await; - - } - - // Get summary with counts - match repository::get_contract_summary_for_owner(pool, contract.id, auth.owner_id).await - { - Ok(Some(summary)) => Json(summary).into_response(), - _ => Json(ContractSummary { - id: contract.id, - name: contract.name, - description: contract.description, - contract_type: contract.contract_type, - phase: contract.phase, - status: contract.status, - supervisor_task_id: contract.supervisor_task_id, - local_only: contract.local_only, - auto_merge_local: contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: contract.version, - created_at: contract.created_at, - }) - .into_response(), - } - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(RepositoryError::VersionConflict { expected, actual }) => { - tracing::info!( - "Version conflict on contract {}: expected {}, actual {}", - id, - expected, - actual - ); - ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "code": "VERSION_CONFLICT", - "message": format!( - "Contract was modified. Expected version {}, actual version {}", - expected, actual - ), - "expectedVersion": expected, - "actualVersion": actual, - })), - ) - .into_response() - } - Err(RepositoryError::Database(e)) => { - tracing::error!("Failed to update contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 204, description = "Contract deleted"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn delete_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // First, verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Clean up any pending supervisor questions for this contract - state.remove_pending_questions_for_contract(id); - - // Clean up all task worktrees BEFORE deleting the contract - // (because CASCADE delete will remove tasks from DB) - cleanup_contract_worktrees(pool, &state, id).await; - - match repository::delete_contract_for_owner(pool, id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to delete contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Repository Management -// ============================================================================= - -/// Add a remote repository to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/remote", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = AddRemoteRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_remote_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<AddRemoteRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_remote_repository(pool, id, &req.name, &req.repository_url, req.is_primary) - .await - { - Ok(repo) => { - // Update supervisor task with repository info if this is a primary repo - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - - // Track repository in history for future suggestions - if let Err(e) = repository::add_or_update_repository_history( - pool, - auth.owner_id, - &req.name, - Some(&req.repository_url), - None, - "remote", - ) - .await - { - // Log but don't fail the request if history tracking fails - tracing::warn!("Failed to track repository in history: {}", e); - } - - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!("Failed to add remote repository to contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Add a local repository to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/local", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = AddLocalRepositoryRequest, - responses( - (status = 201, description = "Repository added", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_local_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<AddLocalRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_local_repository(pool, id, &req.name, &req.local_path, req.is_primary) - .await - { - Ok(repo) => { - // Update supervisor task with repository info if this is a primary repo - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - - // Track repository in history for future suggestions - if let Err(e) = repository::add_or_update_repository_history( - pool, - auth.owner_id, - &req.name, - None, - Some(&req.local_path), - "local", - ) - .await - { - // Log but don't fail the request if history tracking fails - tracing::warn!("Failed to track repository in history: {}", e); - } - - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!("Failed to add local repository to contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Create a managed repository (daemon will create it). -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/repositories/managed", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = CreateManagedRepositoryRequest, - responses( - (status = 201, description = "Repository creation requested", body = ContractRepository), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn create_managed_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<CreateManagedRepositoryRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::create_managed_repository(pool, id, &req.name, req.is_primary).await { - Ok(repo) => { - // For managed repos, the daemon will create the repo and we'll update later - // For now, just mark that this is a managed repo configuration - // The helper handles the case where repo has no URL yet - update_supervisor_with_repo_if_needed(pool, id, auth.owner_id, &repo).await; - (StatusCode::CREATED, Json(repo)).into_response() - } - Err(e) => { - tracing::error!( - "Failed to create managed repository for contract {}: {}", - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Delete a repository from a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/repositories/{repo_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("repo_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 204, description = "Repository removed"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn delete_repository( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, repo_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::delete_contract_repository(pool, repo_id, id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to delete repository {} from contract {}: {}", - repo_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Set a repository as primary for a contract. -#[utoipa::path( - put, - path = "/api/v1/contracts/{id}/repositories/{repo_id}/primary", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("repo_id" = Uuid, Path, description = "Repository ID") - ), - responses( - (status = 204, description = "Repository set as primary"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or repository not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn set_repository_primary( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, repo_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::set_repository_primary(pool, repo_id, id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Repository not found")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to set repository {} as primary for contract {}: {}", - repo_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Task Association -// ============================================================================= - -/// Add a task to a contract. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/tasks/{task_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("task_id" = Uuid, Path, description = "Task ID") - ), - responses( - (status = 204, description = "Task added to contract"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or task not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn add_task_to_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, task_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - // Verify task exists and belongs to owner - match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get task {}: {}", task_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::add_task_to_contract(pool, id, task_id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to add task {} to contract {}: {}", task_id, id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Remove a task from a contract. -#[utoipa::path( - delete, - path = "/api/v1/contracts/{id}/tasks/{task_id}", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("task_id" = Uuid, Path, description = "Task ID") - ), - responses( - (status = 204, description = "Task removed from contract"), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or task not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn remove_task_from_contract( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path((id, task_id)): Path<(Uuid, Uuid)>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::remove_task_from_contract(pool, id, task_id, auth.owner_id).await { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Task not found in this contract")), - ) - .into_response(), - Err(e) => { - tracing::error!( - "Failed to remove task {} from contract {}: {}", - task_id, - id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Phase Management -// ============================================================================= - -/// Change contract phase. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/phase", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = ChangePhaseRequest, - responses( - (status = 200, description = "Phase changed", body = ContractSummary), - (status = 400, description = "Validation failed", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 409, description = "Version conflict", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn change_phase( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<ChangePhaseRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // First, get the contract to check phase_guard - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // If phase_guard is enabled and not confirmed, return phase deliverables for review - // This applies to ALL callers (including supervisors) - phase_guard enforcement at API level - if contract.phase_guard && !req.confirmed.unwrap_or(false) { - // If user provided feedback, return it - if let Some(ref feedback) = req.feedback { - return Json(serde_json::json!({ - "status": "changes_requested", - "currentPhase": contract.phase, - "requestedPhase": req.phase, - "feedback": feedback, - "message": "Feedback has been noted. Address the changes and try again." - })) - .into_response(); - } - - // Get files created in this phase - let phase_files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { - Ok(files) => files - .into_iter() - .filter(|f| f.contract_phase.as_deref() == Some(&contract.phase)) - .map(|f| serde_json::json!({ - "id": f.id, - "name": f.name, - "description": f.description - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get tasks completed in this contract - let phase_tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { - Ok(tasks) => tasks - .into_iter() - .filter(|t| t.status == "done" || t.status == "completed") - .map(|t| serde_json::json!({ - "id": t.id, - "name": t.name, - "status": t.status - })) - .collect::<Vec<_>>(), - Err(_) => Vec::new(), - }; - - // Get phase deliverables with completion status - let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&contract.phase, &contract.contract_type); - let completed_deliverables = contract.get_completed_deliverables(&contract.phase); - - let deliverables: Vec<serde_json::Value> = phase_deliverables - .deliverables - .iter() - .map(|d| serde_json::json!({ - "id": d.id, - "name": d.name, - "completed": completed_deliverables.contains(&d.id) - })) - .collect(); - - let deliverables_summary = format!( - "Phase '{}' deliverables: {} files created, {} tasks completed.", - contract.phase, - phase_files.len(), - phase_tasks.len() - ); - - let transition_id = uuid::Uuid::new_v4().to_string(); - - return Json(serde_json::json!({ - "status": "requires_confirmation", - "transitionId": transition_id, - "currentPhase": contract.phase, - "nextPhase": req.phase, - "deliverablesSummary": deliverables_summary, - "deliverables": deliverables, - "phaseFiles": phase_files, - "phaseTasks": phase_tasks, - "requiresConfirmation": true, - "message": "Phase guard is enabled. User confirmation required." - })) - .into_response(); - } - - // Phase guard is disabled or user confirmed - proceed with phase change - // Use the version-checking function for explicit conflict detection - match repository::change_contract_phase_with_version( - pool, - id, - auth.owner_id, - &req.phase, - req.expected_version, - ) - .await - { - Ok(PhaseChangeResult::Success(updated_contract)) => { - // Save supervisor state on phase change (Task 3.3) - // This is a key save point for restoration - let new_phase_for_state = updated_contract.phase.clone(); - let contract_id_for_state = updated_contract.id; - let pool_for_state = pool.clone(); - tokio::spawn(async move { - if let Err(e) = repository::update_supervisor_phase(&pool_for_state, contract_id_for_state, &new_phase_for_state).await { - tracing::warn!( - contract_id = %contract_id_for_state, - new_phase = %new_phase_for_state, - error = %e, - "Failed to save supervisor state on phase change" - ); - } - }); - - // Notify supervisor of phase change - if let Some(supervisor_task_id) = updated_contract.supervisor_task_id { - if let Ok(Some(supervisor)) = repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - let state_clone = state.clone(); - let contract_id = updated_contract.id; - let new_phase = updated_contract.phase.clone(); - tokio::spawn(async move { - state_clone.notify_supervisor_of_phase_change( - supervisor.id, - supervisor.daemon_id, - contract_id, - &new_phase, - ).await; - }); - } - } - - // Record history event for phase change - let _ = repository::record_history_event( - pool, - auth.owner_id, - Some(contract.id), - None, - "phase", - Some("changed"), - Some(&contract.phase), - serde_json::json!({ - "contractName": &contract.name, - "newPhase": &updated_contract.phase, - }), - ).await; - - // Get summary with counts - match repository::get_contract_summary_for_owner(pool, updated_contract.id, auth.owner_id).await - { - Ok(Some(summary)) => Json(summary).into_response(), - _ => Json(ContractSummary { - id: updated_contract.id, - name: updated_contract.name, - description: updated_contract.description, - contract_type: updated_contract.contract_type, - phase: updated_contract.phase, - status: updated_contract.status, - supervisor_task_id: updated_contract.supervisor_task_id, - local_only: updated_contract.local_only, - auto_merge_local: updated_contract.auto_merge_local, - file_count: 0, - task_count: 0, - repository_count: 0, - version: updated_contract.version, - created_at: updated_contract.created_at, - }) - .into_response(), - } - } - Ok(PhaseChangeResult::VersionConflict { expected, actual, current_phase }) => { - tracing::info!( - contract_id = %id, - expected_version = expected, - actual_version = actual, - current_phase = %current_phase, - "Phase change failed due to version conflict" - ); - ( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "code": "VERSION_CONFLICT", - "message": "Phase change failed due to concurrent modification", - "details": { - "expected_version": expected, - "actual_version": actual, - "current_phase": current_phase - } - })), - ) - .into_response() - } - Ok(PhaseChangeResult::ValidationFailed { reason, missing_requirements }) => { - tracing::warn!( - contract_id = %id, - reason = %reason, - "Phase change validation failed" - ); - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "code": "VALIDATION_FAILED", - "message": reason, - "details": { - "missing_requirements": missing_requirements - } - })), - ) - .into_response() - } - Ok(PhaseChangeResult::NotFound) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(), - Ok(PhaseChangeResult::Unauthorized) => ( - StatusCode::UNAUTHORIZED, - Json(ApiError::new("UNAUTHORIZED", "Not authorized to change this contract's phase")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to change phase for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Deliverables -// ============================================================================= - -/// Request body for marking a deliverable complete -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct MarkDeliverableRequest { - /// The deliverable ID to mark as complete (e.g., 'plan-document', 'pull-request') - pub deliverable_id: String, - /// Phase the deliverable belongs to. Defaults to current contract phase if not specified. - pub phase: Option<String>, -} - -/// Mark a deliverable as complete for a contract phase. -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/deliverables/complete", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - request_body = MarkDeliverableRequest, - responses( - (status = 200, description = "Deliverable marked complete", body = serde_json::Value), - (status = 400, description = "Invalid deliverable ID", body = ApiError), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn mark_deliverable_complete( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - Json(req): Json<MarkDeliverableRequest>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Get contract - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Use specified phase or default to current contract phase - let target_phase = req.phase.unwrap_or_else(|| contract.phase.clone()); - - // Validate the deliverable ID exists for this phase/contract type - // Use custom phase_config if present, otherwise fall back to built-in contract types - let phase_config = contract.get_phase_config(); - let phase_deliverables = crate::llm::get_phase_deliverables_with_config( - &target_phase, - &contract.contract_type, - phase_config.as_ref(), - ); - - // Validate deliverable exists - if let Err(validation_error) = validate_deliverable(&req.deliverable_id, &phase_deliverables) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "code": "INVALID_DELIVERABLE", - "message": validation_error.message, - })), - ) - .into_response(); - } - - // Check if already completed - if contract.is_deliverable_complete(&target_phase, &req.deliverable_id) { - return Json(serde_json::json!({ - "success": true, - "message": format!("Deliverable '{}' is already marked complete for {} phase", req.deliverable_id, target_phase), - "deliverableId": req.deliverable_id, - "phase": target_phase, - "alreadyComplete": true, - })) - .into_response(); - } - - // Mark the deliverable as complete - match repository::mark_deliverable_complete(pool, id, &target_phase, &req.deliverable_id).await { - Ok(updated_contract) => { - let completed = updated_contract.get_completed_deliverables(&target_phase); - Json(serde_json::json!({ - "success": true, - "message": format!("Marked deliverable '{}' as complete for {} phase", req.deliverable_id, target_phase), - "deliverableId": req.deliverable_id, - "phase": target_phase, - "completedDeliverables": completed, - })) - .into_response() - } - Err(e) => { - tracing::error!("Failed to mark deliverable complete for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Events -// ============================================================================= - -/// Get contract event history. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/events", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Event history", body = Vec<crate::db::models::ContractEvent>), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_events( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - match repository::list_contract_events(pool, id).await { - Ok(events) => Json(events).into_response(), - Err(e) => { - tracing::error!("Failed to get events for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Internal Helper Functions -// ============================================================================= - -/// Clean up all worktrees for tasks in a contract. -/// -/// This is called when a contract is completed or deleted to remove -/// all associated task worktrees from connected daemons. -async fn cleanup_contract_worktrees( - pool: &sqlx::PgPool, - state: &SharedState, - contract_id: Uuid, -) { - tracing::info!( - contract_id = %contract_id, - "Cleaning up worktrees for contract tasks" - ); - - // Get all tasks with worktree info for this contract - let tasks = match repository::list_contract_tasks_with_worktree_info(pool, contract_id).await { - Ok(tasks) => tasks, - Err(e) => { - tracing::error!( - contract_id = %contract_id, - error = %e, - "Failed to list tasks for worktree cleanup" - ); - return; - } - }; - - if tasks.is_empty() { - tracing::debug!( - contract_id = %contract_id, - "No tasks with worktrees to clean up" - ); - return; - } - - tracing::info!( - contract_id = %contract_id, - task_count = tasks.len(), - "Found tasks with worktrees to clean up" - ); - - // Send cleanup command to each task's daemon - // Skip tasks that share a supervisor's worktree (they don't own the worktree) - for task in tasks { - // Skip tasks that reuse the supervisor's worktree - the supervisor owns it - if task.supervisor_worktree_task_id.is_some() { - tracing::debug!( - task_id = %task.id, - supervisor_worktree_task_id = ?task.supervisor_worktree_task_id, - contract_id = %contract_id, - "Task shares supervisor worktree, skipping worktree cleanup" - ); - continue; - } - - if let Some(daemon_id) = task.daemon_id { - let cmd = crate::server::state::DaemonCommand::CleanupWorktree { - task_id: task.id, - delete_branch: true, // Delete the branch when contract is done - }; - - match state.send_daemon_command(daemon_id, cmd).await { - Ok(()) => { - tracing::info!( - task_id = %task.id, - daemon_id = %daemon_id, - contract_id = %contract_id, - "Sent worktree cleanup command" - ); - } - Err(e) => { - tracing::warn!( - task_id = %task.id, - daemon_id = %daemon_id, - contract_id = %contract_id, - error = %e, - "Failed to send worktree cleanup command (daemon may be offline)" - ); - } - } - } else { - tracing::debug!( - task_id = %task.id, - contract_id = %contract_id, - "Task has no daemon assigned, skipping worktree cleanup" - ); - } - } -} - -// ============================================================================= -// Supervisor Status API -// ============================================================================= - -/// Query parameters for supervisor heartbeat history -#[derive(Debug, Deserialize)] -pub struct HeartbeatHistoryQuery { - /// Maximum number of heartbeats to return (default: 10) - pub limit: Option<i32>, - /// Offset for pagination (default: 0) - pub offset: Option<i32>, -} - -/// Get supervisor status for a contract. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/status", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor status", body = crate::db::models::SupervisorStatusResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or supervisor not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_supervisor_status( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if contract has a supervisor - let supervisor_task_id = match contract.supervisor_task_id { - Some(task_id) => task_id, - None => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), - ) - .into_response(); - } - }; - - // Get supervisor status from supervisor_states table - match repository::get_supervisor_status(pool, id, auth.owner_id).await { - Ok(Some(status_info)) => { - // Determine if supervisor is actively running - let is_running = status_info.is_running && status_info.task_status == "running"; - - let response = crate::db::models::SupervisorStatusResponse { - task_id: status_info.task_id, - state: status_info.supervisor_state, - phase: status_info.phase, - current_activity: status_info.current_activity, - progress: None, // We don't track progress percentage yet - last_heartbeat: status_info.last_heartbeat, - pending_task_ids: status_info.pending_task_ids, - is_running, - }; - Json(response).into_response() - } - Ok(None) => { - // No supervisor state record exists, but supervisor task might exist - // Try to get info from the task itself - match repository::get_task_for_owner(pool, supervisor_task_id, auth.owner_id).await { - Ok(Some(task)) => { - let is_running = task.daemon_id.is_some() && task.status == "running"; - let response = crate::db::models::SupervisorStatusResponse { - task_id: task.id, - state: task.status.clone(), - phase: contract.phase.clone(), - current_activity: task.progress_summary.clone(), - progress: None, - last_heartbeat: task.updated_at, - pending_task_ids: Vec::new(), - is_running, - }; - Json(response).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "Supervisor task not found")), - ) - .into_response(), - Err(e) => { - tracing::error!("Failed to get supervisor task {}: {}", supervisor_task_id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } - } - Err(e) => { - tracing::error!("Failed to get supervisor status for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -/// Get supervisor heartbeat history for a contract. -#[utoipa::path( - get, - path = "/api/v1/contracts/{id}/supervisor/heartbeats", - params( - ("id" = Uuid, Path, description = "Contract ID"), - ("limit" = Option<i32>, Query, description = "Maximum number of heartbeats to return (default: 10)"), - ("offset" = Option<i32>, Query, description = "Offset for pagination (default: 0)") - ), - responses( - (status = 200, description = "Supervisor heartbeat history", body = crate::db::models::SupervisorHeartbeatHistoryResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn get_supervisor_heartbeats( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, - axum::extract::Query(query): axum::extract::Query<HeartbeatHistoryQuery>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(_)) => {} - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - } - - let limit = query.limit.unwrap_or(10).min(100); // Cap at 100 - let offset = query.offset.unwrap_or(0); - - // Get activity history as heartbeats - let activities = match repository::get_supervisor_activity_history(pool, id, limit, offset).await { - Ok(activities) => activities, - Err(e) => { - tracing::error!("Failed to get supervisor heartbeats for contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Get total count for pagination - let total = match repository::count_supervisor_activity_history(pool, id).await { - Ok(count) => count, - Err(e) => { - tracing::warn!("Failed to count supervisor heartbeats: {}", e); - activities.len() as i64 - } - }; - - // Convert to heartbeat entries - let heartbeats: Vec<crate::db::models::SupervisorHeartbeatEntry> = activities - .into_iter() - .map(|a| crate::db::models::SupervisorHeartbeatEntry { - timestamp: a.timestamp, - state: a.state, - activity: a.activity, - progress: a.progress.map(|p| p as u8), - phase: a.phase, - pending_task_ids: a.pending_task_ids, - }) - .collect(); - - Json(crate::db::models::SupervisorHeartbeatHistoryResponse { - heartbeats, - total, - }) - .into_response() -} - -/// Sync supervisor state (refresh last_activity timestamp). -#[utoipa::path( - post, - path = "/api/v1/contracts/{id}/supervisor/sync", - params( - ("id" = Uuid, Path, description = "Contract ID") - ), - responses( - (status = 200, description = "Supervisor synced", body = crate::db::models::SupervisorSyncResponse), - (status = 401, description = "Unauthorized", body = ApiError), - (status = 404, description = "Contract or supervisor not found", body = ApiError), - (status = 503, description = "Database not configured", body = ApiError), - (status = 500, description = "Internal server error", body = ApiError), - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn sync_supervisor( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Path(id): Path<Uuid>, -) -> impl IntoResponse { - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), - ) - .into_response(); - }; - - // Verify contract exists and belongs to owner - let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("CONTRACT_NOT_FOUND", "Contract not found")), - ) - .into_response(); - } - Err(e) => { - tracing::error!("Failed to get contract {}: {}", id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response(); - } - }; - - // Check if contract has a supervisor - if contract.supervisor_task_id.is_none() { - return ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor task for this contract")), - ) - .into_response(); - } - - // Sync supervisor state (update last_activity) - match repository::sync_supervisor_state(pool, id).await { - Ok(Some(_state)) => { - // Get task status to determine current state - let task_status = if let Some(task_id) = contract.supervisor_task_id { - match repository::get_task_for_owner(pool, task_id, auth.owner_id).await { - Ok(Some(task)) => task.status, - _ => "unknown".to_string(), - } - } else { - "unknown".to_string() - }; - - Json(crate::db::models::SupervisorSyncResponse { - synced: true, - state: task_status, - message: Some("Supervisor state synced successfully".to_string()), - }) - .into_response() - } - Ok(None) => { - // No supervisor state exists, return not found - ( - StatusCode::NOT_FOUND, - Json(ApiError::new("SUPERVISOR_NOT_FOUND", "No supervisor state found for this contract")), - ) - .into_response() - } - Err(e) => { - tracing::error!("Failed to sync supervisor state for contract {}: {}", id, e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ApiError::new("DB_ERROR", e.to_string())), - ) - .into_response() - } - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -#[cfg(test)] -mod tests { - use super::*; - use crate::db::models::{DeliverableDefinition, PhaseConfig, PhaseDefinition}; - use crate::llm::{get_phase_deliverables_for_type, get_phase_deliverables_with_config}; - use std::collections::HashMap; - - #[test] - fn test_validate_deliverable_valid_simple_plan() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("plan-document", &phase_deliverables); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_deliverable_valid_simple_execute() { - let phase_deliverables = get_phase_deliverables_for_type("execute", "simple"); - let result = validate_deliverable("pull-request", &phase_deliverables); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_deliverable_invalid_id() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("nonexistent-deliverable", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Invalid deliverable")); - assert!(err.message.contains("nonexistent-deliverable")); - assert!(err.message.contains("plan-document")); - } - - #[test] - fn test_validate_deliverable_specification_phases() { - // Research phase - let phase_deliverables = get_phase_deliverables_for_type("research", "specification"); - assert!(validate_deliverable("research-notes", &phase_deliverables).is_ok()); - assert!(validate_deliverable("invalid", &phase_deliverables).is_err()); - - // Specify phase - let phase_deliverables = get_phase_deliverables_for_type("specify", "specification"); - assert!(validate_deliverable("requirements-document", &phase_deliverables).is_ok()); - assert!(validate_deliverable("plan-document", &phase_deliverables).is_err()); - - // Review phase - let phase_deliverables = get_phase_deliverables_for_type("review", "specification"); - assert!(validate_deliverable("release-notes", &phase_deliverables).is_ok()); - } - - #[test] - fn test_validate_deliverable_execute_type_no_deliverables() { - // Execute-only contracts have no deliverables - let phase_deliverables = get_phase_deliverables_for_type("execute", "execute"); - // Any deliverable should fail since there are none - let result = validate_deliverable("pull-request", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Valid IDs: []")); - } - - #[test] - fn test_validate_deliverable_with_custom_phase_config() { - // Create a custom phase config - let mut deliverables = HashMap::new(); - deliverables.insert( - "design".to_string(), - vec![ - DeliverableDefinition { - id: "architecture-doc".to_string(), - name: "Architecture Document".to_string(), - priority: "required".to_string(), - }, - DeliverableDefinition { - id: "api-spec".to_string(), - name: "API Specification".to_string(), - priority: "recommended".to_string(), - }, - ], - ); - - let phase_config = PhaseConfig { - phases: vec![ - PhaseDefinition { - id: "design".to_string(), - name: "Design".to_string(), - order: 0, - }, - PhaseDefinition { - id: "build".to_string(), - name: "Build".to_string(), - order: 1, - }, - ], - default_phase: "design".to_string(), - deliverables, - }; - - // Validate against custom config - let phase_deliverables = - get_phase_deliverables_with_config("design", "custom", Some(&phase_config)); - - // Valid custom deliverables - assert!(validate_deliverable("architecture-doc", &phase_deliverables).is_ok()); - assert!(validate_deliverable("api-spec", &phase_deliverables).is_ok()); - - // Invalid deliverable for custom config - let result = validate_deliverable("plan-document", &phase_deliverables); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.message.contains("Invalid deliverable")); - assert!(err.message.contains("plan-document")); - assert!(err.message.contains("architecture-doc")); - assert!(err.message.contains("api-spec")); - } - - #[test] - fn test_validate_deliverable_error_message_format() { - let phase_deliverables = get_phase_deliverables_for_type("plan", "simple"); - let result = validate_deliverable("xyz", &phase_deliverables); - let err = result.unwrap_err(); - - // Check error message format matches the specification - assert!(err.message.contains("Invalid deliverable 'xyz'")); - assert!(err.message.contains("plan phase")); - assert!(err.message.contains("Valid IDs:")); - assert!(err.message.contains("plan-document")); - } - - #[test] - fn test_deliverable_validation_error_display() { - let err = DeliverableValidationError::new("Test error message"); - assert_eq!(format!("{}", err), "Test error message"); - } - - #[test] - fn test_validate_deliverable_unknown_phase() { - // Unknown phase should return empty deliverables - let phase_deliverables = get_phase_deliverables_for_type("unknown", "simple"); - let result = validate_deliverable("any-id", &phase_deliverables); - assert!(result.is_err()); - } -} |
