//! HTTP handlers for directive CRUD and DAG progression.
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use uuid::Uuid;
use crate::db::models::{
CleanupResponse, CleanupTasksResponse, CreateDirectiveRequest, CreateTaskRequest,
CreateDirectiveStepRequest, Directive, DirectiveListResponse,
DirectiveStep, DirectiveWithSteps, PickUpOrdersResponse,
UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest,
UpdateOrderRequest,
CreateDirectiveOrderGroupRequest, DirectiveOrderGroup,
DirectiveOrderGroupListResponse, UpdateDirectiveOrderGroupRequest,
OrderListResponse,
};
use crate::db::repository;
use crate::orchestration::directive::{build_cleanup_prompt, build_order_pickup_prompt};
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::SharedState;
// =============================================================================
// Directive CRUD
// =============================================================================
/// List all directives for the authenticated user.
#[utoipa::path(
get,
path = "/api/v1/directives",
responses(
(status = 200, description = "List of directives", body = DirectiveListResponse),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn list_directives(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match repository::list_directives_for_owner(pool, auth.owner_id).await {
Ok(directives) => {
let total = directives.len() as i64;
Json(DirectiveListResponse { directives, total }).into_response()
}
Err(e) => {
tracing::error!("Failed to list directives: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("LIST_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Create a new directive.
#[utoipa::path(
post,
path = "/api/v1/directives",
request_body = CreateDirectiveRequest,
responses(
(status = 201, description = "Directive created", body = Directive),
(status = 401, description = "Unauthorized", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn create_directive(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Json(req): Json<CreateDirectiveRequest>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match repository::create_directive_for_owner(pool, auth.owner_id, req).await {
Ok(directive) => (StatusCode::CREATED, Json(directive)).into_response(),
Err(e) => {
tracing::error!("Failed to create directive: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("CREATE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Get a directive with all its steps.
#[utoipa::path(
get,
path = "/api/v1/directives/{id}",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 200, description = "Directive with steps", body = DirectiveWithSteps),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn get_directive(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await {
Ok(Some((directive, steps))) => {
Json(DirectiveWithSteps { directive, steps }).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to get directive: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Update a directive.
#[utoipa::path(
put,
path = "/api/v1/directives/{id}",
params(("id" = Uuid, Path, description = "Directive ID")),
request_body = UpdateDirectiveRequest,
responses(
(status = 200, description = "Directive updated", body = Directive),
(status = 404, description = "Not found", body = ApiError),
(status = 409, description = "Version conflict", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn update_directive(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<UpdateDirectiveRequest>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match repository::update_directive_for_owner(pool, auth.owner_id, id, req).await {
Ok(Some(directive)) => Json(directive).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response(),
Err(repository::RepositoryError::VersionConflict { expected, actual }) => (
StatusCode::CONFLICT,
Json(ApiError::new(
"VERSION_CONFLICT",
&format!("Expected version {}, but current is {}", expected, actual),
)),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to update directive: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("UPDATE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Delete a directive.
#[utoipa::path(
delete,
path = "/api/v1/directives/{id}",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 204, description = "Deleted"),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn delete_directive(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match repository::delete_directive_for_owner(pool, auth.owner_id, id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to delete directive: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DELETE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
// =============================================================================
// Step CRUD
// =============================================================================
/// Create a step in a directive.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/steps",
params(("id" = Uuid, Path, description = "Directive ID")),
request_body = CreateDirectiveStepRequest,
responses(
(status = 201, description = "Step created", body = DirectiveStep),
(status = 404, description = "Directive not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn create_step(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<CreateDirectiveStepRequest>,
) -> 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 directive ownership
match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response();
}
}
match repository::create_directive_step(pool, id, req).await {
Ok(step) => (StatusCode::CREATED, Json(step)).into_response(),
Err(e) => {
tracing::error!("Failed to create step: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("CREATE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Batch create steps in a directive.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/steps/batch",
params(("id" = Uuid, Path, description = "Directive ID")),
request_body = Vec<CreateDirectiveStepRequest>,
responses(
(status = 201, description = "Steps created", body = Vec<DirectiveStep>),
(status = 404, description = "Directive not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn batch_create_steps(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(steps): Json<Vec<CreateDirectiveStepRequest>>,
) -> 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 directive ownership
match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response();
}
}
match repository::batch_create_directive_steps(pool, id, steps).await {
Ok(created) => (StatusCode::CREATED, Json(created)).into_response(),
Err(e) => {
tracing::error!("Failed to batch create steps: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("CREATE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Update a step.
#[utoipa::path(
put,
path = "/api/v1/directives/{id}/steps/{step_id}",
params(
("id" = Uuid, Path, description = "Directive ID"),
("step_id" = Uuid, Path, description = "Step ID"),
),
request_body = UpdateDirectiveStepRequest,
responses(
(status = 200, description = "Step updated", body = DirectiveStep),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn update_step(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((id, step_id)): Path<(Uuid, Uuid)>,
Json(req): Json<UpdateDirectiveStepRequest>,
) -> 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 directive ownership
match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response();
}
}
match repository::update_directive_step(pool, step_id, req).await {
Ok(Some(step)) => Json(step).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Step not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to update step: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("UPDATE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Delete a step.
#[utoipa::path(
delete,
path = "/api/v1/directives/{id}/steps/{step_id}",
params(
("id" = Uuid, Path, description = "Directive ID"),
("step_id" = Uuid, Path, description = "Step ID"),
),
responses(
(status = 204, description = "Deleted"),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn delete_step(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((id, step_id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// Verify directive ownership
match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response();
}
}
match repository::delete_directive_step(pool, step_id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Step not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to delete step: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DELETE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
// =============================================================================
// Directive Lifecycle Actions
// =============================================================================
/// Start a directive: sets status=active, advances ready steps.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/start",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 200, description = "Directive started", body = DirectiveWithSteps),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn start_directive(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// Set to active
match repository::set_directive_status(pool, auth.owner_id, id, "active").await {
Ok(Some(directive)) => {
// Advance ready steps
let _ = repository::advance_directive_ready_steps(pool, id).await;
let steps = repository::list_directive_steps(pool, id).await.unwrap_or_default();
Json(DirectiveWithSteps { directive, steps }).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to start directive: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("START_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Pause a directive.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/pause",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 200, description = "Directive paused", body = Directive),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn pause_directive(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
match repository::set_directive_status(pool, auth.owner_id, id, "paused").await {
Ok(Some(directive)) => Json(directive).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("PAUSE_FAILED", &e.to_string())),
)
.into_response(),
}
}
/// Advance a directive: find newly-ready steps. If all steps done, set idle.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/advance",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 200, description = "Advance result", body = DirectiveWithSteps),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn advance_directive(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// Verify ownership
let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
Ok(Some(d)) => d,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response();
}
};
// Advance ready steps
let _ = repository::advance_directive_ready_steps(pool, id).await;
// Check if idle
let _ = repository::check_directive_idle(pool, id).await;
// Return updated state
let directive = match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
Ok(Some(d)) => d,
_ => directive,
};
let steps = repository::list_directive_steps(pool, id).await.unwrap_or_default();
Json(DirectiveWithSteps { directive, steps }).into_response()
}
/// Mark a step as completed.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/steps/{step_id}/complete",
params(
("id" = Uuid, Path, description = "Directive ID"),
("step_id" = Uuid, Path, description = "Step ID"),
),
responses(
(status = 200, description = "Step completed", body = DirectiveStep),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn complete_step(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((id, step_id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse {
step_status_change(state, auth, id, step_id, "completed").await
}
/// Mark a step as failed.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/steps/{step_id}/fail",
params(
("id" = Uuid, Path, description = "Directive ID"),
("step_id" = Uuid, Path, description = "Step ID"),
),
responses(
(status = 200, description = "Step failed", body = DirectiveStep),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn fail_step(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((id, step_id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse {
step_status_change(state, auth, id, step_id, "failed").await
}
/// Mark a step as skipped.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/steps/{step_id}/skip",
params(
("id" = Uuid, Path, description = "Directive ID"),
("step_id" = Uuid, Path, description = "Step ID"),
),
responses(
(status = 200, description = "Step skipped", body = DirectiveStep),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn skip_step(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((id, step_id)): Path<(Uuid, Uuid)>,
) -> impl IntoResponse {
step_status_change(state, auth, id, step_id, "skipped").await
}
/// Helper for step status changes.
async fn step_status_change(
state: SharedState,
auth: crate::server::auth::AuthenticatedUser,
directive_id: Uuid,
step_id: Uuid,
new_status: &str,
) -> 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 directive ownership
match repository::get_directive_for_owner(pool, auth.owner_id, directive_id).await {
Ok(Some(_)) => {}
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response();
}
}
let req = UpdateDirectiveStepRequest {
status: Some(new_status.to_string()),
..Default::default()
};
match repository::update_directive_step(pool, step_id, req).await {
Ok(Some(step)) => {
// After step status change, advance the DAG
let _ = repository::advance_directive_ready_steps(pool, directive_id).await;
let _ = repository::check_directive_idle(pool, directive_id).await;
Json(step).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Step not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to update step status: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("UPDATE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Update a directive's goal (triggers re-planning).
#[utoipa::path(
put,
path = "/api/v1/directives/{id}/goal",
params(("id" = Uuid, Path, description = "Directive ID")),
request_body = UpdateGoalRequest,
responses(
(status = 200, description = "Goal updated", body = Directive),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn update_goal(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<UpdateGoalRequest>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// Save old goal to history before overwriting (best-effort)
match repository::get_directive_for_owner(pool, auth.owner_id, id).await {
Ok(Some(current)) => {
if let Err(e) = repository::save_directive_goal_history(pool, id, ¤t.goal).await
{
tracing::warn!(
directive_id = %id,
error = %e,
"Failed to save goal history before update — continuing with goal update"
);
}
}
Ok(None) => {
// Directive not found — update_directive_goal will handle this
}
Err(e) => {
tracing::warn!(
directive_id = %id,
error = %e,
"Failed to fetch current directive for goal history — continuing with goal update"
);
}
}
match repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await {
Ok(Some(directive)) => Json(directive).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to update goal: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("UPDATE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
// =============================================================================
// Task Cleanup
// =============================================================================
/// Clean up merged steps for an idle directive by spawning a verification task.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/cleanup",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 200, description = "Cleanup task spawned", body = CleanupResponse),
(status = 404, description = "Not found", body = ApiError),
(status = 409, description = "Directive is not idle", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn cleanup_directive(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
)
.into_response();
};
// Get the directive with steps and verify ownership
let (directive, _steps) =
match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await {
Ok(Some(ds)) => ds,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response();
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response();
}
};
// Verify directive is idle
if directive.status != "idle" {
return (
StatusCode::CONFLICT,
Json(ApiError::new(
"NOT_IDLE",
"Directive must be idle to run cleanup",
)),
)
.into_response();
}
// Auto-remove completed steps that were already included in a merged PR
if directive.pr_url.is_some() || directive.pr_branch.is_some() {
match crate::orchestration::directive::remove_already_merged_steps(pool, id).await {
Ok(count) if count > 0 => {
tracing::info!("Auto-removed {} completed steps already in PR for directive {} during cleanup", count, id);
}
Err(e) => {
tracing::warn!("Failed to auto-remove merged steps during cleanup for directive {}: {}", id, e);
}
_ => {}
}
}
// Get completed step tasks for branch name computation
let step_tasks = match repository::get_completed_step_tasks(pool, id).await {
Ok(tasks) => tasks,
Err(e) => {
tracing::error!("Failed to get completed step tasks: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_STEPS_FAILED", &e.to_string())),
)
.into_response();
}
};
if step_tasks.is_empty() {
return Json(CleanupResponse {
message: "No completed steps to clean up".to_string(),
task_id: None,
})
.into_response();
}
let pr_branch = match &directive.pr_branch {
Some(b) => b.clone(),
None => {
return Json(CleanupResponse {
message: "No PR branch set — nothing to verify against".to_string(),
task_id: None,
})
.into_response();
}
};
let base_branch = directive.base_branch.as_deref().unwrap_or("main");
// Build the cleanup prompt
let prompt = build_cleanup_prompt(&directive, &step_tasks, &pr_branch, base_branch);
// Create the cleanup task (following pick_up_orders pattern)
let req = CreateTaskRequest {
contract_id: None,
name: format!("Cleanup: {}", directive.title),
description: Some("Directive cleanup — verify merged branches and remove merged steps".to_string()),
plan: prompt,
parent_task_id: None,
is_supervisor: false,
priority: 0,
repository_url: directive.repository_url.clone(),
base_branch: directive.base_branch.clone(),
target_branch: None,
merge_mode: None,
target_repo_path: None,
completion_action: None,
continue_from_task_id: None,
copy_files: None,
checkpoint_sha: None,
branched_from_task_id: None,
conversation_history: None,
supervisor_worktree_task_id: None,
directive_id: Some(directive.id),
directive_step_id: None,
};
let task = match repository::create_task_for_owner(pool, auth.owner_id, req).await {
Ok(t) => t,
Err(e) => {
tracing::error!("Failed to create cleanup task: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("CREATE_TASK_FAILED", &e.to_string())),
)
.into_response();
}
};
// Assign as orchestrator task
if let Err(e) = repository::assign_orchestrator_task(pool, id, task.id).await {
tracing::error!("Failed to assign orchestrator task: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("ASSIGN_TASK_FAILED", &e.to_string())),
)
.into_response();
}
// Cancel old planning tasks
let cancelled = repository::cancel_old_planning_tasks(pool, id, task.id).await;
if let Ok(count) = cancelled {
if count > 0 {
tracing::info!(
directive_id = %id,
cancelled_count = count,
"Cancelled old planning tasks superseded by cleanup"
);
}
}
// Set directive to active
if let Err(e) = repository::set_directive_status(pool, auth.owner_id, id, "active").await {
tracing::warn!("Failed to set directive status to active: {}", e);
}
// Advance ready steps
let _ = repository::advance_directive_ready_steps(pool, id).await;
Json(CleanupResponse {
message: format!("Cleanup task spawned for {} completed steps", step_tasks.len()),
task_id: Some(task.id),
})
.into_response()
}
// =============================================================================
// PR Creation
// =============================================================================
/// Trigger PR creation or update for a directive.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/create-pr",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 200, description = "PR task spawned", body = DirectiveWithSteps),
(status = 404, description = "Not found", body = ApiError),
(status = 409, description = "Completion task already running", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn create_pr(
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 crate::orchestration::directive::trigger_completion_task(
pool,
&state,
id,
auth.owner_id,
)
.await
{
Ok(_task_id) => {
// Return the updated directive with steps
match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await {
Ok(Some((directive, steps))) => {
Json(DirectiveWithSteps { directive, steps }).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response(),
}
}
Err(e) => {
let msg = e.to_string();
if msg.contains("not found") {
(
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", &msg)),
)
.into_response()
} else if msg.contains("already running") || msg.contains("already claimed") {
(
StatusCode::CONFLICT,
Json(ApiError::new("COMPLETION_IN_PROGRESS", &msg)),
)
.into_response()
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("CREATE_PR_FAILED", &msg)),
)
.into_response()
}
}
}
}
// =============================================================================
// Order Pickup
// =============================================================================
/// Pick up available open orders for a directive by spawning a planning task.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/pick-up-orders",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 200, description = "Orders picked up", body = PickUpOrdersResponse),
(status = 404, description = "Not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
),
security(("bearer_auth" = []), ("api_key" = [])),
tag = "Directives"
)]
pub async fn pick_up_orders(
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 directive ownership and get directive with steps
let (directive, mut steps) =
match repository::get_directive_with_steps_for_owner(pool, auth.owner_id, id).await {
Ok(Some((d, s))) => (d, s),
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Directive not found")),
)
.into_response();
}
Err(e) => {
tracing::error!("Failed to get directive: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response();
}
};
// Auto-remove completed steps that were already included in a PR
if directive.pr_url.is_some() || directive.pr_branch.is_some() {
match crate::orchestration::directive::remove_already_merged_steps(pool, id).await {
Ok(count) if count > 0 => {
tracing::info!("Auto-removed {} completed steps already in PR for directive {}", count, id);
// Re-fetch steps since some were removed
steps = match repository::list_directive_steps(pool, id).await {
Ok(s) => s,
Err(e) => {
tracing::error!("Failed to re-fetch steps after cleanup: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("REFETCH_STEPS_FAILED", &e.to_string())),
).into_response();
}
};
}
Err(e) => {
tracing::warn!("Failed to auto-remove merged steps for directive {}: {}", id, e);
}
_ => {}
}
}
// Reconcile existing orders: mark done if step completed, under_review if step in progress
match repository::reconcile_directive_orders(pool, auth.owner_id, id).await {
Ok(count) => {
if count > 0 {
tracing::info!("Reconciled {} orders for directive {}", count, id);
}
}
Err(e) => {
tracing::warn!("Failed to reconcile directive orders: {}", e);
// Non-fatal: continue with pickup even if reconciliation fails
}
}
// Fetch available orders
let orders = match repository::get_available_orders_for_pickup(pool, auth.owner_id, id).await {
Ok(o) => o,
Err(e) => {
tracing::error!("Failed to fetch available orders: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("FETCH_ORDERS_FAILED", &e.to_string())),
)
.into_response();
}
};
// If no orders available, return early
if orders.is_empty() {
return Json(PickUpOrdersResponse {
message: "No orders available to plan".to_string(),
order_count: 0,
task_id: None,
})
.into_response();
}
let order_count = orders.len() as i64;
let order_ids: Vec<Uuid> = orders.iter().map(|o| o.id).collect();
// Get generation and goal history for the planning prompt
let generation =
match repository::get_directive_max_generation(pool, id).await {
Ok(g) => g + 1,
Err(e) => {
tracing::error!("Failed to get max generation: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GENERATION_FAILED", &e.to_string())),
)
.into_response();
}
};
let goal_history = match repository::get_directive_goal_history(pool, id, 3).await {
Ok(h) => h,
Err(e) => {
tracing::warn!("Failed to get goal history: {}", e);
vec![]
}
};
// Build the specialized planning prompt
let plan = build_order_pickup_prompt(&directive, &steps, &orders, generation, &goal_history);
// Link orders to the directive
if let Err(e) =
repository::bulk_link_orders_to_directive(pool, auth.owner_id, &order_ids, id).await
{
tracing::error!("Failed to link orders to directive: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("LINK_ORDERS_FAILED", &e.to_string())),
)
.into_response();
}
// Mark picked-up orders as in_progress
if let Err(e) =
repository::bulk_update_order_status(pool, auth.owner_id, &order_ids, "in_progress").await
{
tracing::warn!("Failed to update order status to in_progress: {}", e);
}
// Create the planning task
let req = CreateTaskRequest {
contract_id: None,
name: format!("Pick up orders: {}", directive.title),
description: Some("Directive order pickup planning task".to_string()),
plan,
parent_task_id: None,
is_supervisor: false,
priority: 0,
repository_url: directive.repository_url.clone(),
base_branch: directive.base_branch.clone(),
target_branch: None,
merge_mode: None,
target_repo_path: None,
completion_action: None,
continue_from_task_id: None,
copy_files: None,
checkpoint_sha: None,
branched_from_task_id: None,
conversation_history: None,
supervisor_worktree_task_id: None,
directive_id: Some(directive.id),
directive_step_id: None,
};
let task = match repository::create_task_for_owner(pool, auth.owner_id, req).await {
Ok(t) => t,
Err(e) => {
tracing::error!("Failed to create pickup planning task: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("CREATE_TASK_FAILED", &e.to_string())),
)
.into_response();
}
};
// Assign as orchestrator task
if let Err(e) = repository::assign_orchestrator_task(pool, id, task.id).await {
tracing::error!("Failed to assign orchestrator task: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("ASSIGN_TASK_FAILED", &e.to_string())),
)
.into_response();
}
// Cancel old planning tasks
let cancelled = repository::cancel_old_planning_tasks(pool, id, task.id).await;
if let Ok(count) = cancelled {
if count > 0 {
tracing::info!(
directive_id = %id,
cancelled_count = count,
"Cancelled old planning tasks superseded by order pickup"
);
}
}
// Set directive to active if draft/idle/paused
match directive.status.as_str() {
"draft" | "idle" | "paused" => {
if let Err(e) = repository::set_directive_status(pool, auth.owner_id, id, "active").await
{
tracing::warn!("Failed to set directive status to active: {}", e);
}
}
_ => {}
}
// Advance ready steps
let _ = repository::advance_directive_ready_steps(pool, id).await;
Json(PickUpOrdersResponse {
message: format!("Planning {} orders", order_count),
order_count,
task_id: Some(task.id),
})
.into_response()
}