use super::cleanup_workbenches;
use super::create_flag;
use super::create_routine;
use super::delete_routine;
use super::get_lock_status;
use super::get_routine;
use super::health;
use super::list_agents;
use super::list_flags;
use super::list_routine_runs;
use super::list_routines;
use super::lock_routines;
use super::mcp::MoadimMcp;
use super::metrics;
use super::resolve_flag;
use super::restart;
use super::shutdown;
use super::trigger_routine;
use super::unlock_routines;
use super::update_routine;
use crate::error::AppError;
use crate::middlewares;
use crate::routines::{self, RoutineStore};
use crate::utils::time::now_secs;
use axum::{
http::{
header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH},
HeaderMap, StatusCode,
},
middleware,
response::{IntoResponse, Response},
routing::{delete, get, post},
Router,
};
use std::hash::{Hash, Hasher};
use std::sync::{Arc, LazyLock};
use tower::limit::GlobalConcurrencyLimitLayer;
use tower_http::catch_panic::CatchPanicLayer;
use tower_http::compression::CompressionLayer;
use utoipa_swagger_ui::SwaggerUi;
const MAX_CONCURRENT_REQUESTS: usize = 64;
pub type ShutdownSignal = Arc<tokio::sync::Notify>;
#[derive(Clone)]
pub struct AppState {
pub routines: RoutineStore,
pub routines_dir: std::path::PathBuf,
pub uptime_start: u64,
pub shutdown: ShutdownSignal,
}
impl axum::extract::FromRef<AppState> for RoutineStore {
fn from_ref(state: &AppState) -> Self {
state.routines.clone()
}
}
const INDEX_HTML: &str = include_str!(concat!(env!("OUT_DIR"), "/index.html"));
static INDEX_ETAG: LazyLock<String> = LazyLock::new(|| etag_for(INDEX_HTML));
fn etag_for(html: &str) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
html.hash(&mut hasher);
format!("\"{:016x}\"", hasher.finish())
}
fn serve_spa(html: &'static str, etag: &'static str, headers: &HeaderMap) -> Response {
let not_modified = headers
.get(IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == etag);
if not_modified {
return (StatusCode::NOT_MODIFIED, [(ETAG, etag)]).into_response();
}
(
[(ETAG, etag), (CACHE_CONTROL, "no-cache")],
axum::response::Html(html),
)
.into_response()
}
#[utoipa::path(get, path = "/",
responses(
(status = 200, description = "Web client HTML", body = str),
(status = 304, description = "Client's cached copy is still current"),
))]
pub async fn index(headers: HeaderMap) -> Response {
serve_spa(INDEX_HTML, INDEX_ETAG.as_str(), &headers)
}
pub async fn redirect_client_to_root(uri: axum::http::Uri) -> axum::response::Redirect {
let path = match uri.path().strip_prefix("/client") {
Some("") | None => "/",
Some(rest) => rest,
};
match uri.query() {
Some(query) => axum::response::Redirect::permanent(&format!("{path}?{query}")),
None => axum::response::Redirect::permanent(path),
}
}
async fn api_not_found() -> AppError {
AppError::NotFound
}
#[path = "http_settings_routes.rs"]
mod http_settings_routes;
#[allow(
unused_imports,
reason = "utoipa's OpenApi derive resolves these hidden __path_* types via crate::routes::http::__path_*, generated by #[utoipa::path] on the re-exported handlers below"
)]
pub use http_settings_routes::{
__path_get_current_machine, __path_get_max_concurrent_runs, __path_get_user_prompt,
__path_list_machines, __path_put_machine, __path_put_max_concurrent_runs,
__path_put_user_prompt, get_current_machine, get_max_concurrent_runs, get_user_prompt,
list_machines, put_machine, put_max_concurrent_runs, put_user_prompt, MachineResponse,
MaxConcurrentRunsResponse, SetMachineRequest, SetMaxConcurrentRunsRequest,
SetUserPromptRequest,
};
#[cfg(test)]
pub(crate) fn build_app(routines: RoutineStore) -> Router {
build_app_with_shutdown(routines, Arc::new(tokio::sync::Notify::new()))
}
pub(crate) fn build_app_with_shutdown(
routines: RoutineStore,
shutdown_signal: ShutdownSignal,
) -> Router {
use rmcp::transport::streamable_http_server::{
session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
};
let mcp_routines = routines.clone();
let app_state = AppState {
routines,
routines_dir: crate::paths::routines_dir(),
uptime_start: now_secs(),
shutdown: shutdown_signal,
};
let mcp_routines_dir = app_state.routines_dir.clone();
let uptime_start = app_state.uptime_start;
let mcp_shutdown = app_state.shutdown.clone();
let mcp_service = StreamableHttpService::new(
move || {
Ok(MoadimMcp::new(
mcp_routines.clone(),
mcp_routines_dir.clone(),
uptime_start,
mcp_shutdown.clone(),
))
},
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
);
let api = Router::new()
.route("/health", get(health::health))
.route("/metrics", get(metrics::metrics))
.route("/shutdown", post(shutdown::shutdown))
.route("/restart", post(restart::restart))
.route("/machine", get(get_current_machine).put(put_machine))
.route("/machines", get(list_machines))
.route(
"/config/user-prompt",
get(get_user_prompt).put(put_user_prompt),
)
.route(
"/config/max-concurrent-runs",
get(get_max_concurrent_runs).put(put_max_concurrent_runs),
)
.route("/agents", get(list_agents::list_agents))
.route("/routines.ics", get(routines::ical_feed))
.route(
"/routines",
get(list_routines::list_routines).post(create_routine::create_routine),
)
.route(
"/routines/cleanup",
post(cleanup_workbenches::cleanup_workbenches),
)
.route("/routines/runs", get(routines::get_all_runs))
.route(
"/routines/lock",
get(get_lock_status::get_lock_status)
.post(lock_routines::lock_routines)
.delete(unlock_routines::unlock_routines),
)
.route(
"/routines/{id}",
get(get_routine::get_routine)
.put(update_routine::replace)
.patch(update_routine::update_routine)
.delete(delete_routine::delete_routine),
)
.route(
"/routines/{id}/trigger",
post(trigger_routine::trigger_routine),
)
.route(
"/routines/{id}/prompt-preview",
get(routines::get_prompt_preview),
)
.route(
"/routines/{id}/scheduled-trigger",
post(routines::scheduled_trigger),
)
.route(
"/routines/{id}/flags",
get(list_flags::list_flags).post(create_flag::create_flag),
)
.route(
"/routines/{id}/flags/{filename}",
delete(resolve_flag::resolve_flag),
)
.route("/routines/{id}/logs", get(routines::get_logs))
.route(
"/routines/{id}/runs",
get(list_routine_runs::list_routine_runs),
)
.route(
"/routines/{id}/runs/{workbench}/log",
get(routines::get_run_log),
)
.route(
"/routines/{id}/runs/{workbench}/summary",
get(routines::get_run_summary),
)
.fallback(api_not_found)
.layer(middleware::from_fn(middlewares::timeout::request_timeout(
middlewares::timeout::API_REQUEST_TIMEOUT,
)));
Router::new()
.route("/", get(index))
.route(
"/ui",
get(|| async { axum::response::Redirect::permanent("/") }),
)
.route("/client", get(redirect_client_to_root))
.route("/client/{*rest}", get(redirect_client_to_root))
.nest("/api/v1", api)
.nest_service("/mcp", mcp_service)
.merge({
use utoipa::OpenApi as _;
SwaggerUi::new("/docs").url("/docs/openapi.json", crate::openapi::ApiDoc::openapi())
})
.fallback(get(index))
.layer(middleware::from_fn(
middlewares::host_validation::host_validation(
middlewares::host_validation::allowed_hosts(),
),
))
.layer(middleware::from_fn(
middlewares::security_headers::security_headers,
))
.layer(middleware::from_fn(middlewares::logger::logger))
.layer(CompressionLayer::new())
.layer(CatchPanicLayer::new())
.layer(GlobalConcurrencyLimitLayer::new(MAX_CONCURRENT_REQUESTS))
.with_state(app_state)
}
#[path = "http_listener.rs"]
mod http_listener;
pub use http_listener::run_with_listener_until;
#[cfg(test)]
use http_listener::{
serve_with_grace, shutdown_grace, write_openapi_spec, SHUTDOWN_GRACE, SHUTDOWN_GRACE_MS_ENV,
};
#[cfg(test)]
#[path = "http_tests.rs"]
mod http_tests;
#[cfg(test)]
#[path = "http_routing_tests.rs"]
mod http_routing_tests;
#[cfg(test)]
#[path = "http_listener_tests.rs"]
mod http_listener_tests;