recall_server/server/
mod.rs1use std::future::Future;
20use std::net::SocketAddr;
21use std::sync::{Arc, PoisonError, RwLock};
22
23use anyhow::{Context, Result};
24use axum::extract::DefaultBodyLimit;
25use 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
48const 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
77pub struct Server {
79 state: Arc<AppState>,
80}
81
82impl Server {
83 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 pub fn router(&self) -> Router {
107 let state = self.state.clone();
108 Router::new()
109 .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 .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 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 pub fn claude_status(&self) -> Status {
136 self.state.read().claude_status.clone()
137 }
138
139 pub fn set_claude_status(&self, status: Status) {
144 self.state.write().claude_status = status;
145 }
146
147 pub fn run_backup(&self) {
150 run_backup(&self.state);
151 }
152
153 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 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 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 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}