1pub use routa_core::acp;
20pub use routa_core::db;
21pub use routa_core::error;
22pub use routa_core::events;
23pub use routa_core::git;
24pub use routa_core::mcp;
25pub use routa_core::models;
26pub use routa_core::orchestration;
27pub use routa_core::rpc;
28pub use routa_core::sandbox;
29pub use routa_core::shell_env;
30pub use routa_core::skills;
31pub use routa_core::state;
32pub use routa_core::store;
33pub use routa_core::tools;
34
35pub use routa_core::{AppState, AppStateInner, Database, ServerError};
37
38pub mod api;
41mod application;
42
43use std::net::SocketAddr;
46use std::sync::Arc;
47
48use axum::Router;
49use tower_http::cors::{Any, CorsLayer};
50use tower_http::trace::TraceLayer;
51
52pub struct ServerConfig {
54 pub host: String,
55 pub port: u16,
56 pub db_path: String,
57 pub static_dir: Option<String>,
60}
61
62impl Default for ServerConfig {
63 fn default() -> Self {
64 Self {
65 host: "127.0.0.1".to_string(),
66 port: 3210,
67 db_path: "routa.db".to_string(),
68 static_dir: None,
69 }
70 }
71}
72
73pub async fn create_app_state(db_path: &str) -> Result<state::AppState, String> {
78 let db = db::Database::open(db_path).map_err(|e| format!("Failed to open database: {}", e))?;
79
80 let state: state::AppState = Arc::new(state::AppStateInner::new(db));
81
82 state
84 .workspace_store
85 .ensure_default()
86 .await
87 .map_err(|e| format!("Failed to initialize default workspace: {}", e))?;
88
89 let cwd = std::env::current_dir()
91 .map(|p| p.to_string_lossy().to_string())
92 .unwrap_or_else(|_| ".".to_string());
93 state.skill_registry.reload(&cwd);
94
95 api::polling::start_polling_if_enabled();
97
98 Ok(state)
99}
100
101fn resolve_static_target(path: &str) -> (String, &'static str) {
102 let is_rsc_request = path.ends_with(".txt");
103
104 if path.starts_with("/workspace/") {
105 let clean_path = path.trim_end_matches(".txt");
106 let segments: Vec<&str> = clean_path
107 .trim_start_matches("/workspace/")
108 .split('/')
109 .filter(|s| !s.is_empty())
110 .collect();
111
112 let ext = if is_rsc_request { "txt" } else { "html" };
113 let content = if is_rsc_request {
114 "text/x-component; charset=utf-8"
115 } else {
116 "text/html; charset=utf-8"
117 };
118 let placeholder_with_suffix = |base: &str, suffix: &[&str]| {
119 if suffix.is_empty() {
120 format!("{}.{}", base, ext)
121 } else {
122 format!("{}/{}.{}", base, suffix.join("/"), ext)
123 }
124 };
125
126 if segments.len() >= 3 && segments[1] == "sessions" {
127 let suffix = if segments.len() > 3 {
128 &segments[3..]
129 } else {
130 &[][..]
131 };
132 (
133 placeholder_with_suffix(
134 "workspace/__placeholder__/sessions/__placeholder__",
135 suffix,
136 ),
137 content,
138 )
139 } else if segments.len() >= 3 && segments[1] == "team" {
140 let suffix = if segments.len() > 3 {
141 &segments[3..]
142 } else {
143 &[][..]
144 };
145 (
146 placeholder_with_suffix("workspace/__placeholder__/team/__placeholder__", suffix),
147 content,
148 )
149 } else if segments.len() >= 2 && segments[1] == "kanban" {
150 let suffix = if segments.len() > 2 {
151 &segments[2..]
152 } else {
153 &[][..]
154 };
155 (
156 placeholder_with_suffix("workspace/__placeholder__/kanban", suffix),
157 content,
158 )
159 } else if segments.len() >= 2 && segments[1] == "team" {
160 let suffix = if segments.len() > 2 {
161 &segments[2..]
162 } else {
163 &[][..]
164 };
165 (
166 placeholder_with_suffix("workspace/__placeholder__/team", suffix),
167 content,
168 )
169 } else if !segments.is_empty() {
170 let suffix = if segments.len() > 1 {
171 &segments[1..]
172 } else {
173 &[][..]
174 };
175 (
176 placeholder_with_suffix("workspace/__placeholder__", suffix),
177 content,
178 )
179 } else {
180 ("index.html".to_string(), "text/html; charset=utf-8")
181 }
182 } else {
183 let clean_path = path.trim_start_matches('/').trim_end_matches('/');
184 if is_rsc_request {
185 (
186 if clean_path.is_empty() {
187 "index.txt".to_string()
188 } else {
189 format!("{}.txt", clean_path)
190 },
191 "text/x-component; charset=utf-8",
192 )
193 } else if clean_path.is_empty() {
194 ("index.html".to_string(), "text/html; charset=utf-8")
195 } else {
196 (format!("{}.html", clean_path), "text/html; charset=utf-8")
197 }
198 }
199}
200
201pub async fn start_server(config: ServerConfig) -> Result<SocketAddr, String> {
205 let _ = tracing_subscriber::fmt()
207 .with_env_filter(
208 tracing_subscriber::EnvFilter::try_from_default_env()
209 .unwrap_or_else(|_| "routa_core=info,routa_server=info,tower_http=info".into()),
210 )
211 .try_init();
212
213 let full_path = shell_env::full_path();
216 std::env::set_var("PATH", full_path);
217
218 tracing::info!(
219 "Starting Routa backend server on {}:{}",
220 config.host,
221 config.port
222 );
223
224 std::env::set_var(
225 "ROUTA_SERVER_URL",
226 format!("http://{}:{}", config.host, config.port),
227 );
228
229 let state = create_app_state(&config.db_path).await?;
230
231 start_server_with_state(config, state).await
232}
233
234pub async fn start_server_with_state(
239 config: ServerConfig,
240 state: state::AppState,
241) -> Result<SocketAddr, String> {
242 std::env::set_var(
243 "ROUTA_SERVER_URL",
244 format!("http://{}:{}", config.host, config.port),
245 );
246
247 let cors = CorsLayer::new()
249 .allow_origin(Any)
250 .allow_methods(Any)
251 .allow_headers(Any);
252
253 let mut app = Router::new()
254 .merge(api::api_router())
255 .route("/api/health", axum::routing::get(health_check))
256 .layer(cors.clone())
257 .layer(TraceLayer::new_for_http())
258 .with_state(state);
259
260 if let Some(ref static_dir) = config.static_dir {
262 let static_path = std::path::Path::new(static_dir);
263 if static_path.exists() && static_path.is_dir() {
264 tracing::info!("Serving static frontend from: {}", static_dir);
265
266 let static_dir_clone = static_dir.clone();
280 let fallback_service =
281 tower::service_fn(move |req: axum::http::Request<axum::body::Body>| {
282 let static_dir = static_dir_clone.clone();
283 async move {
284 let path = req.uri().path();
285 let is_rsc_request = path.ends_with(".txt");
286 let (target_file, content_type) = resolve_static_target(path);
287
288 let file_path = std::path::Path::new(&static_dir).join(&target_file);
289 tracing::debug!(
290 "SPA fallback: {} -> {} (rsc={})",
291 path,
292 file_path.to_string_lossy(),
293 is_rsc_request
294 );
295
296 let workspace_segments: Vec<&str> = path
297 .trim_start_matches("/workspace/")
298 .trim_end_matches(".txt")
299 .split('/')
300 .filter(|segment| !segment.is_empty())
301 .collect();
302 let should_rewrite_workspace_placeholder = path.starts_with("/workspace/")
303 && !workspace_segments.is_empty()
304 && workspace_segments
305 .get(1)
306 .map(|segment| *segment != "sessions")
307 .unwrap_or(true);
308 let actual_workspace_id = workspace_segments
309 .first()
310 .copied()
311 .unwrap_or("__placeholder__");
312
313 let response = match tokio::fs::read(&file_path).await {
314 Ok(contents) => {
315 let body = if should_rewrite_workspace_placeholder {
316 let rewritten = String::from_utf8_lossy(&contents)
317 .replace("__placeholder__", actual_workspace_id);
318 axum::body::Body::from(rewritten)
319 } else {
320 axum::body::Body::from(contents)
321 };
322
323 axum::http::Response::builder()
324 .status(axum::http::StatusCode::OK)
325 .header("content-type", content_type)
326 .body(body)
327 .unwrap()
328 }
329 Err(_) => {
330 let index_path =
332 std::path::Path::new(&static_dir).join("index.html");
333 match tokio::fs::read(&index_path).await {
334 Ok(contents) => axum::http::Response::builder()
335 .status(axum::http::StatusCode::OK)
336 .header("content-type", "text/html; charset=utf-8")
337 .body(axum::body::Body::from(contents))
338 .unwrap(),
339 Err(_) => axum::http::Response::builder()
340 .status(axum::http::StatusCode::NOT_FOUND)
341 .body(axum::body::Body::from("Not found"))
342 .unwrap(),
343 }
344 }
345 };
346 Ok::<_, std::convert::Infallible>(response)
347 }
348 });
349
350 let serve_dir =
351 tower_http::services::ServeDir::new(static_dir).fallback(fallback_service);
352 app = app.fallback_service(serve_dir);
353 } else {
354 tracing::warn!(
355 "Static directory not found: {}. Frontend won't be served.",
356 static_dir
357 );
358 }
359 }
360
361 let addr: SocketAddr = format!("{}:{}", config.host, config.port)
363 .parse()
364 .map_err(|e| format!("Invalid address: {}", e))?;
365
366 let listener = tokio::net::TcpListener::bind(addr)
367 .await
368 .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
369
370 let local_addr = listener
371 .local_addr()
372 .map_err(|e| format!("Failed to get local address: {}", e))?;
373
374 tracing::info!("Routa backend server listening on {}", local_addr);
375
376 tokio::spawn(async move {
378 if let Err(e) = axum::serve(listener, app).await {
379 tracing::error!("Server error: {}", e);
380 }
381 });
382
383 Ok(local_addr)
384}
385
386#[cfg(test)]
387mod tests {
388 use super::resolve_static_target;
389
390 #[test]
391 fn resolves_workspace_overview_placeholder() {
392 let (target, content_type) = resolve_static_target("/workspace/default");
393 assert_eq!(target, "workspace/__placeholder__.html");
394 assert_eq!(content_type, "text/html; charset=utf-8");
395 }
396
397 #[test]
398 fn resolves_workspace_kanban_placeholder() {
399 let (target, content_type) = resolve_static_target("/workspace/default/kanban");
400 assert_eq!(target, "workspace/__placeholder__/kanban.html");
401 assert_eq!(content_type, "text/html; charset=utf-8");
402 }
403
404 #[test]
405 fn resolves_workspace_team_placeholder() {
406 let (target, content_type) = resolve_static_target("/workspace/default/team");
407 assert_eq!(target, "workspace/__placeholder__/team.html");
408 assert_eq!(content_type, "text/html; charset=utf-8");
409 }
410
411 #[test]
412 fn resolves_workspace_team_run_placeholder() {
413 let (target, content_type) = resolve_static_target("/workspace/default/team/session-123");
414 assert_eq!(
415 target,
416 "workspace/__placeholder__/team/__placeholder__.html"
417 );
418 assert_eq!(content_type, "text/html; charset=utf-8");
419 }
420
421 #[test]
422 fn resolves_workspace_session_placeholder() {
423 let (target, content_type) =
424 resolve_static_target("/workspace/default/sessions/session-123");
425 assert_eq!(
426 target,
427 "workspace/__placeholder__/sessions/__placeholder__.html"
428 );
429 assert_eq!(content_type, "text/html; charset=utf-8");
430 }
431
432 #[test]
433 fn resolves_workspace_team_rsc_placeholder() {
434 let (target, content_type) =
435 resolve_static_target("/workspace/default/team/session-123.txt");
436 assert_eq!(target, "workspace/__placeholder__/team/__placeholder__.txt");
437 assert_eq!(content_type, "text/x-component; charset=utf-8");
438 }
439}
440
441async fn health_check() -> axum::Json<serde_json::Value> {
442 axum::Json(serde_json::json!({
443 "status": "ok",
444 "timestamp": chrono::Utc::now().to_rfc3339(),
445 "server": "routa-server",
446 "version": env!("CARGO_PKG_VERSION"),
447 }))
448}