//! 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::{
CleanupTasksResponse, CreateDirectiveRequest,
CreateDirectiveStepRequest, Directive, DirectiveListResponse,
DirectiveStep, DirectiveWithSteps,
UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateGoalRequest,
};
use crate::db::repository;
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();
};
match repository::update_directive_goal(pool, auth.owner_id, id, &req.goal).await {
Ok(Some(directive)) => {
// Clear non-started steps so replanning starts fresh
match repository::clear_pending_directive_steps(pool, id).await {
Ok(count) => {
if count > 0 {
tracing::info!(
directive_id = %id,
removed_steps = count,
"Cleared pending steps after goal update — replanning will generate new steps"
);
}
}
Err(e) => {
tracing::warn!(
directive_id = %id,
error = %e,
"Failed to clear pending steps after goal update"
);
}
}
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 terminal tasks associated with a directive.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/cleanup-tasks",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 200, description = "Tasks cleaned up", body = CleanupTasksResponse),
(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 cleanup_tasks(
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
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::cleanup_directive_tasks(pool, auth.owner_id, id).await {
Ok(deleted) => Json(CleanupTasksResponse { deleted }).into_response(),
Err(e) => {
tracing::error!("Failed to cleanup directive tasks: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("CLEANUP_FAILED", &e.to_string())),
)
.into_response()
}
}
}