Skip to main content

recall_server/server/
mod.rs

1//! The HTTP surface.
2//!
3//! Routes and their auth posture are frozen:
4//!
5//! | route | auth |
6//! |---|---|
7//! | `GET /health` | none — uptime tooling holds no secret |
8//! | `GET /admin` | none — static markup, no data |
9//! | `POST /sync`, `GET /sync`, `GET /admin/stats` | bearer token |
10//! | anything else | 404 JSON |
11//!
12//! This module owns the shared state, the router, and the background jobs.
13//! The four things it wires together are private submodules, each living
14//! next to its own tests: `middleware.rs` (rate limiting, then auth),
15//! `handlers.rs` (one function per route), `respond.rs` (the JSON shape of
16//! every reply, errors included) and `limit.rs` (the per-IP window the
17//! middleware consults).
18
19use std::future::Future;
20use std::net::SocketAddr;
21use std::sync::{Arc, PoisonError, RwLock};
22
23use anyhow::{Context, Result};
24use axum::extract::DefaultBodyLimit;
25// Imported by name because this module has a `middleware` of its own, and
26// an unqualified `middleware::` would resolve to that one.
27use axum::middleware::from_fn_with_state;
28use axum::routing::get;
29use axum::Router;
30use recall_wire::MergeError;
31use tokio::net::TcpListener;
32use tokio::task::JoinHandle;
33
34use crate::merge::{Merger, Status};
35use crate::{now, Config, Store};
36
37mod handlers;
38mod limit;
39mod middleware;
40mod respond;
41
42use handlers::{
43    handle_admin_page, handle_admin_stats, handle_health, handle_pull, handle_push, not_found,
44};
45use limit::RateLimiter;
46use middleware::guard;
47
48/// Bounds a single push. Memory files are prose; anything this large is a
49/// bug or an attack, not a note.
50const MAX_BODY_BYTES: usize = 5 << 20;
51
52struct Runtime {
53    last_backup_at: String,
54    last_merge_at: String,
55    last_merge_error: Option<MergeError>,
56    claude_status: Status,
57}
58
59struct AppState {
60    cfg: Config,
61    store: Arc<Store>,
62    merger: Merger,
63    started_at: String,
64    runtime: RwLock<Runtime>,
65    limiter: RateLimiter,
66}
67
68impl AppState {
69    fn read(&self) -> std::sync::RwLockReadGuard<'_, Runtime> {
70        self.runtime.read().unwrap_or_else(PoisonError::into_inner)
71    }
72    fn write(&self) -> std::sync::RwLockWriteGuard<'_, Runtime> {
73        self.runtime.write().unwrap_or_else(PoisonError::into_inner)
74    }
75}
76
77/// The HTTP API, its background jobs, and the state they share.
78pub struct Server {
79    state: Arc<AppState>,
80}
81
82impl Server {
83    /// Builds a server around an already-open store.
84    pub fn new(cfg: Config, store: Arc<Store>) -> Self {
85        let limiter = RateLimiter::new(cfg.rate_limit_window, cfg.rate_limit_max);
86        let merger = Merger::new(cfg.claude_bin.clone(), cfg.merge_timeout);
87        Self {
88            state: Arc::new(AppState {
89                cfg,
90                store,
91                merger,
92                started_at: now(),
93                runtime: RwLock::new(Runtime {
94                    last_backup_at: String::new(),
95                    last_merge_at: String::new(),
96                    last_merge_error: None,
97                    claude_status: Status::default(),
98                }),
99                limiter,
100            }),
101        }
102    }
103
104    /// The router, built separately from binding a port so tests can drive
105    /// it without real sockets.
106    pub fn router(&self) -> Router {
107        let state = self.state.clone();
108        Router::new()
109            // Go's mux dispatched every method through one guarded handler
110            // and 404'd the ones it didn't implement; the method fallbacks
111            // keep that shape (and its JSON body) instead of axum's bare
112            // 405.
113            .route(
114                "/sync",
115                get(handle_pull).post(handle_push).fallback(not_found),
116            )
117            .route("/admin/stats", get(handle_admin_stats).fallback(not_found))
118            // Registered before the layer, so only these two routes are
119            // rate limited and authenticated.
120            .route_layer(from_fn_with_state(state.clone(), guard))
121            .route("/health", get(handle_health).fallback(not_found))
122            .route("/admin", get(handle_admin_page).fallback(not_found))
123            .fallback(not_found)
124            .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
125            .with_state(state)
126    }
127
128    /// Re-runs the local `claude auth status` probe.
129    pub async fn refresh_claude_status(&self) {
130        let status = self.state.merger.check_status().await;
131        self.state.write().claude_status = status;
132    }
133
134    /// The last known state of the `claude` CLI.
135    pub fn claude_status(&self) -> Status {
136        self.state.read().claude_status.clone()
137    }
138
139    /// Overrides the cached CLI status.
140    ///
141    /// Exposed so tests can exercise the merge-failure path — reaching it
142    /// otherwise needs a real, logged-in CLI on the machine running them.
143    pub fn set_claude_status(&self, status: Status) {
144        self.state.write().claude_status = status;
145    }
146
147    /// Writes a backup now. Failure is logged, never propagated: it becomes
148    /// visible through `/health`'s `last_backup_at` going stale.
149    pub fn run_backup(&self) {
150        run_backup(&self.state);
151    }
152
153    /// Starts background work: the first Claude CLI status check, its
154    /// refresh loop, and backups. All of it is best-effort — none of it may
155    /// take the sync API down.
156    pub fn start_background(&self) -> Vec<JoinHandle<()>> {
157        let mut tasks = Vec::new();
158        if self.state.cfg.merge_enabled {
159            let state = self.state.clone();
160            tasks.push(tokio::spawn(async move {
161                let every = state.cfg.claude_status_interval;
162                loop {
163                    let status = state.merger.check_status().await;
164                    state.write().claude_status = status;
165                    tokio::time::sleep(every).await;
166                }
167            }));
168        }
169        if !self.state.cfg.backup_dir.is_empty() {
170            let state = self.state.clone();
171            tasks.push(tokio::spawn(async move {
172                let every = state.cfg.backup_interval;
173                loop {
174                    // VACUUM INTO can take a while on a large database and
175                    // holds the store lock, so it stays off the async
176                    // worker threads.
177                    let s = state.clone();
178                    let _ = tokio::task::spawn_blocking(move || run_backup(&s)).await;
179                    tokio::time::sleep(every).await;
180                }
181            }));
182        }
183        tasks
184    }
185
186    /// Binds `cfg.addr` and serves until SIGTERM or ctrl-c, then shuts down
187    /// gracefully so an in-flight merge isn't cut off mid-write.
188    pub async fn serve(&self) -> Result<()> {
189        let listener = TcpListener::bind(&self.state.cfg.addr)
190            .await
191            .with_context(|| format!("binding {}", self.state.cfg.addr))?;
192        eprintln!(
193            "recall server listening on {} (db: {})",
194            self.state.cfg.addr, self.state.cfg.db_path
195        );
196        self.serve_with_shutdown(listener, shutdown_signal()).await
197    }
198
199    /// Serves on an already-bound listener until `shutdown` resolves.
200    pub async fn serve_with_shutdown<F>(&self, listener: TcpListener, shutdown: F) -> Result<()>
201    where
202        F: Future<Output = ()> + Send + 'static,
203    {
204        let tasks = self.start_background();
205        let result = axum::serve(
206            listener,
207            self.router()
208                .into_make_service_with_connect_info::<SocketAddr>(),
209        )
210        .with_graceful_shutdown(shutdown)
211        .await;
212        for task in tasks {
213            task.abort();
214        }
215        result.map_err(Into::into)
216    }
217}
218
219fn run_backup(state: &AppState) {
220    if state.cfg.backup_dir.is_empty() {
221        return;
222    }
223    match state
224        .store
225        .backup(&state.cfg.backup_dir, state.cfg.backup_keep)
226    {
227        Ok(dest) => {
228            state.write().last_backup_at = now();
229            eprintln!("backup written: {}", dest.display());
230        }
231        Err(e) => eprintln!("backup failed: {e:#}"),
232    }
233}
234
235async fn shutdown_signal() {
236    let ctrl_c = async {
237        let _ = tokio::signal::ctrl_c().await;
238    };
239    #[cfg(unix)]
240    let terminate = async {
241        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
242            Ok(mut sig) => {
243                sig.recv().await;
244            }
245            Err(_) => std::future::pending::<()>().await,
246        }
247    };
248    #[cfg(not(unix))]
249    let terminate = std::future::pending::<()>();
250
251    tokio::select! {
252        _ = ctrl_c => {}
253        _ = terminate => {}
254    }
255}