diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 944ab5de..b570254a 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1657,24 +1657,18 @@ fn strip_container_prefix(name: &str) -> String { .to_owned() } -/// Build an RFC 9457 (`application/problem+json`) error response via the +/// Convenience wrapper for the common internal-error case: a 500 +/// RFC 9457 (`application/problem+json`) response via the /// `problem_details` crate. `from_status_code` sets `status` + `title` /// (the canonical reason phrase) and leaves `type` as the default -/// `about:blank`; `with_detail` carries the caller message. The crate's -/// axum `IntoResponse` emits the body with the `application/problem+json` -/// content type. Centralising this keeps every dashboard error on one -/// machine-readable shape the frontend parses (it reads `detail`). -fn problem_response(status: StatusCode, detail: &str) -> Response { - problem_details::ProblemDetails::from_status_code(status) - .with_detail(detail) +/// `about:blank`; `with_detail` carries the caller message; the crate's +/// axum `IntoResponse` emits the `application/problem+json` body the +/// frontend parses (it reads `detail`). Most dashboard handlers funnel +/// their errors through here; handlers with a more specific client +/// failure (bad input, not found) build the same `ProblemDetails` +/// inline with the right status. +fn error_response(message: &str) -> Response { + problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR) + .with_detail(message) .into_response() } - -/// Convenience wrapper for the common internal-error case: a 500 -/// problem-details response (see [`problem_response`]). Most dashboard -/// handlers funnel their errors through here; handlers with a more -/// specific failure (bad input, not found) call [`problem_response`] -/// directly with the right status. -fn error_response(message: &str) -> Response { - problem_response(StatusCode::INTERNAL_SERVER_ERROR, message) -} diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs index 8604483c..b85bfb40 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -16,7 +16,9 @@ use axum::{ use hive_sh4re::Approval; use serde::Deserialize; -use super::{AppState, error_response, problem_response}; +use problem_details::ProblemDetails; + +use super::{AppState, error_response}; use crate::actions; use crate::coordinator::Coordinator; use crate::lifecycle; @@ -210,10 +212,9 @@ pub(super) async fn get_approval_diff( .map(|n| format!("refs/tags/proposal/{n}")) } other => { - return problem_response( - StatusCode::BAD_REQUEST, - &format!("unknown diff base {other:?}"), - ); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("unknown diff base {other:?}")) + .into_response(); } }; let Some(base_ref) = base_ref else { diff --git a/hive-c0re/src/dashboard/journal.rs b/hive-c0re/src/dashboard/journal.rs index 5a661d51..a0debbc3 100644 --- a/hive-c0re/src/dashboard/journal.rs +++ b/hive-c0re/src/dashboard/journal.rs @@ -13,7 +13,9 @@ use axum::{ }; use serde::Deserialize; -use super::{error_response, problem_response, strip_container_prefix, validate_agent_name}; +use problem_details::ProblemDetails; + +use super::{error_response, strip_container_prefix, validate_agent_name}; use crate::lifecycle; #[derive(Deserialize)] @@ -48,10 +50,9 @@ pub(super) async fn get_journal( let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX); let live = lifecycle::list().await.unwrap_or_default(); if !live.iter().any(|c| c == &prefixed) { - return problem_response( - StatusCode::NOT_FOUND, - &format!("journal: no managed container {prefixed:?}"), - ); + return ProblemDetails::from_status_code(StatusCode::NOT_FOUND) + .with_detail(format!("journal: no managed container {prefixed:?}")) + .into_response(); } let lines = q.lines.unwrap_or(500).min(5000); let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) { @@ -64,10 +65,9 @@ pub(super) async fn get_journal( format!("{u}.service") }; if !allowed.contains(&unit.as_str()) { - return problem_response( - StatusCode::BAD_REQUEST, - &format!("journal: unknown unit {unit:?}"), - ); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("journal: unknown unit {unit:?}")) + .into_response(); } Some(unit) } @@ -127,10 +127,9 @@ pub(super) async fn get_journal_host( format!("{u}.service") }; if !allowed.contains(&unit.as_str()) { - return problem_response( - StatusCode::BAD_REQUEST, - &format!("journal-host: unknown unit {unit:?}"), - ); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("journal-host: unknown unit {unit:?}")) + .into_response(); } cmd.args(["-u", &unit]); } diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index ff379188..bb32f6f8 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -12,7 +12,9 @@ use axum::{ }; use serde::{Deserialize, Serialize}; -use super::{AppState, guard_agent_name, problem_response, strip_container_prefix}; +use problem_details::ProblemDetails; + +use super::{AppState, guard_agent_name, strip_container_prefix}; #[derive(Serialize)] pub(super) struct ToolGroupsSnapshot { @@ -120,10 +122,9 @@ pub(super) async fn post_tool_groups( // Validate group names before queuing — fail fast so the operator // sees the error immediately rather than waiting for the worker. if let Err(e) = crate::tool_groups::validate_groups(&body.groups) { - return problem_response( - StatusCode::BAD_REQUEST, - &format!("invalid tool-groups for {logical}: {e}"), - ); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("invalid tool-groups for {logical}: {e}")) + .into_response(); } // Enqueue a PermChange so the JSON file write is serialised through // the FIFO worker. Prevents concurrent batch-apply actions for @@ -207,10 +208,9 @@ pub(super) async fn post_capabilities( .collect(); for cap in &body.caps { if !known.contains(&cap.as_str()) { - return problem_response( - StatusCode::BAD_REQUEST, - &format!("unknown capability: {cap}"), - ); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("unknown capability: {cap}")) + .into_response(); } } // Enqueue a PermChange so the JSON file write is serialised through @@ -278,18 +278,16 @@ pub(super) async fn post_permissions( if let Some(groups) = &change.tool_groups && let Err(e) = crate::tool_groups::validate_groups(groups) { - return problem_response( - StatusCode::BAD_REQUEST, - &format!("invalid tool-groups for {logical}: {e}"), - ); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("invalid tool-groups for {logical}: {e}")) + .into_response(); } if let Some(caps) = &change.capabilities { for cap in caps { if !known_caps.contains(&cap.as_str()) { - return problem_response( - StatusCode::BAD_REQUEST, - &format!("unknown capability for {logical}: {cap}"), - ); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail(format!("unknown capability for {logical}: {cap}")) + .into_response(); } } } diff --git a/hive-c0re/src/dashboard/questions.rs b/hive-c0re/src/dashboard/questions.rs index ef50aff8..b94cdd7e 100644 --- a/hive-c0re/src/dashboard/questions.rs +++ b/hive-c0re/src/dashboard/questions.rs @@ -13,7 +13,9 @@ use axum::{ }; use serde::Deserialize; -use super::{AppState, error_response, problem_response}; +use problem_details::ProblemDetails; + +use super::{AppState, error_response}; #[derive(Deserialize)] pub(super) struct AnswerForm { @@ -41,10 +43,11 @@ pub(super) async fn post_answer_question( ) -> Response { let answer = form.answer.trim(); if answer.is_empty() { - return with_cors(problem_response( - StatusCode::BAD_REQUEST, - "answer: required", - )); + return with_cors( + ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail("answer: required") + .into_response(), + ); } let resp = match state .coord diff --git a/hive-c0re/src/dashboard/reminders.rs b/hive-c0re/src/dashboard/reminders.rs index 75789b0f..9f2abb45 100644 --- a/hive-c0re/src/dashboard/reminders.rs +++ b/hive-c0re/src/dashboard/reminders.rs @@ -10,7 +10,9 @@ use axum::{ response::{IntoResponse, Response}, }; -use super::{AppState, error_response, problem_response}; +use problem_details::ProblemDetails; + +use super::{AppState, error_response}; pub(super) async fn api_reminders(State(state): State) -> Response { match state.coord.broker.list_pending_reminders() { @@ -24,10 +26,9 @@ pub(super) async fn post_cancel_reminder( AxumPath(id): AxumPath, ) -> Response { match state.coord.broker.cancel_reminder(id) { - Ok(0) => problem_response( - StatusCode::NOT_FOUND, - &format!("reminder {id} not pending (already delivered?)"), - ), + Ok(0) => ProblemDetails::from_status_code(StatusCode::NOT_FOUND) + .with_detail(format!("reminder {id} not pending (already delivered?)")) + .into_response(), Ok(_) => { tracing::info!(%id, "operator cancelled reminder"); state.coord.emit_reminders_snapshot(); @@ -47,10 +48,9 @@ pub(super) async fn post_retry_reminder( AxumPath(id): AxumPath, ) -> Response { match state.coord.broker.reset_reminder_failure(id) { - Ok(0) => problem_response( - StatusCode::NOT_FOUND, - &format!("reminder {id} not pending (already delivered?)"), - ), + Ok(0) => ProblemDetails::from_status_code(StatusCode::NOT_FOUND) + .with_detail(format!("reminder {id} not pending (already delivered?)")) + .into_response(), Ok(_) => { tracing::info!(%id, "operator reset reminder failure for retry"); state.coord.emit_reminders_snapshot(); diff --git a/hive-c0re/src/dashboard/schedules.rs b/hive-c0re/src/dashboard/schedules.rs index ccabe878..e5bf1ece 100644 --- a/hive-c0re/src/dashboard/schedules.rs +++ b/hive-c0re/src/dashboard/schedules.rs @@ -11,7 +11,9 @@ use axum::{ response::{IntoResponse, Response}, }; -use super::{AppState, error_response, problem_response}; +use problem_details::ProblemDetails; + +use super::{AppState, error_response}; /// `GET /api/schedules` — snapshot of every schedule for the /// scheduled-prompts tab. Returns the wire shape directly @@ -51,19 +53,19 @@ pub(super) async fn post_schedule_new( axum::Json(payload): axum::Json, ) -> Response { if payload.targets.is_empty() { - return problem_response( - StatusCode::BAD_REQUEST, - "schedule must have at least one target", - ); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail("schedule must have at least one target") + .into_response(); } if payload.body.trim().is_empty() { - return problem_response(StatusCode::BAD_REQUEST, "schedule body must be non-empty"); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail("schedule body must be non-empty") + .into_response(); } if let Some(0) = payload.interval_seconds { - return problem_response( - StatusCode::BAD_REQUEST, - "interval_seconds must be > 0 (use None for one-shot)", - ); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail("interval_seconds must be > 0 (use None for one-shot)") + .into_response(); } let new = crate::scheduled_prompts::NewSchedule { owner: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), diff --git a/hive-c0re/src/dashboard/topology.rs b/hive-c0re/src/dashboard/topology.rs index 5751d9a0..c768830a 100644 --- a/hive-c0re/src/dashboard/topology.rs +++ b/hive-c0re/src/dashboard/topology.rs @@ -13,7 +13,9 @@ use axum::{ }; use serde::Deserialize; -use super::{AppState, error_response, problem_response}; +use problem_details::ProblemDetails; + +use super::{AppState, error_response}; /// `POST /api/topology/set-parent` body. `child` is required. /// `new_parent` may be: @@ -53,7 +55,9 @@ pub(super) async fn post_set_parent( ) -> Response { let child = form.child.trim().to_owned(); if child.is_empty() { - return problem_response(StatusCode::BAD_REQUEST, "set-parent: `child` required"); + return ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) + .with_detail("set-parent: `child` required") + .into_response(); } // Empty / whitespace-only `new_parent` ⇒ promote to root. Web // forms submit the empty string for a "no value" radio button,