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!("{clean_path}.txt")
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!("{clean_path}.html"), "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(state.clone()))
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
403async fn health_check() -> axum::Json<serde_json::Value> {
404 axum::Json(serde_json::json!({
405 "status": "ok",
406 "timestamp": chrono::Utc::now().to_rfc3339(),
407 "server": "routa-server",
408 "version": env!("CARGO_PKG_VERSION"),
409 }))
410}
411
412#[cfg(test)]
413mod tests {
414 use super::resolve_static_target;
415
416 #[test]
417 fn resolves_workspace_overview_placeholder() {
418 let (target, content_type) = resolve_static_target("/workspace/default");
419 assert_eq!(target, "workspace/__placeholder__.html");
420 assert_eq!(content_type, "text/html; charset=utf-8");
421 }
422
423 #[test]
424 fn resolves_workspace_kanban_placeholder() {
425 let (target, content_type) = resolve_static_target("/workspace/default/kanban");
426 assert_eq!(target, "workspace/__placeholder__/kanban.html");
427 assert_eq!(content_type, "text/html; charset=utf-8");
428 }
429
430 #[test]
431 fn resolves_workspace_team_placeholder() {
432 let (target, content_type) = resolve_static_target("/workspace/default/team");
433 assert_eq!(target, "workspace/__placeholder__/team.html");
434 assert_eq!(content_type, "text/html; charset=utf-8");
435 }
436
437 #[test]
438 fn resolves_workspace_team_root_tree_placeholder() {
439 let (target, content_type) =
440 resolve_static_target("/workspace/default/team/__next._tree.txt");
441 assert_eq!(target, "workspace/__placeholder__/team/__next._tree.txt");
442 assert_eq!(content_type, "text/x-component; charset=utf-8");
443 }
444
445 #[test]
446 fn resolves_workspace_team_run_placeholder() {
447 let (target, content_type) = resolve_static_target("/workspace/default/team/session-123");
448 assert_eq!(
449 target,
450 "workspace/__placeholder__/team/__placeholder__.html"
451 );
452 assert_eq!(content_type, "text/html; charset=utf-8");
453 }
454
455 #[test]
456 fn resolves_workspace_team_run_tree_placeholder() {
457 let (target, content_type) =
458 resolve_static_target("/workspace/default/team/session-123/__next._tree.txt");
459 assert_eq!(
460 target,
461 "workspace/__placeholder__/team/__placeholder__/__next._tree.txt"
462 );
463 assert_eq!(content_type, "text/x-component; charset=utf-8");
464 }
465
466 #[test]
467 fn resolves_workspace_session_placeholder() {
468 let (target, content_type) =
469 resolve_static_target("/workspace/default/sessions/session-123");
470 assert_eq!(
471 target,
472 "workspace/__placeholder__/sessions/__placeholder__.html"
473 );
474 assert_eq!(content_type, "text/html; charset=utf-8");
475 }
476
477 #[test]
478 fn resolves_workspace_team_rsc_placeholder() {
479 let (target, content_type) =
480 resolve_static_target("/workspace/default/team/session-123.txt");
481 assert_eq!(target, "workspace/__placeholder__/team/__placeholder__.txt");
482 assert_eq!(content_type, "text/x-component; charset=utf-8");
483 }
484
485 #[test]
486 fn resolves_workspace_reposlide_placeholder() {
487 let (target, content_type) =
488 resolve_static_target("/workspace/ws-1/codebases/cb-1/reposlide");
489 assert_eq!(
490 target,
491 "workspace/__placeholder__/codebases/__placeholder__/reposlide.html"
492 );
493 assert_eq!(content_type, "text/html; charset=utf-8");
494 }
495}