summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/contract_daemon.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers/contract_daemon.rs')
-rw-r--r--makima/src/server/handlers/contract_daemon.rs936
1 files changed, 0 insertions, 936 deletions
diff --git a/makima/src/server/handlers/contract_daemon.rs b/makima/src/server/handlers/contract_daemon.rs
deleted file mode 100644
index 5f56f06..0000000
--- a/makima/src/server/handlers/contract_daemon.rs
+++ /dev/null
@@ -1,936 +0,0 @@
-//! 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, 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 completed deliverables for the current phase
- let completed_deliverables = contract.get_completed_deliverables(&contract.phase);
-
- // 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 {
- 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_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type);
-
- 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_for_type(&contract.phase, &contract.contract_type);
- 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 completed deliverables and tasks for checklist
- let completed_deliverables = contract.get_completed_deliverables(&contract.phase);
-
- let tasks = repository::list_tasks_in_contract(pool, id, auth.owner_id)
- .await
- .unwrap_or_default()
- .into_iter()
- .map(|t| TaskInfo {
- 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_for_type(&contract.phase, &completed_deliverables, &tasks, has_repository, &contract.contract_type);
-
- // 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()
- }
- }
-}