//! HTTP handlers for directive CRUD and DAG progression.
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::Deserialize;
use uuid::Uuid;
use crate::db::models::{
BatchSetDirectiveMemoryRequest, CreateDirectiveRequest, CreateDirectiveStepRequest,
Directive, DirectiveListResponse, DirectiveMemory, DirectiveMemoryListResponse,
DirectiveStep, DirectiveWithSteps, SetDirectiveMemoryRequest, UpdateDirectiveRequest,
UpdateDirectiveStepRequest, UpdateGoalRequest,
};
use crate::db::repository;
use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::SharedState;
/// Query parameters for the memory list endpoint.
#[derive(Debug, Deserialize)]
pub struct MemoryListQuery {
pub category: Option<String>,
}
// =============================================================================
// 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)) => 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()
}
}
}
// =============================================================================
// Directive Memory CRUD
// =============================================================================
/// List all memories for a directive, optionally filtered by category.
#[utoipa::path(
get,
path = "/api/v1/directives/{id}/memories",
params(
("id" = Uuid, Path, description = "Directive ID"),
("category" = Option<String>, Query, description = "Filter by category"),
),
responses(
(status = 200, description = "List of memories", body = DirectiveMemoryListResponse),
(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 list_memories(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Query(query): Query<MemoryListQuery>,
) -> 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::list_directive_memories(pool, id, query.category.as_deref()).await {
Ok(memories) => {
let total = memories.len() as i64;
Json(DirectiveMemoryListResponse { memories, total }).into_response()
}
Err(e) => {
tracing::error!("Failed to list memories: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("LIST_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Get a single memory entry by key.
#[utoipa::path(
get,
path = "/api/v1/directives/{id}/memories/{key}",
params(
("id" = Uuid, Path, description = "Directive ID"),
("key" = String, Path, description = "Memory key"),
),
responses(
(status = 200, description = "Memory entry", body = DirectiveMemory),
(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_memory(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((id, key)): Path<(Uuid, String)>,
) -> 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::get_directive_memory(pool, id, &key).await {
Ok(Some(memory)) => Json(memory).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Memory entry not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to get memory: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("GET_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Set (upsert) a single memory entry.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/memories",
params(("id" = Uuid, Path, description = "Directive ID")),
request_body = SetDirectiveMemoryRequest,
responses(
(status = 200, description = "Memory entry set", body = DirectiveMemory),
(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 set_memory(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<SetDirectiveMemoryRequest>,
) -> 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::set_directive_memory(pool, id, &req).await {
Ok(memory) => Json(memory).into_response(),
Err(e) => {
tracing::error!("Failed to set memory: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("SET_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Batch set multiple memory entries.
#[utoipa::path(
post,
path = "/api/v1/directives/{id}/memories/batch",
params(("id" = Uuid, Path, description = "Directive ID")),
request_body = BatchSetDirectiveMemoryRequest,
responses(
(status = 200, description = "Memory entries set", body = Vec<DirectiveMemory>),
(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_set_memories(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<BatchSetDirectiveMemoryRequest>,
) -> 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_set_directive_memories(pool, id, &req.memories).await {
Ok(memories) => Json(memories).into_response(),
Err(e) => {
tracing::error!("Failed to batch set memories: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("SET_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Delete a single memory entry by key.
#[utoipa::path(
delete,
path = "/api/v1/directives/{id}/memories/{key}",
params(
("id" = Uuid, Path, description = "Directive ID"),
("key" = String, Path, description = "Memory key"),
),
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_memory(
State(state): State<SharedState>,
Authenticated(auth): Authenticated,
Path((id, key)): Path<(Uuid, String)>,
) -> 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_memory(pool, id, &key).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
Json(ApiError::new("NOT_FOUND", "Memory entry not found")),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to delete memory: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("DELETE_FAILED", &e.to_string())),
)
.into_response()
}
}
}
/// Clear all memories for a directive.
#[utoipa::path(
delete,
path = "/api/v1/directives/{id}/memories",
params(("id" = Uuid, Path, description = "Directive ID")),
responses(
(status = 204, description = "All memories cleared"),
(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 clear_memories(
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::clear_directive_memories(pool, id).await {
Ok(_) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
tracing::error!("Failed to clear memories: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError::new("CLEAR_FAILED", &e.to_string())),
)
.into_response()
}
}
}