diff --git a/hive-c0re/src/dashboard/health.rs b/hive-c0re/src/dashboard/health.rs index f1cc19a8..6f5837fc 100644 --- a/hive-c0re/src/dashboard/health.rs +++ b/hive-c0re/src/dashboard/health.rs @@ -28,19 +28,20 @@ use utoipa::ToSchema; use crate::host_stats::ServerWarning; +#[derive(Serialize, ToSchema)] +struct LiveBody { + status: &'static str, +} + /// Liveness. Always `200`; no further checks. #[utoipa::path( get, path = "/health/live", - responses((status = 200, description = "process is up", body = serde_json::Value)), + responses((status = 200, description = "process is up", body = LiveBody)), tag = "health" )] pub(super) async fn get_health_live() -> Response { - ( - StatusCode::OK, - axum::Json(serde_json::json!({ "status": "ok" })), - ) - .into_response() + (StatusCode::OK, axum::Json(LiveBody { status: "ok" })).into_response() } #[derive(Serialize, ToSchema)] diff --git a/hive-c0re/src/dashboard/misc_api.rs b/hive-c0re/src/dashboard/misc_api.rs index c335cae4..51695b9a 100644 --- a/hive-c0re/src/dashboard/misc_api.rs +++ b/hive-c0re/src/dashboard/misc_api.rs @@ -8,26 +8,41 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use super::{AppState, Ident, error_response, scan_validated_paths}; +use crate::audit_log::AuditEntry; use crate::container_stats::ContainerResource; use crate::hive_stats::HiveStats; +#[derive(Serialize, ToSchema)] +pub(super) struct OperatorInboxItem { + id: i64, + from: String, + body: String, + at: chrono::DateTime, + in_reply_to: Option, + file_refs: Vec, +} + +#[derive(Serialize, ToSchema)] +pub(super) struct OperatorInboxBody { + messages: Vec, +} + /// Unread operator-directed messages for the dashboard's Y3R C4LL inbox. /// /// Returns messages addressed to `"operator"` that haven't been /// acked yet (the operator clears them via the existing /// `POST /api/agent/operator/mark-all-read`). Newest-first; path-shaped /// tokens are validated so the client renders file links like the -/// terminal does. Shape: `{ "messages": [{ id, from, body, at, -/// in_reply_to, file_refs }] }`. +/// terminal does. #[utoipa::path( get, path = "/api/operator-inbox", responses( - (status = 200, description = "unread operator-directed messages", body = serde_json::Value), + (status = 200, description = "unread operator-directed messages", body = OperatorInboxBody), (status = 500, description = "broker read failed"), ), tag = "misc_api" @@ -40,7 +55,7 @@ pub(super) async fn api_operator_inbox(State(state): State) -> Respons .unread_for_recipient("operator", INBOX_LIMIT) { Ok(messages) => { - let items: Vec = messages + let messages: Vec = messages .into_iter() .filter_map(|m| { let crate::broker::MessageEvent::Sent { @@ -55,17 +70,17 @@ pub(super) async fn api_operator_inbox(State(state): State) -> Respons return None; }; let file_refs = scan_validated_paths(&body); - Some(serde_json::json!({ - "id": id, - "from": from, - "body": body, - "at": hive_sh4re::wire_time::from_secs(at), - "in_reply_to": in_reply_to, - "file_refs": file_refs, - })) + Some(OperatorInboxItem { + id, + from, + at: hive_sh4re::wire_time::from_secs(at), + body, + in_reply_to, + file_refs, + }) }) .collect(); - axum::Json(serde_json::json!({ "messages": items })).into_response() + axum::Json(OperatorInboxBody { messages }).into_response() } Err(e) => error_response(&format!("operator-inbox failed: {e:#}")), } @@ -114,18 +129,23 @@ pub(super) async fn api_container_resources() -> Response { axum::Json(crate::container_stats::gather().await).into_response() } +#[derive(Serialize, ToSchema)] +pub(super) struct AuditLogBody { + entries: Vec, + total: i64, +} + /// Most-recent agent-initiated privileged-action /// audit entries, newest first (server-clamped to 500). /// -/// Backs the operator dashboard's audit view. Returns -/// `{ "entries": [AuditEntry…], "total": N }` so the UI can show +/// Backs the operator dashboard's audit view. `total` lets the UI show /// "latest 500 of N" rather than silently capping. `ts_unix` is in /// **seconds**. #[utoipa::path( get, path = "/api/audit-log", responses( - (status = 200, description = "recent audit entries + total count", body = serde_json::Value), + (status = 200, description = "recent audit entries + total count", body = AuditLogBody), (status = 500, description = "sqlite read failed"), ), tag = "misc_api" @@ -140,7 +160,12 @@ pub(super) async fn api_audit_log(State(state): State) -> Response { Ok(n) => n, Err(e) => return error_response(&format!("audit-log count: {e:#}")), }; - axum::Json(serde_json::json!({ "entries": entries, "total": total })).into_response() + axum::Json(AuditLogBody { entries, total }).into_response() +} + +#[derive(Serialize, ToSchema)] +pub(super) struct MarkAllReadBody { + marked: u64, } /// Operator-driven "clear this agent's inbox" — backs the side-panel @@ -148,14 +173,14 @@ pub(super) async fn api_audit_log(State(state): State) -> Response { /// /// Marks every message addressed to the agent as acked (backfilling /// `delivered_at` for any still-pending rows so vacuum can collect -/// them). Returns `{ "marked": N }` so the frontend can show "cleared -/// N messages" feedback without an extra fetch. +/// them). `marked` lets the frontend show "cleared N messages" +/// feedback without an extra fetch. #[utoipa::path( post, path = "/api/agent/{name}/mark-all-read", params(("name" = String, Path, description = "agent name")), responses( - (status = 200, description = "count of messages marked read", body = serde_json::Value), + (status = 200, description = "count of messages marked read", body = MarkAllReadBody), (status = 400, description = "bad agent name"), (status = 500, description = "broker write failed"), ), @@ -172,9 +197,9 @@ pub(super) async fn post_mark_all_read( } }; match state.coord.broker.mark_all_read(name.as_str()) { - Ok(n) => { - tracing::info!(%name, marked = n, "operator marked all messages read"); - axum::Json(serde_json::json!({ "marked": n })).into_response() + Ok(marked) => { + tracing::info!(%name, marked, "operator marked all messages read"); + axum::Json(MarkAllReadBody { marked }).into_response() } Err(e) => error_response(&format!("mark-all-read {name} failed: {e:#}")), } diff --git a/hive-c0re/src/dashboard/schedules.rs b/hive-c0re/src/dashboard/schedules.rs index 6ea9ea30..79322a72 100644 --- a/hive-c0re/src/dashboard/schedules.rs +++ b/hive-c0re/src/dashboard/schedules.rs @@ -18,6 +18,16 @@ use crate::scheduled_prompts_worker::FireNowReport; use super::{AppState, error_problem, error_response}; +#[derive(serde::Serialize, utoipa::ToSchema)] +pub(super) struct NewScheduleBody { + id: i64, +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +pub(super) struct CancelResultBody { + cancelled: bool, +} + /// Snapshot of every schedule for the /// scheduled-prompts tab. /// @@ -74,7 +84,7 @@ pub(super) async fn api_schedules(State(state): State) -> Response { // `api_schedules` above. request_body(content = serde_json::Value, description = "SchedulePromptPayload wire shape"), responses( - (status = 200, description = "created; body carries the new row id", body = serde_json::Value), + (status = 200, description = "created; body carries the new row id", body = NewScheduleBody), (status = 400, description = "no targets, empty body, or interval_seconds == 0"), (status = 500, description = "submit failed"), ), @@ -108,7 +118,7 @@ pub(super) async fn post_schedule_new( match state.coord.scheduled_prompts.submit(&new) { Ok(id) => { state.coord.emit_schedules_snapshot(); - Ok(axum::Json(serde_json::json!({"id": id})).into_response()) + Ok(axum::Json(NewScheduleBody { id }).into_response()) } Err(e) => Err(error_problem(&format!("schedule submit: {e:#}"))), } @@ -177,7 +187,7 @@ pub(super) async fn post_schedule_fire_now( post, path = "/api/rebuild-queue/{id}/cancel", params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")), - responses((status = 200, description = "whether the DAG was cancelled", body = serde_json::Value)), + responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)), tag = "schedules" )] pub(super) async fn post_rebuild_queue_cancel( @@ -188,9 +198,9 @@ pub(super) async fn post_rebuild_queue_cancel( // Any terminal side effect is the DAG's own spared tail node, which the // scheduler picks up on its next pass — nothing to fire from here. state.coord.emit_rebuild_queue_snapshot(); - axum::Json(serde_json::json!({"cancelled": true})).into_response() + axum::Json(CancelResultBody { cancelled: true }).into_response() } else { - axum::Json(serde_json::json!({"cancelled": false})).into_response() + axum::Json(CancelResultBody { cancelled: false }).into_response() } } diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index d3d4bc08..56b74cc5 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -726,6 +726,18 @@ pub(super) async fn jobq_rollup( axum::Json(state.coord.job_queue.state_rollup()) } +/// Response body for `/api/dashboard/history`. No `ToSchema` — its +/// `events` field wraps [`crate::dashboard_events::DashboardEvent`], +/// which doesn't derive `ToSchema` either (a large enum with many +/// variants; see that type's doc comment for why annotating it is +/// out of scope here). The `responses(...)` doc below spells out the +/// shape in prose instead of a `body = ...` reference. +#[derive(Serialize)] +struct DashboardHistoryBody { + seq: u64, + events: Vec, +} + #[utoipa::path( get, path = "/api/dashboard/history", @@ -798,7 +810,7 @@ pub(super) async fn dashboard_history(State(state): State) -> Response } }) .collect(); - axum::Json(serde_json::json!({ "seq": seq, "events": events })).into_response() + axum::Json(DashboardHistoryBody { seq, events }).into_response() } Err(e) => error_response(&format!("dashboard/history failed: {e:#}")), } diff --git a/hive-c0re/src/stores/audit_log.rs b/hive-c0re/src/stores/audit_log.rs index eb1cd377..e2928f2b 100644 --- a/hive-c0re/src/stores/audit_log.rs +++ b/hive-c0re/src/stores/audit_log.rs @@ -27,6 +27,7 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use rusqlite::{Connection, params}; use serde::Serialize; +use utoipa::ToSchema; /// Process-singleton handle, set once at coordinator startup. Mirrors /// `build_logs::GLOBAL` — lets recording sites write without threading an @@ -80,7 +81,7 @@ impl AuditOutcome { } /// One audit row as returned to the dashboard. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] pub struct AuditEntry { pub id: i64, pub ts_unix: DateTime,