Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions api/operations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion crates/rite-server/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand All @@ -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
Expand Down
180 changes: 173 additions & 7 deletions crates/rite-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -238,17 +237,18 @@ async fn health() -> &'static str {
"ok"
}

async fn status(
axum::extract::State(state): axum::extract::State<AppState>,
) -> Json<serde_json::Value> {
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.
Expand Down Expand Up @@ -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};
Expand All @@ -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::<BTreeSet<_>>();
let expected = [
"actions_failed",
"actions_succeeded",
"events_matched",
"events_received",
"handlers_loaded",
"uptime_seconds",
]
.into_iter()
.map(String::from)
.collect::<BTreeSet<_>>();
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<mpsc::Sender<(HeaderMap, String)>>,
headers: HeaderMap,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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::<Vec<_>>();
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]
Expand Down
17 changes: 17 additions & 0 deletions generated/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}" },
];

Expand All @@ -49,6 +50,7 @@ pub fn generated_router() -> Router<crate::AppState> {
.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))
}

Expand Down Expand Up @@ -98,6 +100,21 @@ async fn get_handler(
.await
}

async fn get_status(
State(state): State<crate::AppState>,
) -> 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<crate::AppState>,
Path(path): Path<BTreeMap<String, String>>,
Expand Down
Loading