diff --git a/README.md b/README.md index 763ac6d..8cec324 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,23 @@ RITE_GITHUB_WEBHOOK_SECRET=change-me cargo run -p rite-cli -- --config rite.toml Endpoints: - `GET /health` — returns `ok` +- `GET /status` — live process-local event/action counters (HTTP-only) - `GET /sources` — configured source adapters - `POST /event/github` — authenticated GitHub webhook ingress using `X-Hub-Signature-256` - `POST /event/uptime_kuma` — authenticated Uptime Kuma webhook ingress using base64 `Signature` HMAC-SHA256 +### Process status + +`GET /status` returns a JSON snapshot from the running Rite process with exactly +these fields: `events_received`, `events_matched`, `actions_succeeded`, +`actions_failed`, `uptime_seconds`, and `handlers_loaded`. The counters and +uptime are process-local and reset when Rite restarts; they are not persisted, +aggregated, or a view into another running server. + +Status is intentionally HTTP-only. `rite-cli` reads local configuration rather +than connecting to a running server, so it does not expose potentially +misleading live metrics; the MCP manifest likewise has no status tool. + ## CLI and MCP Rite's agent-facing read operations are generated from `api/operations.yaml` and run through the diff --git a/api/operations.yaml b/api/operations.yaml index ad16286..d4a8729 100644 --- a/api/operations.yaml +++ b/api/operations.yaml @@ -28,6 +28,14 @@ operations: type: string required: true location: path + - name: get_status + description: Get live process-local counters for the running Rite server; values reset when it restarts. + method: GET + path: /status + read: true + output_type: Value + parameters: [] + surfaces: [http] - name: receive_event description: Receive a raw webhook delivery for HMAC-verified event ingress. method: POST diff --git a/crates/rite-server/src/dispatch.rs b/crates/rite-server/src/dispatch.rs index e791b72..ab38dcc 100644 --- a/crates/rite-server/src/dispatch.rs +++ b/crates/rite-server/src/dispatch.rs @@ -81,7 +81,7 @@ impl OperationError { /// The single dispatch every generated surface funnels through. /// /// Operation helpers stay `async` so arms can await real I/O; the current -/// three are pure reads. +/// generated reads are pure snapshots/configuration queries. /// /// # Errors /// @@ -95,6 +95,7 @@ pub async fn execute_operation( match operation { "list_sources" => list_sources(state).await, "list_handlers" => list_handlers(state).await, + "get_status" => Ok(crate::status_snapshot(state)), "get_handler" => { let handler_id = input .path diff --git a/crates/rite-server/src/lib.rs b/crates/rite-server/src/lib.rs index bba42f9..f4a5705 100644 --- a/crates/rite-server/src/lib.rs +++ b/crates/rite-server/src/lib.rs @@ -11,7 +11,7 @@ use std::{ time::Instant, }; -use axum::{Json, Router, routing::get}; +use axum::{Router, routing::get}; use rite_core::{EventSource, RiteAction, RiteEvent, RiteHandler}; use rite_sources::{github::GitHubSource, iris::IrisSource, uptime_kuma::UptimeKumaSource}; use serde::Deserialize; @@ -113,7 +113,6 @@ impl Default for Metrics { pub fn app(state: AppState) -> Router { Router::new() .route("/health", get(health)) - .route("/status", get(status)) .merge(dispatch::generated::generated_router()) .with_state(state) } @@ -238,17 +237,18 @@ async fn health() -> &'static str { "ok" } -async fn status( - axum::extract::State(state): axum::extract::State, -) -> Json { - Json(serde_json::json!({ +/// Return the live, process-local metric snapshot shared by every public +/// surface that can truthfully address the running server. +#[must_use] +pub(crate) fn status_snapshot(state: &AppState) -> serde_json::Value { + serde_json::json!({ "events_received": state.metrics.events_received.load(Ordering::Relaxed), "events_matched": state.metrics.events_matched.load(Ordering::Relaxed), "actions_succeeded": state.metrics.actions_succeeded.load(Ordering::Relaxed), "actions_failed": state.metrics.actions_failed.load(Ordering::Relaxed), "uptime_seconds": state.metrics.started_at.elapsed().as_secs(), "handlers_loaded": state.handlers.len(), - })) + }) } /// Builds state with a GitHub source and TOML-configured handlers. @@ -363,6 +363,7 @@ mod tests { }; use hmac::{Hmac, Mac}; use sha2::Sha256; + use std::collections::BTreeSet; use tokio::io::AsyncWriteExt; use tokio::sync::{mpsc, oneshot}; use tokio::time::{Duration, timeout}; @@ -384,6 +385,27 @@ mod tests { STANDARD.encode(mac.finalize().into_bytes()) } + fn assert_status_schema(status: &serde_json::Value) { + let object = status.as_object().expect("status is an object"); + let keys = object.keys().cloned().collect::>(); + let expected = [ + "actions_failed", + "actions_succeeded", + "events_matched", + "events_received", + "handlers_loaded", + "uptime_seconds", + ] + .into_iter() + .map(String::from) + .collect::>(); + assert_eq!(keys, expected); + assert!( + object.values().all(serde_json::Value::is_u64), + "every status value is a nonnegative integer: {status}" + ); + } + async fn capture_request( State(sender): State>, headers: HeaderMap, @@ -485,6 +507,36 @@ mod tests { .expect("response"); assert_eq!(health.status(), StatusCode::OK); + let before = app + .clone() + .oneshot( + Request::get("/status") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(before.status(), StatusCode::OK); + assert!( + before + .headers() + .get(header::CONTENT_TYPE) + .expect("JSON content type") + .to_str() + .expect("valid content type") + .starts_with("application/json") + ); + let before = to_bytes(before.into_body(), usize::MAX) + .await + .expect("body"); + let before: serde_json::Value = serde_json::from_slice(&before).expect("json"); + assert_status_schema(&before); + assert_eq!(before["events_received"], 0); + assert_eq!(before["events_matched"], 0); + assert_eq!(before["actions_succeeded"], 0); + assert_eq!(before["actions_failed"], 0); + assert_eq!(before["handlers_loaded"], 0); + let body = br#"{"ref":"refs/heads/main","repository":{"name":"rite"}}"#; let response = app .clone() @@ -521,10 +573,124 @@ mod tests { .await .expect("body"); let status: serde_json::Value = serde_json::from_slice(&status).expect("json"); + assert_status_schema(&status); assert_eq!(status["events_received"], 1); assert_eq!(status["events_matched"], 0); assert_eq!(status["actions_succeeded"], 0); assert_eq!(status["actions_failed"], 0); + assert_eq!(status["handlers_loaded"], 0); + } + + #[tokio::test] + async fn generated_status_route_and_shared_dispatch_report_same_live_metrics() { + let config = load_config( + "[[rites]]\nname = \"status-handler\"\nsource = \"github\"\nmatch = { event_type = \"push\" }\naction = { type = \"http_post\", url = \"http://127.0.0.1:9\" }\n", + ) + .expect("config parses"); + let state = configured_state("secret", config).expect("state configures"); + state + .metrics + .events_received + .store(7, std::sync::atomic::Ordering::Relaxed); + state + .metrics + .events_matched + .store(5, std::sync::atomic::Ordering::Relaxed); + state + .metrics + .actions_succeeded + .store(3, std::sync::atomic::Ordering::Relaxed); + state + .metrics + .actions_failed + .store(2, std::sync::atomic::Ordering::Relaxed); + + let dispatched = crate::dispatch::execute_operation( + &state, + "get_status", + crate::dispatch::OperationInput::default(), + ) + .await + .expect("generated operation succeeds"); + assert_status_schema(&dispatched); + + let response = app(state.clone()) + .oneshot( + Request::get("/status") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get(header::CONTENT_TYPE) + .expect("JSON content type") + .to_str() + .expect("valid content type") + .starts_with("application/json") + ); + let response = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let routed: serde_json::Value = serde_json::from_slice(&response).expect("json"); + assert_status_schema(&routed); + + for field in [ + "events_received", + "events_matched", + "actions_succeeded", + "actions_failed", + "handlers_loaded", + ] { + assert_eq!(routed[field], dispatched[field], "field {field}"); + } + let dispatched_uptime = dispatched["uptime_seconds"] + .as_u64() + .expect("dispatch uptime is an integer"); + let routed_uptime = routed["uptime_seconds"] + .as_u64() + .expect("route uptime is an integer"); + assert!( + routed_uptime >= dispatched_uptime, + "uptime can advance between snapshots" + ); + assert_eq!(routed["events_received"], 7); + assert_eq!(routed["events_matched"], 5); + assert_eq!(routed["actions_succeeded"], 3); + assert_eq!(routed["actions_failed"], 2); + assert_eq!(routed["handlers_loaded"], 1); + } + + #[test] + fn status_route_is_generated_and_health_is_the_only_handwritten_app_route() { + let status_routes = crate::dispatch::generated::GENERATED_ROUTES + .iter() + .filter(|route| route.name == "get_status") + .collect::>(); + assert_eq!(status_routes.len(), 1); + assert_eq!(status_routes[0].method, "GET"); + assert_eq!(status_routes[0].path, "/status"); + + let source = include_str!("lib.rs"); + let app_body = source + .split("pub fn app") + .nth(1) + .and_then(|remainder| remainder.split("/// Parse handler configuration").next()) + .expect("app body remains delimited by its following API docs"); + assert!(app_body.contains(".route(\"/health\", get(health))")); + assert_eq!( + app_body.matches(".route(").count(), + 1, + "only /health may be registered by hand" + ); + let status_registration = format!(".route(\"/{}", "status"); + assert!( + !app_body.contains(&status_registration), + "/status must remain owned by generated routing" + ); } #[tokio::test] diff --git a/generated/http.rs b/generated/http.rs index 8c34453..6734b0c 100644 --- a/generated/http.rs +++ b/generated/http.rs @@ -41,6 +41,7 @@ pub const GENERATED_ROUTES: &[GeneratedRoute] = &[ GeneratedRoute { name: "list_sources", method: "GET", path: "/sources" }, GeneratedRoute { name: "list_handlers", method: "GET", path: "/handlers" }, GeneratedRoute { name: "get_handler", method: "GET", path: "/handlers/{handler_id}" }, + GeneratedRoute { name: "get_status", method: "GET", path: "/status" }, GeneratedRoute { name: "receive_event", method: "POST", path: "/event/{source}" }, ]; @@ -49,6 +50,7 @@ pub fn generated_router() -> Router { .route("/sources", get(list_sources)) .route("/handlers", get(list_handlers)) .route("/handlers/{handler_id}", get(get_handler)) + .route("/status", get(get_status)) .route("/event/{source}", post(receive_event)) } @@ -98,6 +100,21 @@ async fn get_handler( .await } +async fn get_status( + State(state): State, +) -> Response { + crate::dispatch::execute_operation_http( + &state, + "get_status", + GeneratedOperationInput { + path: BTreeMap::new(), + query: BTreeMap::new(), + body: Value::Null, + }, + ) + .await +} + async fn receive_event( State(state): State, Path(path): Path>,