summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/contracts.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers/contracts.rs')
-rw-r--r--makima/src/server/handlers/contracts.rs327
1 files changed, 308 insertions, 19 deletions
diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs
index 6c237dc..b15667d 100644
--- a/makima/src/server/handlers/contracts.rs
+++ b/makima/src/server/handlers/contracts.rs
@@ -13,14 +13,82 @@ use uuid::Uuid;
use crate::db::models::{
AddLocalRepositoryRequest, AddRemoteRepositoryRequest, ChangePhaseRequest,
ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations,
- CreateContractRequest, CreateManagedRepositoryRequest, UpdateContractRequest,
- UpdateTaskRequest,
+ 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(
@@ -1253,8 +1321,10 @@ pub async fn remove_task_from_contract(
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),
),
@@ -1299,6 +1369,7 @@ pub async fn change_phase(
};
// 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 {
@@ -1340,6 +1411,20 @@ pub async fn change_phase(
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,
@@ -1350,22 +1435,32 @@ pub async fn change_phase(
let transition_id = uuid::Uuid::new_v4().to_string();
return Json(serde_json::json!({
- "status": "pending_confirmation",
+ "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 transition requires confirmation. Set confirmed=true in the request to proceed."
+ "message": "Phase guard is enabled. User confirmation required."
}))
.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)) => {
+ // 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)) => {
// 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 {
@@ -1394,7 +1489,7 @@ pub async fn change_phase(
Some(&contract.phase),
serde_json::json!({
"contractName": &contract.name,
- "newPhase": &contract.phase,
+ "newPhase": &updated_contract.phase,
}),
).await;
@@ -1422,11 +1517,56 @@ pub async fn change_phase(
.into_response(),
}
}
- Ok(None) => (
+ 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);
(
@@ -1512,20 +1652,22 @@ pub async fn mark_deliverable_complete(
let target_phase = req.phase.unwrap_or_else(|| contract.phase.clone());
// Validate the deliverable ID exists for this phase/contract type
- let phase_deliverables = crate::llm::get_phase_deliverables_for_type(&target_phase, &contract.contract_type);
- let deliverable = phase_deliverables.deliverables.iter().find(|d| d.id == req.deliverable_id);
+ // 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(),
+ );
- if deliverable.is_none() {
- let valid_ids: Vec<&str> = phase_deliverables.deliverables.iter().map(|d| d.id.as_str()).collect();
+ // Validate deliverable exists
+ if let Err(validation_error) = validate_deliverable(&req.deliverable_id, &phase_deliverables) {
return (
StatusCode::BAD_REQUEST,
- Json(ApiError::new(
- "INVALID_DELIVERABLE",
- format!(
- "Invalid deliverable_id '{}' for {} phase (contract type: {}). Valid IDs: {:?}",
- req.deliverable_id, target_phase, contract.contract_type, valid_ids
- ),
- )),
+ Json(serde_json::json!({
+ "code": "INVALID_DELIVERABLE",
+ "message": validation_error.message,
+ })),
)
.into_response();
}
@@ -1729,3 +1871,150 @@ async fn cleanup_contract_worktrees(
}
}
}
+
+// =============================================================================
+// 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());
+ }
+}