use std::time::Duration;
use anyhow::Context;
use axum::response::IntoResponse;
use axum::{
Router,
extract::State,
routing::{get, post},
};
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
use sqlx::PgPool;
use tokio_util::sync::CancellationToken;
use tower_http::{limit::RequestBodyLimitLayer, trace::TraceLayer};
use crate::{auth, dashboard, events::EventHub, tools::Bus, webhooks};
pub struct ServeOptions {
pub bind: String,
pub allowed_hosts: Vec<String>,
pub allowed_origins: Vec<String>,
pub max_request_bytes: usize,
pub rate_limit_per_minute: u32,
pub dashboard_secret: Vec<u8>,
pub nats_url: Option<String>,
pub nats_credentials: Option<String>,
pub publication_worker: bool,
pub event_ping_secs: u64,
}
pub const DEFAULT_MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024;
pub const DEFAULT_RATE_LIMIT_PER_MINUTE: u32 = 600;
#[derive(Clone)]
struct HealthState {
pool: PgPool,
backends: crate::store::routing::Backends,
hub: EventHub,
}
async fn health(
State(state): State<HealthState>,
axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> (axum::http::StatusCode, axum::Json<serde_json::Value>) {
let db = sqlx::query_scalar::<_, i32>("SELECT 1")
.fetch_one(&state.pool)
.await;
if let Err(e) = db {
tracing::error!(error = %e, "health check failed");
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
axum::Json(serde_json::json!({ "status": "degraded", "database": "down" })),
);
}
let mut body = serde_json::json!({ "status": "ok", "database": "up" });
let events = state.hub.listener().report();
if events["listener"] != "live" {
body["status"] = serde_json::json!("degraded");
}
body["events"] = events;
if state.backends.jetstream_configured() {
body["broker"] = serde_json::json!("configured");
if query.get("broker").is_some_and(|v| v == "check") {
body["broker"] = match state.backends.broker_reachable().await {
Some(true) => serde_json::json!("up"),
_ => serde_json::json!("unreachable"),
};
}
if let Ok(row) = sqlx::query_as::<_, (i64, i64, Option<i64>)>(
"SELECT count(*) FILTER (WHERE state <> 'failed'),
count(*) FILTER (WHERE state = 'failed'),
extract(epoch FROM now() - min(created_at)
FILTER (WHERE state <> 'failed'))::bigint
FROM conversation_outbox",
)
.fetch_one(&state.pool)
.await
{
body["publication"] = serde_json::json!({
"pending": row.0,
"failed": row.1,
"oldest_pending_seconds": row.2,
});
}
}
(axum::http::StatusCode::OK, axum::Json(body))
}
async fn explain_payload_too_large(
limit: usize,
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let resp = next.run(req).await;
if resp.status() != axum::http::StatusCode::PAYLOAD_TOO_LARGE {
return resp;
}
(
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
axum::Json(serde_json::json!({
"error": format!(
"request body is too large; this server accepts up to {limit} bytes. \
Send fewer or smaller attachments (256 KiB each, 8 per message), \
or split the call into several smaller ones."
)
})),
)
.into_response()
}
const PRESENCE_SWEEP_INTERVAL: Duration = Duration::from_secs(3600);
async fn run_presence_sweeper(pool: PgPool, ct: CancellationToken) {
loop {
match crate::store::presence::sweep_expired_shared_rows(&pool).await {
Ok(0) => {}
Ok(n) => tracing::debug!(deleted = n, "swept long-dead shared presence rows"),
Err(e) => tracing::warn!(error = %e, "presence sweep failed"),
}
tokio::select! {
_ = ct.cancelled() => return,
_ = tokio::time::sleep(PRESENCE_SWEEP_INTERVAL) => {}
}
}
}
fn configured(value: &Option<String>) -> Option<&str> {
value.as_deref().map(str::trim).filter(|v| !v.is_empty())
}
const PUBLISH_TIMEOUT: Duration = Duration::from_secs(30);
const OUTBOX_IDLE: Duration = Duration::from_millis(500);
const RECONCILE_INTERVAL: Duration = Duration::from_secs(30);
const BODY_RELEASE_GRACE_SECS: i64 = 300;
const BODY_SWEEP_INTERVAL: Duration = Duration::from_secs(60);
const REFERENCE_INTERVAL: Duration = Duration::from_millis(500);
const REFERENCES_PER_PASS: i64 = 200;
async fn run_outbox_worker(
pool: PgPool,
backends: crate::store::routing::Backends,
ct: CancellationToken,
) {
let worker = format!("serve-{}", uuid::Uuid::new_v4().simple());
let mut next_reconcile = tokio::time::Instant::now();
let mut next_sweep = tokio::time::Instant::now();
let mut next_references = tokio::time::Instant::now();
loop {
if ct.is_cancelled() {
return;
}
let now = tokio::time::Instant::now();
if now >= next_reconcile {
next_reconcile = now + RECONCILE_INTERVAL;
reconcile_routed_teams(&pool, &backends).await;
}
if now >= next_sweep {
next_sweep = now + BODY_SWEEP_INTERVAL;
match crate::store::outbox::release_published_bodies(
&pool,
&backends,
None,
BODY_RELEASE_GRACE_SECS,
)
.await
{
Ok(0) => {}
Ok(n) => tracing::debug!(released = n, "dropped bodies their backend now holds"),
Err(e) => tracing::warn!(error = %e, "could not release published bodies"),
}
}
if now >= next_references {
next_references = now + REFERENCE_INTERVAL;
publish_references(&pool, &backends).await;
}
let leased = match crate::store::outbox::lease(&pool, &worker).await {
Ok(leased) => leased,
Err(e) => {
tracing::warn!(error = %e, "could not lease an outbox slot");
None
}
};
let Some(lease) = leased else {
tokio::select! {
_ = ct.cancelled() => return,
_ = tokio::time::sleep(OUTBOX_IDLE) => {}
}
continue;
};
let backend = match backends
.for_conversation(&pool, lease.conversation_id)
.await
{
Ok(backend) => backend,
Err(e) => {
tracing::error!(error = %e, conversation = %lease.conversation_id,
"no backend for this conversation; the message stays pending");
tokio::time::sleep(OUTBOX_IDLE).await;
continue;
}
};
let publish = crate::store::outbox::publish_leased(&pool, &backend, &lease);
match tokio::time::timeout(PUBLISH_TIMEOUT, publish).await {
Ok(Ok(settled)) => {
tracing::debug!(message = %lease.message_id, ?settled, "publication settled")
}
Ok(Err(e)) => tracing::warn!(error = %e, "could not settle a publication"),
Err(_) => {
if let Err(e) = crate::store::outbox::mark_uncertain(
&pool,
&lease,
"the publish did not answer within the timeout",
)
.await
{
tracing::warn!(error = %e, "could not record an uncertain publication");
}
}
}
}
}
async fn publish_references(pool: &PgPool, backends: &crate::store::routing::Backends) {
let teams = match backends.routed_teams(pool).await {
Ok(teams) => teams,
Err(e) => {
tracing::warn!(error = %e, "could not list routed teams");
return;
}
};
for team in teams {
let Ok(crate::store::routing::AnyBackend::JetStream(backend)) =
backends.for_team(pool, team).await
else {
continue;
};
match crate::store::inbox::publish_pending(pool, &backend, team, REFERENCES_PER_PASS).await
{
Ok(0) => {}
Ok(n) => tracing::debug!(%team, published = n, "inbox references published"),
Err(e) => tracing::warn!(error = %e, %team, "could not publish inbox references"),
}
}
}
async fn reconcile_routed_teams(pool: &PgPool, backends: &crate::store::routing::Backends) {
let teams = match backends.routed_teams(pool).await {
Ok(teams) => teams,
Err(e) => {
tracing::warn!(error = %e, "could not list routed teams");
return;
}
};
for team in teams {
let backend = match backends.for_team(pool, team).await {
Ok(backend) => backend,
Err(e) => {
tracing::error!(error = %e, %team, "this team is routed to a backend this \
process cannot reach");
continue;
}
};
match crate::store::outbox::resolve_uncertain(pool, &backend, team).await {
Ok(0) => {}
Ok(n) => tracing::info!(%team, resolved = n, "resolved uncertain publications"),
Err(e) => tracing::warn!(error = %e, %team, "reconciliation failed"),
}
}
}
pub fn build_router(pool: PgPool, opts: &ServeOptions, ct: CancellationToken) -> Router {
let hub = EventHub::with_ping(std::time::Duration::from_secs(opts.event_ping_secs));
tokio::spawn(crate::events::run_pg_listener(
pool.clone(),
hub.clone(),
ct.clone(),
));
tokio::spawn(webhooks::run_dispatcher(
pool.clone(),
hub.clone(),
ct.clone(),
));
tokio::spawn(run_presence_sweeper(pool.clone(), ct.clone()));
let backends = match configured(&opts.nats_url) {
Some(url) => {
let mut config = crate::store::jetstream::Config::new(url.to_owned());
config.credentials = configured(&opts.nats_credentials).map(str::to_owned);
tracing::info!(%url, "JetStream available for teams routed to it");
crate::store::routing::Backends::with_jetstream(pool.clone(), config)
}
None => crate::store::routing::Backends::postgres_only(pool.clone()),
};
if backends.jetstream_configured() && opts.publication_worker {
tokio::spawn(run_outbox_worker(
pool.clone(),
backends.clone(),
ct.clone(),
));
}
let mut config = StreamableHttpServerConfig::default()
.with_json_response(true)
.with_legacy_session_mode(false)
.with_sse_keep_alive(Some(Duration::from_secs(30)))
.with_cancellation_token(ct);
config = if opts.allowed_hosts.is_empty() {
config.disable_allowed_hosts()
} else {
config.with_allowed_hosts(opts.allowed_hosts.clone())
};
if !opts.allowed_origins.is_empty() {
config = config.with_allowed_origins(opts.allowed_origins.clone());
}
let mcp: StreamableHttpService<Bus, LocalSessionManager> = StreamableHttpService::new(
{
let pool = pool.clone();
let hub = hub.clone();
let backends = backends.clone();
move || {
Ok(Bus::with_backends(
pool.clone(),
hub.clone(),
backends.clone(),
))
}
},
Default::default(),
config,
);
let limiter = crate::ratelimit::RateLimiter::new(opts.rate_limit_per_minute);
if limiter.is_none() {
tracing::warn!("in-process rate limiting is disabled (BUS_RATE_LIMIT_PER_MINUTE=0)");
}
let auth_state = auth::AuthState {
pool: pool.clone(),
limiter,
};
let mcp_routes = Router::new()
.nest_service("/mcp", mcp)
.route_layer(axum::middleware::from_fn_with_state(
auth_state,
auth::require_bearer,
))
.layer(RequestBodyLimitLayer::new(opts.max_request_bytes))
.layer(axum::middleware::from_fn({
let limit = opts.max_request_bytes;
move |req, next| explain_payload_too_large(limit, req, next)
}));
let dashboard_state = dashboard::DashboardState {
pool: pool.clone(),
secret: std::sync::Arc::new(opts.dashboard_secret.clone()),
};
let dashboard_routes = Router::new()
.route("/dashboard", get(dashboard::render))
.route("/dashboard/login", post(dashboard::login))
.with_state(dashboard_state);
let health_routes = Router::new()
.route("/health", get(health))
.with_state(HealthState {
pool: pool.clone(),
backends: backends.clone(),
hub: hub.clone(),
});
Router::new()
.fallback(|uri: axum::http::Uri| async move {
let path = uri.path().to_owned();
(
axum::http::StatusCode::NOT_FOUND,
axum::Json(serde_json::json!({
"error": format!(
"no route for {path} on this server. It serves POST /mcp (the MCP \
endpoint), GET /health, GET /dashboard and /admin/* for \
administrative credentials."
)
})),
)
})
.merge(health_routes)
.merge(dashboard_routes)
.merge(mcp_routes)
.nest(
"/admin",
crate::admin_api::router(pool.clone(), opts.rate_limit_per_minute),
)
.layer(
TraceLayer::new_for_http().make_span_with(|req: &axum::http::Request<_>| {
tracing::info_span!(
"http",
method = %req.method(),
path = %req.uri().path(),
)
}),
)
.with_state(pool)
}
pub async fn run(pool: PgPool, opts: ServeOptions) -> anyhow::Result<()> {
let ct = CancellationToken::new();
let app = build_router(pool, &opts, ct.child_token());
let listener = tokio::net::TcpListener::bind(&opts.bind)
.await
.with_context(|| format!("failed to bind {}", opts.bind))?;
let addr = listener.local_addr()?;
tracing::info!(%addr, "ai-crew-sync listening; MCP endpoint at /mcp");
let shutdown = {
let ct = ct.clone();
async move {
let ctrl_c = async {
tokio::signal::ctrl_c().await.ok();
};
#[cfg(unix)]
let term = async {
if let Ok(mut s) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
s.recv().await;
}
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = term => {},
}
tracing::info!("shutdown signal received");
ct.cancel();
}
};
axum::serve(listener, app)
.with_graceful_shutdown(shutdown)
.await
.context("server error")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::configured;
#[test]
fn an_empty_variable_is_not_a_setting() {
assert_eq!(
configured(&Some("nats://b:4222".into())),
Some("nats://b:4222")
);
assert_eq!(
configured(&Some(" nats://b:4222 ".into())),
Some("nats://b:4222")
);
assert_eq!(configured(&Some(String::new())), None);
assert_eq!(configured(&Some(" ".into())), None);
assert_eq!(configured(&None), None);
}
}