diff options
| author | soryu <soryu@soryu.co> | 2026-01-11 05:52:14 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-15 00:21:16 +0000 |
| commit | 87044a747b47bd83249d61a45842c7f7b2eae56d (patch) | |
| tree | ef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/src/server/handlers/contract_daemon.rs | |
| parent | 077820c4167c168072d217a1b01df840463a12a8 (diff) | |
| download | soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip | |
Contract system
Diffstat (limited to 'makima/src/server/handlers/contract_daemon.rs')
| -rw-r--r-- | makima/src/server/handlers/contract_daemon.rs | 960 |
1 files changed, 960 insertions, 0 deletions
diff --git a/makima/src/server/handlers/contract_daemon.rs b/makima/src/server/handlers/contract_daemon.rs new file mode 100644 index 0000000..13c5640 --- /dev/null +++ b/makima/src/server/handlers/contract_daemon.rs @@ -0,0 +1,960 @@ +//! HTTP handlers for daemon-to-contract interaction. +//! +//! These endpoints allow tasks running in daemons to interact with their +//! associated contracts via the contract.sh script. Authentication is via +//! tool keys registered by the daemon when starting a task. + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::db::{models::FileSummary, repository}; +use crate::llm::phase_guidance::{self, FileInfo, PhaseChecklist, TaskInfo}; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +// ============================================================================= +// Request/Response Types +// ============================================================================= + +/// Contract status response for daemon. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractStatusResponse { + pub id: Uuid, + pub name: String, + pub phase: String, + pub status: String, + pub description: Option<String>, +} + +/// Contract goals response. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ContractGoalsResponse { + /// Description serves as goals for the contract + pub description: Option<String>, + pub phase: String, + pub phase_guidance: String, +} + +/// Progress report request from daemon. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProgressReportRequest { + pub message: String, + #[serde(default)] + pub task_id: Option<Uuid>, +} + +/// Suggested action from server. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SuggestedActionResponse { + pub action: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option<serde_json::Value>, +} + +/// Completion action request. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CompletionActionRequest { + #[serde(default)] + pub task_id: Option<Uuid>, + #[serde(default)] + pub files_modified: Vec<String>, + #[serde(default)] + pub lines_added: i32, + #[serde(default)] + pub lines_removed: i32, + #[serde(default)] + pub has_code_changes: bool, +} + +/// Recommended completion action. +#[derive(Debug, Clone, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum CompletionAction { + Branch, + Merge, + Pr, + None, +} + +impl std::fmt::Display for CompletionAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CompletionAction::Branch => write!(f, "branch"), + CompletionAction::Merge => write!(f, "merge"), + CompletionAction::Pr => write!(f, "pr"), + CompletionAction::None => write!(f, "none"), + } + } +} + +/// Completion action response. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CompletionActionResponse { + pub action: String, + pub reason: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch_name: Option<String>, +} + +/// Create file request from daemon. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateFileRequest { + pub name: String, + pub content: String, + #[serde(default)] + pub template_id: Option<String>, +} + +/// Update file request from daemon. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DaemonUpdateFileRequest { + /// Content to update in the file (as markdown body element) + pub content: String, +} + +// ============================================================================= +// Handlers +// ============================================================================= + +/// Get contract status for daemon. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/status", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Contract status", body = ContractStatusResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_contract_status( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(contract)) => Json(ContractStatusResponse { + id: contract.id, + name: contract.name, + phase: contract.phase, + status: contract.status, + description: contract.description, + }) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get phase deliverables checklist. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/checklist", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Phase checklist", body = PhaseChecklist), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_contract_checklist( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get contract + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Get files for this contract + let files = match repository::list_files_in_contract(pool, id, auth.owner_id).await { + Ok(f) => f + .into_iter() + .map(|f| FileInfo { + id: f.id, + name: f.name, + contract_phase: f.contract_phase, + }) + .collect::<Vec<_>>(), + Err(e) => { + tracing::warn!("Failed to get files for contract {}: {}", id, e); + Vec::new() + } + }; + + // Get tasks for this contract + let tasks = match repository::list_tasks_in_contract(pool, id, auth.owner_id).await { + Ok(t) => t + .into_iter() + .map(|t| TaskInfo { + id: t.id, + name: t.name, + status: t.status, + }) + .collect::<Vec<_>>(), + Err(e) => { + tracing::warn!("Failed to get tasks for contract {}: {}", id, e); + Vec::new() + } + }; + + // Check if repository is configured + let has_repository = match repository::list_contract_repositories(pool, id).await { + Ok(repos) => !repos.is_empty(), + Err(_) => false, + }; + + let checklist = phase_guidance::get_phase_checklist(&contract.phase, &files, &tasks, has_repository); + + Json(checklist).into_response() +} + +/// Get contract goals. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/goals", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Contract goals", body = ContractGoalsResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_contract_goals( + 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_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(contract)) => { + let deliverables = phase_guidance::get_phase_deliverables(&contract.phase); + Json(ContractGoalsResponse { + description: contract.description, + phase: contract.phase, + phase_guidance: deliverables.guidance, + }) + .into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Post progress report to contract. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/daemon/report", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = ProgressReportRequest, + responses( + (status = 200, description = "Report received"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn post_progress_report( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<ProgressReportRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Log the report as a contract event + let event_type = "progress_report"; + let payload = serde_json::json!({ + "message": req.message, + "task_id": req.task_id, + }); + + if let Err(e) = repository::record_contract_event(pool, id, event_type, Some(payload)).await { + tracing::warn!("Failed to create contract event: {}", e); + } + + Json(serde_json::json!({"status": "received"})).into_response() +} + +/// Get suggested action based on contract state. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/daemon/suggest-action", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "Suggested action", body = SuggestedActionResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_suggest_action( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get contract + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Get files and tasks for checklist + let files = repository::list_files_in_contract(pool, id, auth.owner_id) + .await + .unwrap_or_default() + .into_iter() + .map(|f| FileInfo { + id: f.id, + name: f.name, + contract_phase: f.contract_phase, + }) + .collect::<Vec<_>>(); + + let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id) + .await + .unwrap_or_default() + .into_iter() + .map(|t| TaskInfo { + id: t.id, + name: t.name, + status: t.status, + }) + .collect::<Vec<_>>(); + + let has_repository = repository::list_contract_repositories(pool, id) + .await + .map(|r| !r.is_empty()) + .unwrap_or(false); + + let checklist = phase_guidance::get_phase_checklist(&contract.phase, &files, &tasks, has_repository); + + // Determine suggested action based on checklist + let (action, description) = if !checklist.suggestions.is_empty() { + ("follow_suggestion", checklist.suggestions.first().unwrap().clone()) + } else if checklist.completion_percentage >= 100 { + ("advance_phase", format!("Phase {} is complete, consider advancing to next phase", contract.phase)) + } else { + ("continue", format!("Continue working on {} phase ({}% complete)", contract.phase, checklist.completion_percentage)) + }; + + Json(SuggestedActionResponse { + action: action.to_string(), + description, + data: None, + }) + .into_response() +} + +/// Get recommended completion action. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/daemon/completion-action", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = CompletionActionRequest, + responses( + (status = 200, description = "Recommended completion action", body = CompletionActionResponse), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_completion_action( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CompletionActionRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Get contract + let contract = match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(c)) => c, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Determine completion action based on phase and changes + let has_changes = !req.files_modified.is_empty() || req.lines_added > 0 || req.lines_removed > 0; + let has_significant_changes = req.lines_added + req.lines_removed > 50; + + let (action, reason) = match contract.phase.as_str() { + "research" | "specify" => { + if has_changes { + (CompletionAction::Merge, "Early phase changes can be merged directly".to_string()) + } else { + (CompletionAction::None, "No changes to commit".to_string()) + } + } + "plan" => { + if has_significant_changes { + (CompletionAction::Pr, "Significant planning changes require review".to_string()) + } else if has_changes { + (CompletionAction::Merge, "Minor planning changes can be merged".to_string()) + } else { + (CompletionAction::None, "No changes to commit".to_string()) + } + } + "execute" => { + if req.has_code_changes { + (CompletionAction::Pr, "Code changes in execute phase require review".to_string()) + } else if has_changes { + (CompletionAction::Branch, "Documentation changes can be branched".to_string()) + } else { + (CompletionAction::None, "No changes to commit".to_string()) + } + } + "review" => { + if has_changes { + (CompletionAction::Pr, "Review phase changes should be reviewed".to_string()) + } else { + (CompletionAction::None, "No changes to commit".to_string()) + } + } + _ => (CompletionAction::None, "Unknown phase".to_string()), + }; + + // Generate branch name based on contract + let branch_name = if matches!(action, CompletionAction::Branch | CompletionAction::Pr) { + let slug = contract.name.to_lowercase().replace(' ', "-"); + Some(format!("contract/{}", slug)) + } else { + None + }; + + Json(CompletionActionResponse { + action: action.to_string(), + reason, + branch_name, + }) + .into_response() +} + +/// List contract files for daemon. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/files", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + responses( + (status = 200, description = "List of contract files", body = Vec<FileSummary>), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn list_contract_files( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + match repository::list_files_in_contract(pool, id, auth.owner_id).await { + Ok(files) => Json(files).into_response(), + Err(e) => { + tracing::error!("Failed to list files for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a specific contract file. +#[utoipa::path( + get, + path = "/api/v1/contracts/{id}/daemon/files/{file_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("file_id" = Uuid, Path, description = "File ID") + ), + responses( + (status = 200, description = "File content"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or file not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn get_contract_file( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, file_id)): Path<(Uuid, Uuid)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Get file and verify it belongs to this contract + match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { + Ok(Some(file)) => { + if file.contract_id != Some(id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found in this contract")), + ) + .into_response(); + } + Json(file).into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get file {}: {}", file_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Update a contract file. +#[utoipa::path( + put, + path = "/api/v1/contracts/{id}/daemon/files/{file_id}", + params( + ("id" = Uuid, Path, description = "Contract ID"), + ("file_id" = Uuid, Path, description = "File ID") + ), + request_body = DaemonUpdateFileRequest, + responses( + (status = 200, description = "File updated"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract or file not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn update_contract_file( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, file_id)): Path<(Uuid, Uuid)>, + Json(req): Json<DaemonUpdateFileRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + } + + // Get file and verify it belongs to this contract + let file = match repository::get_file_for_owner(pool, file_id, auth.owner_id).await { + Ok(Some(f)) => f, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get file {}: {}", file_id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + if file.contract_id != Some(id) { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found in this contract")), + ) + .into_response(); + } + + // Update the file with content parsed as markdown + let body = crate::llm::markdown_to_body(&req.content); + let update_req = crate::db::models::UpdateFileRequest { + name: None, + description: None, + transcript: None, + summary: None, + body: Some(body), + version: None, + repo_file_path: None, + }; + + match repository::update_file_for_owner(pool, file_id, auth.owner_id, update_req).await { + Ok(Some(updated)) => Json(updated).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "File not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to update file {}: {}", file_id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", format!("{}", e))), + ) + .into_response() + } + } +} + +/// Create a new contract file. +#[utoipa::path( + post, + path = "/api/v1/contracts/{id}/daemon/files", + params( + ("id" = Uuid, Path, description = "Contract ID") + ), + request_body = CreateFileRequest, + responses( + (status = 201, description = "File created"), + (status = 401, description = "Unauthorized", body = ApiError), + (status = 404, description = "Contract not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security( + ("tool_key" = []), + ("api_key" = []) + ), + tag = "Contract Daemon" +)] +pub async fn create_contract_file( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<CreateFileRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify contract exists + match repository::get_contract_for_owner(pool, id, auth.owner_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Contract not found")), + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to get contract {}: {}", id, e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response(); + } + }; + + // Create the file with content parsed as markdown + let body = crate::llm::markdown_to_body(&req.content); + let create_req = crate::db::models::CreateFileRequest { + contract_id: id, + name: Some(req.name), + description: None, + transcript: vec![], + location: None, + body, + repo_file_path: None, + contract_phase: None, // Will be looked up from contract's current phase + }; + + match repository::create_file_for_owner(pool, auth.owner_id, create_req).await { + Ok(file) => (StatusCode::CREATED, Json(file)).into_response(), + Err(e) => { + tracing::error!("Failed to create file for contract {}: {}", id, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} |
