diff options
| author | soryu <soryu@soryu.co> | 2026-02-05 23:42:48 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-05 23:42:48 +0000 |
| commit | 88a4f15ce1310f8ee8693835be14aa5280233f17 (patch) | |
| tree | 5c1a0417e02071d2198d13478ffa85533b19f891 /makima/src/server | |
| parent | f1a50b80f3969d150bd1c31edde0aff05369157e (diff) | |
| download | soryu-88a4f15ce1310f8ee8693835be14aa5280233f17.tar.gz soryu-88a4f15ce1310f8ee8693835be14aa5280233f17.zip | |
Add directive-first chain system redesign
Redesigns the chain system with a directive-first architecture where
Directive is the top-level entity (the "why/what") and Chains are
generated execution plans (the "how") that can be dynamically modified.
Backend:
- Add database migration for directive system tables
- Add Directive, DirectiveChain, ChainStep, DirectiveEvent models
- Add DirectiveVerifier and DirectiveApproval models
- Add orchestration module with engine, planner, and verifier
- Add comprehensive API handlers for directives
- Add daemon CLI commands for directive management
- Add directive skill documentation
- Integrate contract completion with directive engine
- Add SSE endpoint for real-time directive events
Frontend:
- Add directives route with split-view layout
- Add 6-tab detail view (Overview, Chain, Events, Evaluations, Approvals, Verifiers)
- Add React Flow DAG visualization for chain steps
- Add SSE subscription hook for real-time event updates
- Add useDirectives and useDirectiveEventSubscription hooks
- Add directive types and API functions
Fixes:
- Fix test failures in ws/protocol, task_output, completion_gate, patch
- Fix word boundary matching in looks_like_task()
- Fix parse_last() to find actual last completion gate
- Fix create_export_patch when merge-base equals HEAD
- Clean up clippy warnings in new code
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/src/server')
| -rw-r--r-- | makima/src/server/handlers/contract_chat.rs | 1223 | ||||
| -rw-r--r-- | makima/src/server/handlers/contracts.rs | 140 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 1488 | ||||
| -rw-r--r-- | makima/src/server/handlers/mod.rs | 3 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 72 |
5 files changed, 1622 insertions, 1304 deletions
diff --git a/makima/src/server/handlers/contract_chat.rs b/makima/src/server/handlers/contract_chat.rs index 06b3a7c..8153093 100644 --- a/makima/src/server/handlers/contract_chat.rs +++ b/makima/src/server/handlers/contract_chat.rs @@ -17,8 +17,6 @@ use uuid::Uuid; use crate::db::{ models::{ ContractChatHistoryResponse, ContractWithRelations, CreateTaskRequest, UpdateFileRequest, - AddContractDefinitionRequest, UpdateContractDefinitionRequest, CreateChainRequest, - CreateChainDirectiveRequest, CreateContractEvaluationRequest, }, repository, }; @@ -2767,1211 +2765,26 @@ async fn handle_contract_request( } } - // Chain directive tools - for directive contracts to create and manage chains - ContractToolRequest::CreateChainFromDirective { name, description } => { - // First, get the current contract to verify it's a directive contract - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - // Check if contract already has a spawned chain - if contract.spawned_chain_id.is_some() { - return ContractRequestResult { - success: false, - message: "This contract already has a chain associated with it".to_string(), - data: Some(json!({ "existing_chain_id": contract.spawned_chain_id })), - }; - } - - // Create the chain - let chain_req = CreateChainRequest { - name: name.clone(), - description: description.clone(), - repositories: None, - loop_enabled: None, - loop_max_iterations: None, - loop_progress_check: None, - contracts: None, - }; - - let chain = match repository::create_chain_for_owner(pool, owner_id, chain_req).await { - Ok(c) => c, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to create chain: {}", e), - data: None, - }, - }; - - // Link the chain to this directive contract - if let Err(e) = sqlx::query( - r#" - UPDATE chains SET directive_contract_id = $2, evaluation_enabled = true WHERE id = $1; - UPDATE contracts SET spawned_chain_id = $1, is_chain_directive = true WHERE id = $2; - "#, - ) - .bind(chain.id) - .bind(contract_id) - .execute(pool) - .await { - return ContractRequestResult { - success: false, - message: format!("Failed to link chain to contract: {}", e), - data: None, - }; - } - - // Create empty directive for the chain - let directive_req = CreateChainDirectiveRequest { - requirements: Some(vec![]), - acceptance_criteria: Some(vec![]), - constraints: Some(vec![]), - external_dependencies: Some(vec![]), - source_type: Some("llm_generated".to_string()), - }; - - if let Err(e) = repository::create_chain_directive(pool, chain.id, directive_req).await { - return ContractRequestResult { - success: false, - message: format!("Failed to create directive: {}", e), - data: None, - }; - } - - ContractRequestResult { - success: true, - message: format!("Created chain '{}' linked to this directive contract", name), - data: Some(json!({ - "chain_id": chain.id, - "chain_name": name, - "description": description - })), - } - } - - ContractToolRequest::AddChainContract { name, description, contract_type, depends_on, requirement_ids } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain. Use create_chain_from_directive first.".to_string(), - data: None, - }, - }; - - // Check for duplicate names - let existing_defs = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - if existing_defs.iter().any(|d| d.name == name) { - return ContractRequestResult { - success: false, - message: format!("A contract definition with name '{}' already exists", name), - data: None, - }; - } - - // Create the contract definition - let def_req = AddContractDefinitionRequest { - name: name.clone(), - description, - contract_type: contract_type.unwrap_or_else(|| "implementation".to_string()), - initial_phase: Some("research".to_string()), - depends_on, - tasks: None, - deliverables: None, - validation: None, - editor_x: None, - editor_y: None, - }; - - let definition = match repository::create_chain_contract_definition(pool, chain_id, def_req).await { - Ok(d) => d, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to create contract definition: {}", e), - data: None, - }, - }; - - // Update requirement_ids if provided - if let Some(req_ids) = requirement_ids { - if !req_ids.is_empty() { - if let Err(e) = sqlx::query( - "UPDATE chain_contract_definitions SET requirement_ids = $2 WHERE id = $1" - ) - .bind(definition.id) - .bind(&req_ids) - .execute(pool) - .await { - tracing::warn!("Failed to set requirement_ids: {}", e); - } - } - } - - ContractRequestResult { - success: true, - message: format!("Added contract '{}' to chain", name), - data: Some(json!({ - "definition_id": definition.id, - "name": name, - "order_index": definition.order_index - })), - } - } - - ContractToolRequest::SetChainDependencies { contract_name, depends_on } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Find the definition by name - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - let definition = match definitions.iter().find(|d| d.name == contract_name) { - Some(d) => d, - None => return ContractRequestResult { - success: false, - message: format!("No contract definition named '{}' found", contract_name), - data: None, - }, - }; - - // Validate that all dependencies exist - for dep_name in &depends_on { - if !definitions.iter().any(|d| &d.name == dep_name) { - return ContractRequestResult { - success: false, - message: format!("Dependency '{}' does not exist", dep_name), - data: None, - }; - } - } - - // Check for circular dependencies (simple check) - if depends_on.contains(&contract_name) { - return ContractRequestResult { - success: false, - message: "A contract cannot depend on itself".to_string(), - data: None, - }; - } - - // Update dependencies - let update_req = UpdateContractDefinitionRequest { - name: None, - description: None, - contract_type: None, - initial_phase: None, - depends_on: Some(depends_on.clone()), - tasks: None, - deliverables: None, - validation: None, - editor_x: None, - editor_y: None, - }; - - match repository::update_chain_contract_definition(pool, definition.id, update_req).await { - Ok(_) => ContractRequestResult { - success: true, - message: format!("Updated dependencies for '{}'", contract_name), - data: Some(json!({ - "contract_name": contract_name, - "depends_on": depends_on - })), - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to update dependencies: {}", e), - data: None, - }, - } - } - - ContractToolRequest::ModifyChainContract { name, new_name, description, add_requirement_ids, remove_requirement_ids } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Find the definition by name - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - let definition = match definitions.iter().find(|d| d.name == name) { - Some(d) => d.clone(), - None => return ContractRequestResult { - success: false, - message: format!("No contract definition named '{}' found", name), - data: None, - }, - }; - - // Check if new name would conflict - if let Some(ref nn) = new_name { - if nn != &name && definitions.iter().any(|d| &d.name == nn) { - return ContractRequestResult { - success: false, - message: format!("A contract definition named '{}' already exists", nn), - data: None, - }; - } - } - - // Update the definition - let update_req = UpdateContractDefinitionRequest { - name: new_name.clone(), - description, - contract_type: None, - initial_phase: None, - depends_on: None, - tasks: None, - deliverables: None, - validation: None, - editor_x: None, - editor_y: None, - }; - - if let Err(e) = repository::update_chain_contract_definition(pool, definition.id, update_req).await { - return ContractRequestResult { - success: false, - message: format!("Failed to update definition: {}", e), - data: None, - }; - } - - // Handle requirement_ids modifications - let mut current_req_ids: Vec<String> = definition.requirement_ids.clone(); - if let Some(add_ids) = add_requirement_ids { - for id in add_ids { - if !current_req_ids.contains(&id) { - current_req_ids.push(id); - } - } - } - if let Some(remove_ids) = remove_requirement_ids { - current_req_ids.retain(|id| !remove_ids.contains(id)); - } - - if current_req_ids != definition.requirement_ids { - if let Err(e) = sqlx::query( - "UPDATE chain_contract_definitions SET requirement_ids = $2 WHERE id = $1" - ) - .bind(definition.id) - .bind(¤t_req_ids) - .execute(pool) - .await { - tracing::warn!("Failed to update requirement_ids: {}", e); - } - } - - ContractRequestResult { - success: true, - message: format!("Modified contract definition '{}'", new_name.as_ref().unwrap_or(&name)), - data: Some(json!({ - "definition_id": definition.id, - "name": new_name.as_ref().unwrap_or(&name), - "requirement_ids": current_req_ids - })), - } - } - - ContractToolRequest::RemoveChainContract { name } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Find the definition by name - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - let definition = match definitions.iter().find(|d| d.name == name) { - Some(d) => d, - None => return ContractRequestResult { - success: false, - message: format!("No contract definition named '{}' found", name), - data: None, - }, - }; - - // Check if other definitions depend on this one - let dependents: Vec<&str> = definitions.iter() - .filter(|d| d.depends_on_names.contains(&name)) - .map(|d| d.name.as_str()) - .collect(); - - if !dependents.is_empty() { - return ContractRequestResult { - success: false, - message: format!("Cannot remove '{}': other contracts depend on it: {}", name, dependents.join(", ")), - data: None, - }; - } - - // Delete the definition - match repository::delete_chain_contract_definition(pool, definition.id).await { - Ok(true) => ContractRequestResult { - success: true, - message: format!("Removed contract definition '{}'", name), - data: Some(json!({ "removed": name })), - }, - Ok(false) => ContractRequestResult { - success: false, - message: format!("Failed to remove '{}': not found", name), - data: None, - }, - Err(e) => ContractRequestResult { - success: false, - message: format!("Failed to remove definition: {}", e), - data: None, - }, - } - } - - ContractToolRequest::PreviewChainDag => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Get chain details and definitions - let chain = match repository::get_chain_for_owner(pool, chain_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Chain not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - // Build DAG representation - let nodes: Vec<serde_json::Value> = definitions.iter().map(|d| { - json!({ - "name": d.name, - "description": d.description, - "contract_type": d.contract_type, - "depends_on": d.depends_on_names, - "requirement_ids": d.requirement_ids - }) - }).collect(); - - // Build ASCII DAG representation - let mut ascii_dag = String::new(); - ascii_dag.push_str(&format!("Chain: {} ({})\n", chain.name, chain.status)); - ascii_dag.push_str(&format!("Contracts: {}\n\n", definitions.len())); - - // Find root nodes (no dependencies) - let roots: Vec<&str> = definitions.iter() - .filter(|d| d.depends_on_names.is_empty()) - .map(|d| d.name.as_str()) - .collect(); - - ascii_dag.push_str("Root contracts (no dependencies):\n"); - for root in &roots { - ascii_dag.push_str(&format!(" [{}]\n", root)); - } - - ascii_dag.push_str("\nDependency relationships:\n"); - for def in &definitions { - if !def.depends_on_names.is_empty() { - ascii_dag.push_str(&format!(" {} <- {}\n", def.name, def.depends_on_names.join(", "))); - } - } - - ContractRequestResult { - success: true, - message: format!("Chain DAG preview with {} contracts", definitions.len()), - data: Some(json!({ - "chain_id": chain_id, - "chain_name": chain.name, - "chain_status": chain.status, - "contract_count": definitions.len(), - "nodes": nodes, - "ascii_dag": ascii_dag - })), - } - } - - ContractToolRequest::ValidateChainDirective => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - let mut errors: Vec<String> = Vec::new(); - let mut warnings: Vec<String> = Vec::new(); - - // Check for empty chain - if definitions.is_empty() { - errors.push("Chain has no contract definitions".to_string()); - } - - // Check for circular dependencies - let def_names: std::collections::HashSet<String> = definitions.iter().map(|d| d.name.clone()).collect(); - for def in &definitions { - for dep in &def.depends_on_names { - if !def_names.contains(dep) { - errors.push(format!("'{}' depends on non-existent contract '{}'", def.name, dep)); - } - } - } - - // Simple cycle detection using DFS - fn has_cycle( - name: &str, - definitions: &[crate::db::models::ChainContractDefinition], - visited: &mut std::collections::HashSet<String>, - rec_stack: &mut std::collections::HashSet<String>, - ) -> Option<String> { - visited.insert(name.to_string()); - rec_stack.insert(name.to_string()); - - if let Some(def) = definitions.iter().find(|d| d.name == name) { - for dep in &def.depends_on_names { - if !visited.contains(dep) { - if let Some(cycle) = has_cycle(dep, definitions, visited, rec_stack) { - return Some(cycle); - } - } else if rec_stack.contains(dep) { - return Some(format!("{} -> {}", name, dep)); - } - } - } - - rec_stack.remove(name); - None - } - - let mut visited = std::collections::HashSet::new(); - for def in &definitions { - if !visited.contains(&def.name) { - let mut rec_stack = std::collections::HashSet::new(); - if let Some(cycle) = has_cycle(&def.name, &definitions, &mut visited, &mut rec_stack) { - errors.push(format!("Circular dependency detected: {}", cycle)); - break; - } - } - } - - // Check for orphan contracts (no one depends on them and they're not root) - let roots: std::collections::HashSet<&str> = definitions.iter() - .filter(|d| d.depends_on_names.is_empty()) - .map(|d| d.name.as_str()) - .collect(); - - let depended_on: std::collections::HashSet<&str> = definitions.iter() - .flat_map(|d| d.depends_on_names.iter().map(|s| s.as_str())) - .collect(); - - for def in &definitions { - if !roots.contains(def.name.as_str()) && !depended_on.contains(def.name.as_str()) { - warnings.push(format!("'{}' has dependencies but nothing depends on it (orphan leaf)", def.name)); - } - } - - // Get directive to check requirement coverage - if let Ok(Some(directive)) = repository::get_chain_directive(pool, chain_id).await { - let requirements: Vec<crate::db::models::DirectiveRequirement> = - serde_json::from_value(directive.requirements.clone()).unwrap_or_default(); - - let covered: std::collections::HashSet<&str> = definitions.iter() - .flat_map(|d| d.requirement_ids.iter().map(|s| s.as_str())) - .collect(); - - for req in &requirements { - if !covered.contains(req.id.as_str()) { - warnings.push(format!("Requirement '{}' ({}) is not covered by any contract", req.id, req.title)); - } - } - } - - let is_valid = errors.is_empty(); - - ContractRequestResult { - success: is_valid, - message: if is_valid { - format!("Chain is valid with {} contracts", definitions.len()) - } else { - format!("Chain validation failed with {} errors", errors.len()) - }, - data: Some(json!({ - "valid": is_valid, - "contract_count": definitions.len(), - "errors": errors, - "warnings": warnings - })), - } - } - - ContractToolRequest::FinalizeChainDirective { auto_start } => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Get chain - let chain = match repository::get_chain_for_owner(pool, chain_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Chain not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - if chain.status != "pending" { - return ContractRequestResult { - success: false, - message: format!("Chain is already {} - cannot finalize", chain.status), - data: None, - }; - } - - // Update chain status - let new_status = if auto_start { "active" } else { "pending" }; - if let Err(e) = sqlx::query("UPDATE chains SET status = $2 WHERE id = $1") - .bind(chain_id) - .bind(new_status) - .execute(pool) - .await { - return ContractRequestResult { - success: false, - message: format!("Failed to update chain status: {}", e), - data: None, - }; - } - - // If auto_start, trigger chain progression to create root contracts - if auto_start { - match repository::progress_chain(pool, chain_id, owner_id).await { - Ok(result) => { - ContractRequestResult { - success: true, - message: format!("Chain finalized and started. Created {} root contracts.", result.contracts_created.len()), - data: Some(json!({ - "chain_id": chain_id, - "status": "active", - "contracts_created": result.contracts_created, - "chain_completed": result.chain_completed - })), - } - } - Err(e) => ContractRequestResult { - success: false, - message: format!("Chain finalized but failed to start: {}", e), - data: Some(json!({ "chain_id": chain_id, "status": "active" })), - }, - } - } else { - ContractRequestResult { - success: true, - message: "Chain finalized but not started. Call finalize_chain_directive with auto_start=true to start.".to_string(), - data: Some(json!({ - "chain_id": chain_id, - "status": "pending" - })), - } - } - } - - ContractToolRequest::GetChainStatus => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Get chain details - let chain = match repository::get_chain_for_owner(pool, chain_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Chain not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - // Get definitions - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - // Get instantiated contracts - let chain_contracts = match repository::list_chain_contracts(pool, chain_id).await { - Ok(cc) => cc, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list chain contracts: {}", e), - data: None, - }, - }; - - // Build status map - let contract_statuses: Vec<serde_json::Value> = chain_contracts.iter().map(|cc| { - json!({ - "name": cc.contract_name, - "contract_id": cc.contract_id, - "status": cc.contract_status, - "phase": cc.contract_phase, - "evaluation_status": cc.evaluation_status, - "evaluation_retry_count": cc.evaluation_retry_count - }) - }).collect(); - - let completed = chain_contracts.iter().filter(|cc| cc.contract_status == "completed").count(); - let active = chain_contracts.iter().filter(|cc| cc.contract_status == "active").count(); - let pending = definitions.len() - chain_contracts.len(); - - ContractRequestResult { - success: true, - message: format!("Chain '{}': {} completed, {} active, {} pending", - chain.name, completed, active, pending), - data: Some(json!({ - "chain_id": chain_id, - "chain_name": chain.name, - "chain_status": chain.status, - "total_definitions": definitions.len(), - "instantiated": chain_contracts.len(), - "completed": completed, - "active": active, - "pending": pending, - "contracts": contract_statuses - })), - } - } - - ContractToolRequest::GetUncoveredRequirements => { - // Get the contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Get directive - let directive = match repository::get_chain_directive(pool, chain_id).await { - Ok(Some(d)) => d, - Ok(None) => return ContractRequestResult { - success: true, - message: "No directive found for this chain".to_string(), - data: Some(json!({ "uncovered": [], "total_requirements": 0 })), - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - // Get definitions - let definitions = match repository::list_chain_contract_definitions(pool, chain_id).await { - Ok(defs) => defs, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to list definitions: {}", e), - data: None, - }, - }; - - // Parse requirements - let requirements: Vec<crate::db::models::DirectiveRequirement> = - serde_json::from_value(directive.requirements.clone()).unwrap_or_default(); - - // Find covered requirement IDs - let covered: std::collections::HashSet<String> = definitions.iter() - .flat_map(|d| d.requirement_ids.iter().cloned()) - .collect(); - - // Find uncovered requirements - let uncovered: Vec<serde_json::Value> = requirements.iter() - .filter(|r| !covered.contains(&r.id)) - .map(|r| json!({ - "id": r.id, - "title": r.title, - "priority": r.priority - })) - .collect(); - - ContractRequestResult { - success: true, - message: format!("{} of {} requirements are uncovered", uncovered.len(), requirements.len()), - data: Some(json!({ - "uncovered": uncovered, - "uncovered_count": uncovered.len(), - "total_requirements": requirements.len(), - "coverage_percent": if requirements.is_empty() { 100.0 } else { - ((requirements.len() - uncovered.len()) as f64 / requirements.len() as f64 * 100.0).round() - } - })), - } - } - - ContractToolRequest::EvaluateContractCompletion { contract_id: target_contract_id, passed, feedback, rework_instructions } => { - // Get the directive contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Verify the target contract is part of this chain - let chain_contract = match repository::get_chain_contract_by_contract_id(pool, target_contract_id).await { - Ok(Some(cc)) => cc, - Ok(None) => return ContractRequestResult { - success: false, - message: format!("Contract {} is not part of a chain", target_contract_id), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - if chain_contract.chain_id != chain_id { - return ContractRequestResult { - success: false, - message: "Contract is not part of this directive's chain".to_string(), - data: None, - }; - } - - // Create evaluation record - let eval_req = CreateContractEvaluationRequest { - contract_id: target_contract_id, - chain_id: Some(chain_id), - chain_contract_id: Some(chain_contract.id), - evaluator_model: Some("directive_contract".to_string()), - passed, - overall_score: if passed { Some(1.0) } else { Some(0.0) }, - criteria_results: vec![], - summary_feedback: feedback.clone(), - rework_instructions: rework_instructions.clone(), - }; - - let evaluation = match repository::create_contract_evaluation(pool, eval_req).await { - Ok(e) => e, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Failed to create evaluation: {}", e), - data: None, - }, - }; - - // Update chain contract evaluation status - let new_status = if passed { "passed" } else { "failed" }; - if let Err(e) = repository::update_chain_contract_evaluation_status( - pool, - chain_contract.id, - new_status, - Some(evaluation.id), - None, // No rework feedback for passed/failed status - ).await { - tracing::warn!("Failed to update chain contract evaluation status: {}", e); - } - - if passed { - // Progress the chain to create downstream contracts - match repository::progress_chain(pool, chain_id, owner_id).await { - Ok(result) => ContractRequestResult { - success: true, - message: format!("Evaluation passed. Created {} downstream contracts.", result.contracts_created.len()), - data: Some(json!({ - "evaluation_id": evaluation.id, - "passed": true, - "contracts_created": result.contracts_created, - "chain_completed": result.chain_completed - })), - }, - Err(e) => ContractRequestResult { - success: true, - message: format!("Evaluation passed but failed to progress chain: {}", e), - data: Some(json!({ - "evaluation_id": evaluation.id, - "passed": true - })), - }, - } - } else { - // Mark contract for rework - if let Err(e) = sqlx::query( - r#" - UPDATE chain_contracts SET evaluation_status = 'rework', rework_feedback = $2 WHERE id = $1; - UPDATE contracts SET status = 'active' WHERE id = (SELECT contract_id FROM chain_contracts WHERE id = $1); - "# - ) - .bind(chain_contract.id) - .bind(&rework_instructions) - .execute(pool) - .await { - tracing::warn!("Failed to mark contract for rework: {}", e); - } - - ContractRequestResult { - success: true, - message: format!("Evaluation failed. Contract marked for rework."), - data: Some(json!({ - "evaluation_id": evaluation.id, - "passed": false, - "rework_instructions": rework_instructions, - "retry_count": chain_contract.evaluation_retry_count + 1 - })), - } - } - } - - ContractToolRequest::RequestRework { contract_id: target_contract_id, feedback } => { - // Get the directive contract's spawned chain - let contract = match repository::get_contract_for_owner(pool, contract_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => return ContractRequestResult { - success: false, - message: "Contract not found".to_string(), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - let chain_id = match contract.spawned_chain_id { - Some(id) => id, - None => return ContractRequestResult { - success: false, - message: "This contract has no associated chain".to_string(), - data: None, - }, - }; - - // Verify the target contract is part of this chain - let chain_contract = match repository::get_chain_contract_by_contract_id(pool, target_contract_id).await { - Ok(Some(cc)) => cc, - Ok(None) => return ContractRequestResult { - success: false, - message: format!("Contract {} is not part of a chain", target_contract_id), - data: None, - }, - Err(e) => return ContractRequestResult { - success: false, - message: format!("Database error: {}", e), - data: None, - }, - }; - - if chain_contract.chain_id != chain_id { - return ContractRequestResult { - success: false, - message: "Contract is not part of this directive's chain".to_string(), - data: None, - }; - } - - // Check retry count - let max_retries = chain_contract.max_evaluation_retries; - if chain_contract.evaluation_retry_count >= max_retries { - return ContractRequestResult { - success: false, - message: format!("Contract has exceeded max retries ({}/{}). Escalate to user.", - chain_contract.evaluation_retry_count, max_retries), - data: Some(json!({ - "retry_count": chain_contract.evaluation_retry_count, - "max_retries": max_retries, - "escalation_required": true - })), - }; - } - - // Mark contract for rework and increment retry count - if let Err(e) = sqlx::query( - r#" - UPDATE chain_contracts - SET evaluation_status = 'rework', - rework_feedback = $2, - evaluation_retry_count = evaluation_retry_count + 1 - WHERE id = $1; - UPDATE contracts SET status = 'active' WHERE id = (SELECT contract_id FROM chain_contracts WHERE id = $1); - "# - ) - .bind(chain_contract.id) - .bind(&feedback) - .execute(pool) - .await { - return ContractRequestResult { - success: false, - message: format!("Failed to request rework: {}", e), - data: None, - }; - } + // Chain directive tools - TEMPORARILY DISABLED + // These tools will be reimplemented using the new directive system. + // See the orchestration module for the new implementation. + ContractToolRequest::CreateChainFromDirective { .. } | + ContractToolRequest::AddChainContract { .. } | + ContractToolRequest::SetChainDependencies { .. } | + ContractToolRequest::ModifyChainContract { .. } | + ContractToolRequest::RemoveChainContract { .. } | + ContractToolRequest::PreviewChainDag | + ContractToolRequest::ValidateChainDirective | + ContractToolRequest::FinalizeChainDirective { .. } | + ContractToolRequest::GetChainStatus | + ContractToolRequest::GetUncoveredRequirements | + ContractToolRequest::EvaluateContractCompletion { .. } | + ContractToolRequest::RequestRework { .. } => { ContractRequestResult { - success: true, - message: format!("Rework requested for contract. Retry {}/{}", - chain_contract.evaluation_retry_count + 1, max_retries), - data: Some(json!({ - "contract_id": target_contract_id, - "retry_count": chain_contract.evaluation_retry_count + 1, - "max_retries": max_retries, - "feedback": feedback - })), + success: false, + message: "Chain directive tools are temporarily disabled. The directive system is being reimplemented.".to_string(), + data: None, } } } diff --git a/makima/src/server/handlers/contracts.rs b/makima/src/server/handlers/contracts.rs index 2b2fc26..8a6ce0f 100644 --- a/makima/src/server/handlers/contracts.rs +++ b/makima/src/server/handlers/contracts.rs @@ -575,78 +575,90 @@ pub async fn update_contract( }), ).await; - // If contract is part of a chain, check evaluation requirements - if let Some(chain_id) = contract.chain_id { - let pool_clone = pool.clone(); - let owner_id = auth.owner_id; - let contract_id = contract.id; - tokio::spawn(async move { - // Check if chain has evaluation enabled - let chain = match repository::get_chain_for_owner(&pool_clone, chain_id, owner_id).await { - Ok(Some(c)) => c, - Ok(None) => { - tracing::warn!(chain_id = %chain_id, "Chain not found for progression"); - return; - } - Err(e) => { - tracing::error!(chain_id = %chain_id, error = %e, "Failed to get chain"); - return; - } - }; - - // If evaluation is enabled, mark contract for evaluation - if chain.evaluation_enabled { - // Mark the chain_contract as pending evaluation - if let Ok(Some(chain_contract)) = repository::get_chain_contract_by_contract_id(&pool_clone, contract_id).await { - if let Err(e) = repository::update_chain_contract_evaluation_status( - &pool_clone, - chain_contract.id, - "pending_evaluation", - None, - None, - ).await { - tracing::error!( - chain_id = %chain_id, - contract_id = %contract_id, - error = %e, - "Failed to mark contract for evaluation" - ); - } else { - tracing::info!( - chain_id = %chain_id, - contract_id = %contract_id, - "Contract marked for evaluation - waiting for directive contract to evaluate" + // If contract is part of a directive chain step, update the step status + // and emit an event for the directive engine to process + let pool_for_step = pool.clone(); + let contract_id_for_step = contract.id; + tokio::spawn(async move { + // Look up the step by contract_id + match repository::get_step_by_contract_id(&pool_for_step, contract_id_for_step).await { + Ok(Some(step)) => { + // Get the chain to find the directive_id + let directive_id = match repository::get_directive_chain(&pool_for_step, step.chain_id).await { + Ok(Some(chain)) => chain.directive_id, + Ok(None) => { + tracing::warn!( + chain_id = %step.chain_id, + "Chain not found for step" ); + return; } - } - // Don't progress chain - directive contract will evaluate and progress - return; - } - - // If evaluation is disabled, progress chain directly - match repository::progress_chain(&pool_clone, chain_id, owner_id).await { - Ok(result) => { - if !result.contracts_created.is_empty() { - tracing::info!( - chain_id = %chain_id, - contracts_created = ?result.contracts_created, - "Chain progressed - created new contracts" + Err(e) => { + tracing::warn!( + chain_id = %step.chain_id, + error = %e, + "Failed to get chain for step" ); + return; } - if result.chain_completed { - tracing::info!(chain_id = %chain_id, "Chain completed"); - } - } - Err(e) => { - tracing::error!( - chain_id = %chain_id, + }; + + // Update step status to 'evaluating' + if let Err(e) = repository::update_step_status(&pool_for_step, step.id, "evaluating").await { + tracing::warn!( + step_id = %step.id, + contract_id = %contract_id_for_step, error = %e, - "Failed to progress chain after contract completion" + "Failed to update step status to evaluating" ); + } else { + tracing::info!( + step_id = %step.id, + contract_id = %contract_id_for_step, + chain_id = %step.chain_id, + directive_id = %directive_id, + "Contract completed - step transitioned to evaluating" + ); + + // Emit directive event for contract completion + if let Err(e) = repository::emit_directive_event( + &pool_for_step, + directive_id, + Some(step.chain_id), + Some(step.id), + "contract_completed", + "info", + Some(serde_json::json!({ + "contract_id": contract_id_for_step, + "step_id": step.id, + "step_name": step.name + })), + "system", + None, + ).await { + tracing::warn!( + step_id = %step.id, + error = %e, + "Failed to emit contract_completed directive event" + ); + } } } - }); - } + Ok(None) => { + tracing::debug!( + contract_id = %contract_id_for_step, + "Contract not linked to any directive chain step" + ); + } + Err(e) => { + tracing::warn!( + contract_id = %contract_id_for_step, + error = %e, + "Failed to look up step for completed contract" + ); + } + } + }); } // Get summary with counts diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs new file mode 100644 index 0000000..6f6c3f1 --- /dev/null +++ b/makima/src/server/handlers/directives.rs @@ -0,0 +1,1488 @@ +//! API handlers for directives. +//! +//! Provides REST endpoints for managing directives, chains, steps, +//! evaluations, events, verifiers, and approvals. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + response::{ + sse::{Event, Sse}, + IntoResponse, + }, + Json, +}; +use futures::stream; +use serde::{Deserialize, Serialize}; +use std::convert::Infallible; +use std::time::Duration; +use uuid::Uuid; + +use crate::db::models::{ + AddStepRequest, CreateDirectiveRequest, CreateVerifierRequest, UpdateDirectiveRequest, + UpdateStepRequest, UpdateVerifierRequest, +}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// Query parameters for listing directives +#[derive(Debug, Deserialize)] +pub struct ListDirectivesQuery { + pub status: Option<String>, +} + +/// Query parameters for listing events +#[derive(Debug, Deserialize)] +pub struct ListEventsQuery { + pub limit: Option<i64>, +} + +/// Query parameters for listing evaluations +#[derive(Debug, Deserialize)] +pub struct ListEvaluationsQuery { + pub limit: Option<i64>, + #[serde(rename = "stepId")] + pub step_id: Option<Uuid>, +} + +/// Response for directive creation +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateDirectiveResponse { + pub id: Uuid, + pub title: String, + pub status: String, +} + +/// Response for approval actions +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalActionRequest { + pub response: Option<String>, +} + +// ============================================================================= +// Directive CRUD +// ============================================================================= + +/// Create a new directive +/// POST /api/v1/directives +pub async fn create_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Json(req): Json<CreateDirectiveRequest>, +) -> 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_directive_for_owner(pool, auth.owner_id, req).await { + Ok(directive) => Json(CreateDirectiveResponse { + id: directive.id, + title: directive.title, + status: directive.status, + }) + .into_response(), + Err(e) => { + tracing::error!("Failed to create directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// List directives for the authenticated owner +/// GET /api/v1/directives +pub async fn list_directives( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Query(params): Query<ListDirectivesQuery>, +) -> 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_directives_for_owner(pool, auth.owner_id, params.status.as_deref()).await + { + Ok(directives) => Json(directives).into_response(), + Err(e) => { + tracing::error!("Failed to list directives: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get a directive with progress details +/// GET /api/v1/directives/:id +pub async fn get_directive( + 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(); + }; + + match repository::get_directive_with_progress(pool, id, auth.owner_id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a directive +/// PUT /api/v1/directives/:id +pub async fn update_directive( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<UpdateDirectiveRequest>, +) -> 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_directive_for_owner(pool, id, auth.owner_id, req).await { + Ok(directive) => Json(directive).into_response(), + Err(repository::RepositoryError::VersionConflict { expected, actual }) => ( + StatusCode::CONFLICT, + Json(ApiError::new( + "VERSION_CONFLICT", + &format!( + "Version conflict: expected {}, got {}", + expected, actual + ), + )), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Archive a directive +/// DELETE /api/v1/directives/:id +pub async fn archive_directive( + 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(); + }; + + match repository::archive_directive_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", "Directive not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to archive directive: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Directive Lifecycle +// ============================================================================= + +/// Start a directive (generate chain and begin execution) +/// POST /api/v1/directives/:id/start +pub async fn start_directive( + 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Start directive via orchestration engine + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.start_directive(id).await { + Ok(()) => { + // Return the updated directive with progress + match repository::get_directive_with_progress(pool, id, auth.owner_id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Err(e) => { + tracing::error!("Failed to start directive: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("START_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Pause a directive +/// POST /api/v1/directives/:id/pause +pub async fn pause_directive( + 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.pause_directive(id).await { + Ok(()) => match repository::get_directive(pool, id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + }, + Err(e) => { + tracing::error!("Failed to pause directive: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("PAUSE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Resume a paused directive +/// POST /api/v1/directives/:id/resume +pub async fn resume_directive( + 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.resume_directive(id).await { + Ok(()) => match repository::get_directive_with_progress(pool, id, auth.owner_id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + }, + Err(e) => { + tracing::error!("Failed to resume directive: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("RESUME_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Stop a directive (cannot be resumed) +/// POST /api/v1/directives/:id/stop +pub async fn stop_directive( + 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.stop_directive(id).await { + Ok(()) => match repository::get_directive(pool, id).await { + Ok(Some(directive)) => Json(directive).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + }, + Err(e) => { + tracing::error!("Failed to stop directive: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("STOP_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Chain Management +// ============================================================================= + +/// Get current chain for a directive +/// GET /api/v1/directives/:id/chain +pub async fn get_chain( + 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::get_current_chain(pool, id).await { + Ok(Some(chain)) => { + match repository::list_chain_steps(pool, chain.id).await { + Ok(steps) => Json(serde_json::json!({ + "chain": chain, + "steps": steps, + })) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "No active chain")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get chain: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get chain graph for DAG visualization +/// GET /api/v1/directives/:id/chain/graph +pub async fn get_chain_graph( + 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Get current chain + let chain = match repository::get_current_chain(pool, id).await { + Ok(Some(chain)) => chain, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "No active chain")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + match repository::get_chain_graph(pool, chain.id).await { + Ok(graph) => Json(graph).into_response(), + Err(e) => { + tracing::error!("Failed to get chain graph: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Regenerate chain (force replan) +/// POST /api/v1/directives/:id/chain/replan +pub async fn replan_chain( + 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine.regenerate_chain(id, "Manual replan requested").await { + Ok(new_chain_id) => Json(serde_json::json!({ + "chainId": new_chain_id, + "message": "Chain regenerated successfully", + })) + .into_response(), + Err(e) => { + tracing::error!("Failed to replan chain: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("REPLAN_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Step Management +// ============================================================================= + +/// Add a step to the current chain +/// POST /api/v1/directives/:id/chain/steps +pub async fn add_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<AddStepRequest>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Get current chain + let chain = match repository::get_current_chain(pool, id).await { + Ok(Some(chain)) => chain, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "No active chain")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + }; + + match repository::create_chain_step(pool, chain.id, req).await { + Ok(step) => (StatusCode::CREATED, Json(step)).into_response(), + Err(e) => { + tracing::error!("Failed to add step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get step details +/// GET /api/v1/directives/:id/chain/steps/:step_id +pub async fn get_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::get_chain_step(pool, step_id).await { + Ok(Some(step)) => Json(step).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a step +/// PUT /api/v1/directives/:id/chain/steps/:step_id +pub async fn update_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateStepRequest>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::update_chain_step(pool, step_id, req).await { + Ok(step) => Json(step).into_response(), + Err(e) => { + tracing::error!("Failed to update step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a step +/// DELETE /api/v1/directives/:id/chain/steps/:step_id +pub async fn delete_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::delete_chain_step(pool, step_id).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Step not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Skip a step +/// POST /api/v1/directives/:id/chain/steps/:step_id/skip +pub async fn skip_step( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, step_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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::update_step_status(pool, step_id, "skipped").await { + Ok(step) => Json(step).into_response(), + Err(e) => { + tracing::error!("Failed to skip step: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Evaluations +// ============================================================================= + +/// List evaluations for a directive +/// GET /api/v1/directives/:id/evaluations +pub async fn list_evaluations( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Query(params): Query<ListEvaluationsQuery>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let result = if let Some(step_id) = params.step_id { + repository::list_step_evaluations(pool, step_id).await + } else { + repository::list_directive_evaluations(pool, id, params.limit).await + }; + + match result { + Ok(evaluations) => Json(evaluations).into_response(), + Err(e) => { + tracing::error!("Failed to list evaluations: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Events +// ============================================================================= + +/// List events for a directive +/// GET /api/v1/directives/:id/events +pub async fn list_events( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Query(params): Query<ListEventsQuery>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::list_directive_events(pool, id, params.limit).await { + Ok(events) => Json(events).into_response(), + Err(e) => { + tracing::error!("Failed to list events: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// SSE stream of events for a directive +/// GET /api/v1/directives/:id/events/stream +pub async fn stream_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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + // Create SSE stream that polls for new events + let pool_clone = pool.clone(); + let stream = stream::unfold( + (pool_clone, id, None::<chrono::DateTime<chrono::Utc>>), + move |(pool, directive_id, last_seen)| async move { + // Wait a bit before next poll + tokio::time::sleep(Duration::from_secs(1)).await; + + // Get recent events + let events = repository::list_directive_events(&pool, directive_id, Some(10)) + .await + .unwrap_or_default(); + + // Filter to only new events + let new_events: Vec<_> = events + .into_iter() + .filter(|e| last_seen.map(|ls| e.created_at > ls).unwrap_or(true)) + .collect(); + + let new_last_seen = new_events.first().map(|e| e.created_at).or(last_seen); + + // Convert to SSE events + let sse_events: Vec<Result<Event, Infallible>> = new_events + .into_iter() + .map(|e| { + Ok(Event::default() + .event("directive_event") + .data(serde_json::to_string(&e).unwrap_or_default())) + }) + .collect(); + + Some((stream::iter(sse_events), (pool, directive_id, new_last_seen))) + }, + ); + + use futures::StreamExt; + Sse::new(stream.flatten()) + .keep_alive( + axum::response::sse::KeepAlive::new() + .interval(Duration::from_secs(15)) + .text("keepalive"), + ) + .into_response() +} + +// ============================================================================= +// Verifiers +// ============================================================================= + +/// List verifiers for a directive +/// GET /api/v1/directives/:id/verifiers +pub async fn list_verifiers( + 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::list_directive_verifiers(pool, id).await { + Ok(verifiers) => Json(verifiers).into_response(), + Err(e) => { + tracing::error!("Failed to list verifiers: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Add a verifier to a directive +/// POST /api/v1/directives/:id/verifiers +pub async fn add_verifier( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateVerifierRequest>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::create_directive_verifier( + pool, + id, + &req.name, + &req.verifier_type, + req.command.as_deref(), + req.working_directory.as_deref(), + false, // auto_detect + vec![], // detect_files + req.weight.unwrap_or(1.0), + req.required.unwrap_or(false), + ) + .await + { + Ok(verifier) => (StatusCode::CREATED, Json(verifier)).into_response(), + Err(e) => { + tracing::error!("Failed to add verifier: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Update a verifier +/// PUT /api/v1/directives/:id/verifiers/:verifier_id +pub async fn update_verifier( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, verifier_id)): Path<(Uuid, Uuid)>, + Json(req): Json<UpdateVerifierRequest>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::update_directive_verifier( + pool, + verifier_id, + req.enabled, + req.command.as_deref(), + req.weight, + req.required, + ) + .await + { + Ok(verifier) => Json(verifier).into_response(), + Err(e) => { + tracing::error!("Failed to update verifier: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +// ============================================================================= +// Approvals +// ============================================================================= + +/// List pending approvals for a directive +/// GET /api/v1/directives/:id/approvals +pub async fn list_approvals( + 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + match repository::list_pending_approvals(pool, id).await { + Ok(approvals) => Json(approvals).into_response(), + Err(e) => { + tracing::error!("Failed to list approvals: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } +} + +/// Approve a pending approval request +/// POST /api/v1/directives/:id/approvals/:approval_id/approve +pub async fn approve_request( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, approval_id)): Path<(Uuid, Uuid)>, + Json(req): Json<ApprovalActionRequest>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine + .on_approval_resolved(approval_id, true, auth.owner_id) + .await + { + Ok(()) => { + match repository::resolve_approval( + pool, + approval_id, + "approved", + req.response.as_deref(), + auth.owner_id, + ) + .await + { + Ok(approval) => Json(approval).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Err(e) => { + tracing::error!("Failed to process approval: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("APPROVAL_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Deny a pending approval request +/// POST /api/v1/directives/:id/approvals/:approval_id/deny +pub async fn deny_request( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, approval_id)): Path<(Uuid, Uuid)>, + Json(req): Json<ApprovalActionRequest>, +) -> 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 ownership + match repository::get_directive_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response() + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response() + } + } + + let engine = crate::orchestration::DirectiveEngine::new(pool.clone()); + match engine + .on_approval_resolved(approval_id, false, auth.owner_id) + .await + { + Ok(()) => { + match repository::resolve_approval( + pool, + approval_id, + "denied", + req.response.as_deref(), + auth.owner_id, + ) + .await + { + Ok(approval) => Json(approval).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", &e.to_string())), + ) + .into_response(), + } + } + Err(e) => { + tracing::error!("Failed to process denial: {}", e); + ( + StatusCode::BAD_REQUEST, + Json(ApiError::new("DENIAL_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 5e172bc..d3fabf7 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -1,7 +1,8 @@ //! HTTP and WebSocket request handlers. pub mod api_keys; -pub mod chains; +// pub mod chains; // Removed - replaced by directives +pub mod directives; pub mod chat; pub mod contract_chat; pub mod contract_daemon; diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index e5b55e7..927e9a5 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chains, chat, contract_chat, contract_daemon, contract_discuss, contracts, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, repository_history, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -214,51 +214,55 @@ pub fn make_router(state: SharedState) -> Router { ) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) - // Chain endpoints (multi-contract orchestration) + // Directive endpoints (replacement for chains) .route( - "/chains", - get(chains::list_chains).post(chains::create_chain), + "/directives", + get(directives::list_directives).post(directives::create_directive), ) - .route("/chains/init", post(chains::init_chain)) .route( - "/chains/{id}", - get(chains::get_chain) - .put(chains::update_chain) - .delete(chains::delete_chain), + "/directives/{id}", + get(directives::get_directive) + .put(directives::update_directive) + .delete(directives::archive_directive), ) - .route("/chains/{id}/contracts", get(chains::get_chain_contracts)) - .route("/chains/{id}/graph", get(chains::get_chain_graph)) - .route("/chains/{id}/events", get(chains::get_chain_events)) - .route("/chains/{id}/editor", get(chains::get_chain_editor)) - // Chain contract definitions + .route("/directives/{id}/start", post(directives::start_directive)) + .route("/directives/{id}/pause", post(directives::pause_directive)) + .route("/directives/{id}/resume", post(directives::resume_directive)) + .route("/directives/{id}/stop", post(directives::stop_directive)) + // Directive chain management + .route("/directives/{id}/chain", get(directives::get_chain)) + .route("/directives/{id}/chain/graph", get(directives::get_chain_graph)) + .route("/directives/{id}/chain/replan", post(directives::replan_chain)) + // Directive step management .route( - "/chains/{id}/definitions", - get(chains::list_chain_definitions).post(chains::create_chain_definition), + "/directives/{id}/chain/steps", + post(directives::add_step), ) .route( - "/chains/{chain_id}/definitions/{definition_id}", - put(chains::update_chain_definition).delete(chains::delete_chain_definition), + "/directives/{id}/chain/steps/{step_id}", + get(directives::get_step) + .put(directives::update_step) + .delete(directives::delete_step), ) + .route("/directives/{id}/chain/steps/{step_id}/skip", post(directives::skip_step)) + // Directive evaluations + .route("/directives/{id}/evaluations", get(directives::list_evaluations)) + // Directive events + .route("/directives/{id}/events", get(directives::list_events)) + .route("/directives/{id}/events/stream", get(directives::stream_events)) + // Directive verifiers .route( - "/chains/{id}/definitions/graph", - get(chains::get_chain_definition_graph), + "/directives/{id}/verifiers", + get(directives::list_verifiers).post(directives::add_verifier), ) - // Chain control - .route("/chains/{id}/start", post(chains::start_chain)) - .route("/chains/{id}/stop", post(chains::stop_chain)) - // Chain repositories .route( - "/chains/{id}/repositories", - get(chains::list_chain_repositories).post(chains::add_chain_repository), - ) - .route( - "/chains/{chain_id}/repositories/{repository_id}", - axum::routing::delete(chains::delete_chain_repository), - ) - .route( - "/chains/{chain_id}/repositories/{repository_id}/primary", - put(chains::set_chain_repository_primary), + "/directives/{id}/verifiers/{verifier_id}", + axum::routing::put(directives::update_verifier), ) + // Directive approvals + .route("/directives/{id}/approvals", get(directives::list_approvals)) + .route("/directives/{id}/approvals/{approval_id}/approve", post(directives::approve_request)) + .route("/directives/{id}/approvals/{approval_id}/deny", post(directives::deny_request)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) // Settings endpoints |
