summaryrefslogtreecommitdiff
path: root/makima/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server')
-rw-r--r--makima/src/server/handlers/directive_documents.rs222
-rw-r--r--makima/src/server/handlers/files.rs5
-rw-r--r--makima/src/server/handlers/mesh.rs5
-rw-r--r--makima/src/server/handlers/versions.rs5
-rw-r--r--makima/src/server/mod.rs16
-rw-r--r--makima/src/server/openapi.rs5
6 files changed, 257 insertions, 1 deletions
diff --git a/makima/src/server/handlers/directive_documents.rs b/makima/src/server/handlers/directive_documents.rs
index ed38ee4..23081b5 100644
--- a/makima/src/server/handlers/directive_documents.rs
+++ b/makima/src/server/handlers/directive_documents.rs
@@ -21,7 +21,7 @@ use utoipa::ToSchema;
use uuid::Uuid;
use crate::db::models::{DirectiveStep, Task};
-use crate::db::repository;
+use crate::db::repository::{self, RepositoryError};
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::SharedState;
@@ -60,6 +60,16 @@ pub struct ReorderDirectiveDocumentRequest {
pub position: i32,
}
+/// Body for `POST /api/v1/contracts/{document_id}/complete`. Both fields
+/// are optional — supplying them records PR provenance; leaving them off
+/// just marks the contract done.
+#[derive(Debug, Default, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CompleteContractRequest {
+ pub pr_url: Option<String>,
+ pub pr_branch: Option<String>,
+}
+
/// Body for `POST /api/v1/contracts/{document_id}/ship`.
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
@@ -656,3 +666,213 @@ pub async fn reorder_contract(
}
}
}
+
+// =============================================================================
+// Contract lifecycle: start / pause / complete / unlock
+//
+// State machine: `draft → queued → active → shipped → archived`. The
+// repository functions enforce transition validity and handle queue
+// auto-promotion when the active slot frees. Handlers here own auth
+// and mapping to HTTP status codes.
+// =============================================================================
+
+/// Common path: load + ownership-check, then dispatch a state-transition
+/// closure. Cuts the boilerplate from start/pause/complete/unlock down
+/// to a couple of lines each.
+async fn run_contract_transition<F, Fut>(
+ pool: sqlx::PgPool,
+ owner_id: Uuid,
+ contract_id: Uuid,
+ f: F,
+) -> impl IntoResponse
+where
+ F: FnOnce(sqlx::PgPool, Uuid) -> Fut,
+ Fut: std::future::Future<
+ Output = Result<Option<crate::db::models::DirectiveDocument>, RepositoryError>,
+ >,
+{
+ match load_owned_document(&pool, owner_id, contract_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("GET_FAILED", &e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match f(pool, contract_id).await {
+ Ok(Some(doc)) => Json(doc).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Contract not found")),
+ )
+ .into_response(),
+ Err(RepositoryError::Validation(msg)) => (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION", &msg)),
+ )
+ .into_response(),
+ Err(RepositoryError::VersionConflict { expected, actual }) => (
+ StatusCode::CONFLICT,
+ Json(ApiError::new(
+ "VERSION_CONFLICT",
+ &format!("expected version {}, actual {}", expected, actual),
+ )),
+ )
+ .into_response(),
+ Err(RepositoryError::Database(e)) => {
+ tracing::error!("Contract transition failed: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("TRANSITION_FAILED", &e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Lock & start a draft contract. If a sibling is already `active`,
+/// this contract goes to `queued`; otherwise it activates immediately.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{document_id}/start",
+ params(("document_id" = Uuid, Path, description = "Contract ID")),
+ responses(
+ (status = 200, description = "Contract started", body = crate::db::models::DirectiveDocument),
+ (status = 400, description = "Invalid state transition", body = ApiError),
+ (status = 404, description = "Not found", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directive Documents"
+)]
+pub async fn start_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(document_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(pool) = state.db_pool.clone() else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+ run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move {
+ repository::start_contract(&pool, id).await
+ })
+ .await
+ .into_response()
+}
+
+/// Pause an active contract — moves it back to `queued` and lets the
+/// next queued sibling take the active slot. The orchestrator daemon
+/// stop is the caller's responsibility.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{document_id}/pause",
+ params(("document_id" = Uuid, Path, description = "Contract ID")),
+ responses(
+ (status = 200, description = "Contract paused", body = crate::db::models::DirectiveDocument),
+ (status = 400, description = "Invalid state transition", body = ApiError),
+ (status = 404, description = "Not found", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directive Documents"
+)]
+pub async fn pause_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(document_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(pool) = state.db_pool.clone() else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+ run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move {
+ repository::pause_contract(&pool, id).await
+ })
+ .await
+ .into_response()
+}
+
+/// Mark an active contract as `shipped` (work done). PR url + branch
+/// are optional — pass them to record provenance, leave them off to
+/// just close out the contract. Auto-promotes the next queued sibling.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{document_id}/complete",
+ params(("document_id" = Uuid, Path, description = "Contract ID")),
+ request_body = CompleteContractRequest,
+ responses(
+ (status = 200, description = "Contract completed", body = crate::db::models::DirectiveDocument),
+ (status = 400, description = "Invalid state transition", body = ApiError),
+ (status = 404, description = "Not found", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directive Documents"
+)]
+pub async fn complete_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(document_id): Path<Uuid>,
+ Json(req): Json<CompleteContractRequest>,
+) -> impl IntoResponse {
+ let Some(pool) = state.db_pool.clone() else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+ run_contract_transition(pool, auth.owner_id, document_id, move |pool, id| async move {
+ repository::complete_contract(&pool, id, req.pr_url.as_deref(), req.pr_branch.as_deref()).await
+ })
+ .await
+ .into_response()
+}
+
+/// Unlock a queued or active contract back to `draft` so its spec is
+/// editable again. If the contract was active, the slot frees and the
+/// next queued sibling auto-promotes.
+#[utoipa::path(
+ post,
+ path = "/api/v1/contracts/{document_id}/unlock",
+ params(("document_id" = Uuid, Path, description = "Contract ID")),
+ responses(
+ (status = 200, description = "Contract unlocked", body = crate::db::models::DirectiveDocument),
+ (status = 400, description = "Invalid state transition", body = ApiError),
+ (status = 404, description = "Not found", body = ApiError),
+ ),
+ security(("bearer_auth" = []), ("api_key" = [])),
+ tag = "Directive Documents"
+)]
+pub async fn unlock_contract(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(document_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(pool) = state.db_pool.clone() else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+ run_contract_transition(pool, auth.owner_id, document_id, |pool, id| async move {
+ repository::unlock_contract(&pool, id).await
+ })
+ .await
+ .into_response()
+}
diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs
index 05e871c..711be41 100644
--- a/makima/src/server/handlers/files.rs
+++ b/makima/src/server/handlers/files.rs
@@ -277,6 +277,11 @@ pub async fn update_file(
)
.into_response()
}
+ Err(RepositoryError::Validation(msg)) => (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION", &msg)),
+ )
+ .into_response(),
}
}
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
index 63b1827..be5387e 100644
--- a/makima/src/server/handlers/mesh.rs
+++ b/makima/src/server/handlers/mesh.rs
@@ -467,6 +467,11 @@ pub async fn update_task(
)
.into_response()
}
+ Err(RepositoryError::Validation(msg)) => (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION", &msg)),
+ )
+ .into_response(),
}
}
diff --git a/makima/src/server/handlers/versions.rs b/makima/src/server/handlers/versions.rs
index 15118d6..bb1b00c 100644
--- a/makima/src/server/handlers/versions.rs
+++ b/makima/src/server/handlers/versions.rs
@@ -203,5 +203,10 @@ pub async fn restore_version(
)
.into_response()
}
+ Err(RepositoryError::Validation(msg)) => (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("VALIDATION", &msg)),
+ )
+ .into_response(),
}
}
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index 68d3dea..a3a1886 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -242,6 +242,22 @@ pub fn make_router(state: SharedState) -> Router {
post(directive_documents::reorder_contract),
)
.route(
+ "/contracts/{document_id}/start",
+ post(directive_documents::start_contract),
+ )
+ .route(
+ "/contracts/{document_id}/pause",
+ post(directive_documents::pause_contract),
+ )
+ .route(
+ "/contracts/{document_id}/complete",
+ post(directive_documents::complete_contract),
+ )
+ .route(
+ "/contracts/{document_id}/unlock",
+ post(directive_documents::unlock_contract),
+ )
+ .route(
"/contracts/{document_id}/tasks",
get(directive_documents::list_document_tasks),
)
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index 7ddaf1b..184d12a 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -124,6 +124,10 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
directive_documents::ship_document,
directive_documents::archive_document,
directive_documents::reorder_contract,
+ directive_documents::start_contract,
+ directive_documents::pause_contract,
+ directive_documents::complete_contract,
+ directive_documents::unlock_contract,
directive_documents::list_document_tasks,
// Order endpoints
orders::list_orders,
@@ -233,6 +237,7 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
directive_documents::UpdateDirectiveDocumentRequest,
directive_documents::ShipDirectiveDocumentRequest,
directive_documents::ReorderDirectiveDocumentRequest,
+ directive_documents::CompleteContractRequest,
directive_documents::DocumentTasksResponse,
// Order schemas
Order,