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 let is_next_metadata_segment = |segment: &str| segment.starts_with("__next.");
126
127 if segments.len() >= 3 && segments[1] == "sessions" {
128 let suffix = if segments.len() > 3 {
129 &segments[3..]
130 } else {
131 &[][..]
132 };
133 (
134 placeholder_with_suffix(
135 "workspace/__placeholder__/sessions/__placeholder__",
136 suffix,
137 ),
138 content,
139 )
140 } else if segments.len() >= 3
141 && segments[1] == "team"
142 && !is_next_metadata_segment(segments[2])
143 {
144 let suffix = if segments.len() > 3 {
145 &segments[3..]
146 } else {
147 &[][..]
148 };
149 (
150 placeholder_with_suffix("workspace/__placeholder__/team/__placeholder__", suffix),
151 content,
152 )
153 } else if segments.len() >= 2 && segments[1] == "kanban" {
154 let suffix = if segments.len() > 2 {
155 &segments[2..]
156 } else {
157 &[][..]
158 };
159 (
160 placeholder_with_suffix("workspace/__placeholder__/kanban", suffix),
161 content,
162 )
163 } else if segments.len() >= 2 && segments[1] == "team" {
164 let suffix = if segments.len() > 2 {
165 &segments[2..]
166 } else {
167 &[][..]
168 };
169 (
170 placeholder_with_suffix("workspace/__placeholder__/team", suffix),
171 content,
172 )
173 } else if segments.len() >= 4 && segments[1] == "codebases" && segments[3] == "reposlide" {
174 let suffix = if segments.len() > 4 {
175 &segments[4..]
176 } else {
177 &[][..]
178 };
179 (
180 placeholder_with_suffix(
181 "workspace/__placeholder__/codebases/__placeholder__/reposlide",
182 suffix,
183 ),
184 content,
185 )
186 } else if !segments.is_empty() {
187 let suffix = if segments.len() > 1 {
188 &segments[1..]
189 } else {
190 &[][..]
191 };
192 (
193 placeholder_with_suffix("workspace/__placeholder__", suffix),
194 content,
195 )
196 } else {
197 ("index.html".to_string(), "text/html; charset=utf-8")
198 }
199 } else {
200 let clean_path = path.trim_start_matches('/').trim_end_matches('/');
201 if is_rsc_request {
202 (
203 if clean_path.is_empty() {
204 "index.txt".to_string()
205 } else {
206 format!("{}.txt", clean_path)
207 },
208 "text/x-component; charset=utf-8",
209 )
210 } else if clean_path.is_empty() {
211 ("index.html".to_string(), "text/html; charset=utf-8")
212 } else {
213 (format!("{}.html", clean_path), "text/html; charset=utf-8")
214 }
215 }
216}
217
218pub async fn start_server(config: ServerConfig) -> Result<SocketAddr, String> {
222 let _ = tracing_subscriber::fmt()
224 .with_env_filter(
225 tracing_subscriber::EnvFilter::try_from_default_env()
226 .unwrap_or_else(|_| "routa_core=info,routa_server=info,tower_http=info".into()),
227 )
228 .try_init();
229
230 let full_path = shell_env::full_path();
233 std::env::set_var("PATH", full_path);
234
235 tracing::info!(
236 "Starting Routa backend server on {}:{}",
237 config.host,
238 config.port
239 );
240
241 std::env::set_var(
242 "ROUTA_SERVER_URL",
243 format!("http://{}:{}", config.host, config.port),
244 );
245
246 let state = create_app_state(&config.db_path).await?;
247
248 start_server_with_state(config, state).await
249}
250
251pub async fn start_server_with_state(
256 config: ServerConfig,
257 state: state::AppState,
258) -> Result<SocketAddr, String> {
259 std::env::set_var(
260 "ROUTA_SERVER_URL",
261 format!("http://{}:{}", config.host, config.port),
262 );
263
264 let cors = CorsLayer::new()
266 .allow_origin(Any)
267 .allow_methods(Any)
268 .allow_headers(Any);
269
270 let mut app = Router::new()
271 .merge(api::api_router())
272 .route("/api/health", axum::routing::get(health_check))
273 .layer(cors.clone())
274 .layer(TraceLayer::new_for_http())
275 .with_state(state);
276
277 if let Some(ref static_dir) = config.static_dir {
279 let static_path = std::path::Path::new(static_dir);
280 if static_path.exists() && static_path.is_dir() {
281 tracing::info!("Serving static frontend from: {}", static_dir);
282
283 let static_dir_clone = static_dir.clone();
297 let fallback_service =
298 tower::service_fn(move |req: axum::http::Request<axum::body::Body>| {
299 let static_dir = static_dir_clone.clone();
300 async move {
301 let path = req.uri().path();
302 let is_rsc_request = path.ends_with(".txt");
303 let (target_file, content_type) = resolve_static_target(path);
304
305 let file_path = std::path::Path::new(&static_dir).join(&target_file);
306 tracing::debug!(
307 "SPA fallback: {} -> {} (rsc={})",
308 path,
309 file_path.to_string_lossy(),
310 is_rsc_request
311 );
312
313 let workspace_segments: Vec<&str> = path
314 .trim_start_matches("/workspace/")
315 .trim_end_matches(".txt")
316 .split('/')
317 .filter(|segment| !segment.is_empty())
318 .collect();
319 let should_rewrite_workspace_placeholder = path.starts_with("/workspace/")
320 && !workspace_segments.is_empty()
321 && workspace_segments
322 .get(1)
323 .map(|segment| *segment != "sessions")
324 .unwrap_or(true);
325 let actual_workspace_id = workspace_segments
326 .first()
327 .copied()
328 .unwrap_or("__placeholder__");
329
330 let response = match tokio::fs::read(&file_path).await {
331 Ok(contents) => {
332 let body = if should_rewrite_workspace_placeholder {
333 let rewritten = String::from_utf8_lossy(&contents)
334 .replace("__placeholder__", actual_workspace_id);
335 axum::body::Body::from(rewritten)
336 } else {
337 axum::body::Body::from(contents)
338 };
339
340 axum::http::Response::builder()
341 .status(axum::http::StatusCode::OK)
342 .header("content-type", content_type)
343 .body(body)
344 .unwrap()
345 }
346 Err(_) => {
347 let index_path =
349 std::path::Path::new(&static_dir).join("index.html");
350 match tokio::fs::read(&index_path).await {
351 Ok(contents) => axum::http::Response::builder()
352 .status(axum::http::StatusCode::OK)
353 .header("content-type", "text/html; charset=utf-8")
354 .body(axum::body::Body::from(contents))
355 .unwrap(),
356 Err(_) => axum::http::Response::builder()
357 .status(axum::http::StatusCode::NOT_FOUND)
358 .body(axum::body::Body::from("Not found"))
359 .unwrap(),
360 }
361 }
362 };
363 Ok::<_, std::convert::Infallible>(response)
364 }
365 });
366
367 let serve_dir =
368 tower_http::services::ServeDir::new(static_dir).fallback(fallback_service);
369 app = app.fallback_service(serve_dir);
370 } else {
371 tracing::warn!(
372 "Static directory not found: {}. Frontend won't be served.",
373 static_dir
374 );
375 }
376 }
377
378 let addr: SocketAddr = format!("{}:{}", config.host, config.port)
380 .parse()
381 .map_err(|e| format!("Invalid address: {}", e))?;
382
383 let listener = tokio::net::TcpListener::bind(addr)
384 .await
385 .map_err(|e| format!("Failed to bind to {}: {}", addr, e))?;
386
387 let local_addr = listener
388 .local_addr()
389 .map_err(|e| format!("Failed to get local address: {}", e))?;
390
391 tracing::info!("Routa backend server listening on {}", local_addr);
392
393 tokio::spawn(async move {
395 if let Err(e) = axum::serve(listener, app).await {
396 tracing::error!("Server error: {}", e);
397 }
398 });
399
400 Ok(local_addr)
401}
402
403#[cfg(test)]
404mod tests {
405 use super::resolve_static_target;
406
407 #[test]
408 fn resolves_workspace_overview_placeholder() {
409 let (target, content_type) = resolve_static_target("/workspace/default");
410 assert_eq!(target, "workspace/__placeholder__.html");
411 assert_eq!(content_type, "text/html; charset=utf-8");
412 }
413
414 #[test]
415 fn resolves_workspace_kanban_placeholder() {
416 let (target, content_type) = resolve_static_target("/workspace/default/kanban");
417 assert_eq!(target, "workspace/__placeholder__/kanban.html");
418 assert_eq!(content_type, "text/html; charset=utf-8");
419 }
420
421 #[test]
422 fn resolves_workspace_team_placeholder() {
423 let (target, content_type) = resolve_static_target("/workspace/default/team");
424 assert_eq!(target, "workspace/__placeholder__/team.html");
425 assert_eq!(content_type, "text/html; charset=utf-8");
426 }
427
428 #[test]
429 fn resolves_workspace_team_root_tree_placeholder() {
430 let (target, content_type) = resolve_static_target("/workspace/default/team/__next._tree.txt");
431 assert_eq!(target, "workspace/__placeholder__/team/__next._tree.txt");
432 assert_eq!(content_type, "text/x-component; charset=utf-8");
433 }
434
435 #[test]
436 fn resolves_workspace_team_run_placeholder() {
437 let (target, content_type) = resolve_static_target("/workspace/default/team/session-123");
438 assert_eq!(
439 target,
440 "workspace/__placeholder__/team/__placeholder__.html"
441 );
442 assert_eq!(content_type, "text/html; charset=utf-8");
443 }
444
445 #[test]
446 fn resolves_workspace_team_run_tree_placeholder() {
447 let (target, content_type) =
448 resolve_static_target("/workspace/default/team/session-123/__next._tree.txt");
449 assert_eq!(
450 target,
451 "workspace/__placeholder__/team/__placeholder__/__next._tree.txt"
452 );
453 assert_eq!(content_type, "text/x-component; charset=utf-8");
454 }
455
456 #[test]
457 fn resolves_workspace_session_placeholder() {
458 let (target, content_type) =
459 resolve_static_target("/workspace/default/sessions/session-123");
460 assert_eq!(
461 target,
462 "workspace/__placeholder__/sessions/__placeholder__.html"
463 );
464 assert_eq!(content_type, "text/html; charset=utf-8");
465 }
466
467 #[test]
468 fn resolves_workspace_team_rsc_placeholder() {
469 let (target, content_type) =
470 resolve_static_target("/workspace/default/team/session-123.txt");
471 assert_eq!(target, "workspace/__placeholder__/team/__placeholder__.txt");
472 assert_eq!(content_type, "text/x-component; charset=utf-8");
473 }
474
475 #[test]
476 fn resolves_workspace_reposlide_placeholder() {
477 let (target, content_type) =
478 resolve_static_target("/workspace/ws-1/codebases/cb-1/reposlide");
479 assert_eq!(
480 target,
481 "workspace/__placeholder__/codebases/__placeholder__/reposlide.html"
482 );
483 assert_eq!(content_type, "text/html; charset=utf-8");
484 }
485}
486
487async fn health_check() -> axum::Json<serde_json::Value> {
488 axum::Json(serde_json::json!({
489 "status": "ok",
490 "timestamp": chrono::Utc::now().to_rfc3339(),
491 "server": "routa-server",
492 "version": env!("CARGO_PKG_VERSION"),
493 }))
494}