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.rs960
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()
+ }
+ }
+}