mod config;
mod dto;
mod error;
mod handlers;
pub mod inject;
mod pid;
mod state;
pub use config::{ServerConfig, Transport, DEFAULT_IDLE_TIMEOUT};
pub use error::{ApiError, ServerError};
pub use handlers::{PROJECT_HEADER, SESSION_HEADER};
pub use pid::{acquire_pid_lock, PidGuard};
pub use state::AppState;
use std::sync::Arc;
use std::time::Duration;
use axum::routing::{delete, patch, post};
use axum::Router;
pub fn build_router(state: AppState) -> Router {
let activity = Arc::clone(state.activity());
Router::new()
.route("/v1/health", axum::routing::get(handlers::health))
.route("/v1/capsules", post(handlers::open_capsule))
.route(
"/v1/capsules/open",
axum::routing::get(handlers::get_open_capsule),
)
.route("/v1/capsules/{id}/close", patch(handlers::close_capsule))
.route("/v1/observations", post(handlers::append_observation))
.route(
"/v1/observations/{id}/forget",
post(handlers::forget_observation),
)
.route("/v1/retrieve", post(handlers::retrieve))
.route("/v1/pins", post(handlers::create_pin))
.route("/v1/pins/{id}", delete(handlers::unpin))
.route(
"/v1/context/session-start",
post(handlers::session_start_context),
)
.route(
"/v1/context/pre-compact",
post(handlers::pre_compact_context),
)
.layer(axum::middleware::from_fn(
move |req, next: axum::middleware::Next| {
let activity = Arc::clone(&activity);
async move {
activity.enter();
let response = next.run(req).await;
activity.leave();
response
}
},
))
.with_state(state)
}
pub async fn serve(config: ServerConfig) -> Result<(), ServerError> {
let _pid_guard = acquire_pid_lock(&config.pid_path)?;
let state = AppState::new(config.kindling_home.clone());
let app = build_router(state.clone());
match config.transport {
#[cfg(unix)]
Transport::Uds => {
serve_on_uds(&config, app, state.activity().clone()).await?;
let _ = remove_socket(&config.socket_path);
}
Transport::Tcp => {
serve_on_tcp(&config, app, state.activity().clone()).await?;
}
}
Ok(())
}
async fn wait_until_idle(activity: Arc<state::Activity>, idle_timeout: Duration) {
let poll = idle_timeout
.checked_div(4)
.unwrap_or(idle_timeout)
.max(Duration::from_millis(25));
loop {
tokio::time::sleep(poll).await;
if activity.is_idle_for(idle_timeout) {
return;
}
}
}
#[cfg(unix)]
async fn serve_on_uds(
config: &ServerConfig,
app: Router,
activity: Arc<state::Activity>,
) -> Result<(), ServerError> {
use std::os::unix::fs::PermissionsExt;
use tokio::net::UnixListener;
let _ = remove_socket(&config.socket_path);
if let Some(parent) = config.socket_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
}
}
let listener = UnixListener::bind(&config.socket_path)?;
std::fs::set_permissions(&config.socket_path, std::fs::Permissions::from_mode(0o600))?;
let idle_timeout = config.idle_timeout;
axum::serve(listener, app)
.with_graceful_shutdown(wait_until_idle(activity, idle_timeout))
.await?;
Ok(())
}
async fn serve_on_tcp(
config: &ServerConfig,
app: Router,
activity: Arc<state::Activity>,
) -> Result<(), ServerError> {
use tokio::net::TcpListener;
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let port = listener.local_addr()?.port();
if let Some(parent) = config.port_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(&config.port_path, port.to_string())?;
let idle_timeout = config.idle_timeout;
let serve_result = axum::serve(listener, app)
.with_graceful_shutdown(wait_until_idle(activity, idle_timeout))
.await;
let _ = remove_socket(&config.port_path);
serve_result?;
Ok(())
}
fn remove_socket(path: &std::path::Path) -> std::io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}