Skip to main content

hypercall_admin/monitoring/
journal.rs

1//! Durable engine journal admin endpoints.
2
3use axum::{extract::State, http::StatusCode, response::IntoResponse};
4use serde::Serialize;
5use sonic_rs::json;
6
7use crate::state::AdminState;
8use hypercall_db::{JournalCommandSummary, JournalFullRecord};
9use hypercall_runtime_api::sonic_json::SonicJson;
10
11/// Query parameters for journal listing
12#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
13pub struct JournalQuery {
14    /// Maximum number of records to return (default: 100)
15    #[serde(default = "default_journal_limit")]
16    pub limit: usize,
17}
18
19fn default_journal_limit() -> usize {
20    100
21}
22
23/// Response containing a list of journal command summaries
24#[derive(Debug, Serialize, utoipa::ToSchema)]
25pub struct JournalListResponse {
26    /// Number of records returned
27    pub count: usize,
28    /// List of journal command summaries (most recent first)
29    #[schema(value_type = Vec<Object>)]
30    pub records: Vec<JournalCommandSummary>,
31}
32
33/// Response containing a full journal record
34#[derive(Debug, Serialize, utoipa::ToSchema)]
35pub struct JournalRecordResponse {
36    /// Whether record was found
37    pub found: bool,
38    /// Full journal record (if found)
39    #[schema(value_type = Option<Object>)]
40    pub record: Option<JournalFullRecord>,
41}
42
43/// GET /monitoring/engine/journal/commands - List recent durable journal entries
44///
45/// Returns the last N journal command records showing what commands
46/// were durably persisted to the database. Unlike in-memory command traces,
47/// these survive restarts and provide idempotency guarantees.
48///
49/// **Authentication**: Requires `X-Admin-Key` header (performance protection).
50#[utoipa::path(
51    get,
52    path = "/monitoring/engine/journal/commands",
53    params(JournalQuery),
54    responses(
55        (status = 200, description = "List of journal command summaries", body = JournalListResponse),
56        (status = 401, description = "Invalid or missing X-Admin-Key header"),
57        (status = 503, description = "Engine journal not available")
58    ),
59    tag = "Monitoring",
60    security(("admin_key" = []))
61)]
62pub async fn list_journal_commands(
63    State(app_state): State<AdminState>,
64    axum::extract::Query(params): axum::extract::Query<JournalQuery>,
65) -> impl IntoResponse {
66    let journal = match &app_state.engine_journal_reader {
67        Some(j) => j.clone(),
68        None => {
69            return (
70                StatusCode::SERVICE_UNAVAILABLE,
71                SonicJson(json!({
72                    "error": "Engine journal not available"
73                })),
74            )
75                .into_response();
76        }
77    };
78
79    // Execute query in blocking task (diesel is sync)
80    let limit = params.limit;
81    let result = tokio::task::spawn_blocking(move || journal.get_recent(limit)).await;
82
83    match result {
84        Ok(Ok(records)) => {
85            let count = records.len();
86            (
87                StatusCode::OK,
88                SonicJson(JournalListResponse { count, records }),
89            )
90                .into_response()
91        }
92        Ok(Err(e)) => (
93            StatusCode::INTERNAL_SERVER_ERROR,
94            SonicJson(json!({
95                "error": format!("Failed to query journal: {}", e)
96            })),
97        )
98            .into_response(),
99        Err(e) => (
100            StatusCode::INTERNAL_SERVER_ERROR,
101            SonicJson(json!({
102                "error": format!("Task failed: {}", e)
103            })),
104        )
105            .into_response(),
106    }
107}
108
109/// GET /monitoring/engine/journal/commands/{request_id} - Get journal record by request ID
110///
111/// Returns the full journal record for a specific request_id, including
112/// the command JSON, response JSON, all emitted events, and pre/post state digests.
113///
114/// **Authentication**: Requires `X-Admin-Key` header (performance protection).
115#[utoipa::path(
116    get,
117    path = "/monitoring/engine/journal/commands/{request_id}",
118    params(
119        ("request_id" = String, Path, description = "Request ID to look up")
120    ),
121    responses(
122        (status = 200, description = "Journal record for the request", body = JournalRecordResponse),
123        (status = 401, description = "Invalid or missing X-Admin-Key header"),
124        (status = 404, description = "No record found for request_id"),
125        (status = 503, description = "Engine journal not available")
126    ),
127    tag = "Monitoring",
128    security(("admin_key" = []))
129)]
130pub async fn get_journal_command_by_id(
131    State(app_state): State<AdminState>,
132    axum::extract::Path(request_id): axum::extract::Path<String>,
133) -> impl IntoResponse {
134    let journal = match &app_state.engine_journal_reader {
135        Some(j) => j.clone(),
136        None => {
137            return (
138                StatusCode::SERVICE_UNAVAILABLE,
139                SonicJson(json!({
140                    "error": "Engine journal not available"
141                })),
142            )
143                .into_response();
144        }
145    };
146
147    let req_id = match uuid::Uuid::parse_str(&request_id) {
148        Ok(id) => id,
149        Err(e) => {
150            return (
151                StatusCode::BAD_REQUEST,
152                SonicJson(json!({
153                    "error": format!("Invalid request_id UUID '{}': {}", request_id, e)
154                })),
155            )
156                .into_response();
157        }
158    };
159
160    // Execute query in blocking task (diesel is sync)
161    let result = tokio::task::spawn_blocking(move || journal.get_by_request_id(&req_id)).await;
162
163    match result {
164        Ok(Ok(Some(record))) => (
165            StatusCode::OK,
166            SonicJson(JournalRecordResponse {
167                found: true,
168                record: Some(record),
169            }),
170        )
171            .into_response(),
172        Ok(Ok(None)) => (
173            StatusCode::NOT_FOUND,
174            SonicJson(json!({
175                "error": format!("No record found for request_id: {}", request_id)
176            })),
177        )
178            .into_response(),
179        Ok(Err(e)) => (
180            StatusCode::INTERNAL_SERVER_ERROR,
181            SonicJson(json!({
182                "error": format!("Failed to query journal: {}", e)
183            })),
184        )
185            .into_response(),
186        Err(e) => (
187            StatusCode::INTERNAL_SERVER_ERROR,
188            SonicJson(json!({
189                "error": format!("Task failed: {}", e)
190            })),
191        )
192            .into_response(),
193    }
194}