use axum::extract::ws::WebSocketUpgrade;
use axum::extract::{Path, Query, State};
use axum::http::{Method, StatusCode};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{middleware, Json, Router};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
fn sanitize_relative_subpath(path: &str) -> Option<PathBuf> {
let path = path.trim();
if path.is_empty() {
return Some(PathBuf::new());
}
let p = std::path::Path::new(path);
if p.is_absolute() {
return None;
}
let mut out = PathBuf::new();
for comp in p.components() {
match comp {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !out.pop() {
return None;
}
}
std::path::Component::Normal(c) => {
out.push(c);
}
_ => return None,
}
}
Some(out)
}
fn path_under_base(base: &std::path::Path, user_path: &str) -> Option<PathBuf> {
let sanitized = sanitize_relative_subpath(user_path)?;
Some(base.join(sanitized))
}
fn resolve_path_under_root(root: &std::path::Path, user_path: &str) -> Option<PathBuf> {
let user_path = user_path.trim();
if user_path.is_empty() {
return None;
}
let p = std::path::Path::new(user_path);
if p.is_absolute() {
let canon_root = root.canonicalize().ok()?;
let canon_p = p.canonicalize().ok()?;
if canon_p.starts_with(&canon_root) {
Some(canon_p)
} else {
None
}
} else {
path_under_base(root, user_path)
}
}
fn path_is_within_root(root: &std::path::Path, path: &std::path::Path) -> bool {
if let (Ok(canon_root), Ok(canon_path)) = (root.canonicalize(), path.canonicalize()) {
return canon_path.starts_with(&canon_root);
}
path.starts_with(root)
}
fn projects_root() -> PathBuf {
let s = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(s)
}
use std::time::Duration;
use tokio::sync::{broadcast, oneshot, RwLock};
use tower_http::cors::{Any, CorsLayer};
use tower_http::normalize_path::NormalizePathLayer;
use super::agent_runner;
use super::diagnostics;
use super::lsp_bridge;
use super::lsp_client::{self, SecondLsp};
use super::orchestration::{discover_workspace, OrchestrationResponse};
use super::run_backend::{
resolve_run_config, run_command_blocking, spawn_run_streaming, ReadFileRequest,
RunCommandRequest, WriteFileRequest,
};
use super::symbols;
struct JobEntry {
output_tx: broadcast::Sender<String>,
kill_tx: oneshot::Sender<()>,
}
use super::agent_runner::emit_activity;
#[derive(Clone)]
struct AppState {
workspace_root: PathBuf,
orchestration: Arc<RwLock<Option<OrchestrationResponse>>>,
jobs: Arc<RwLock<std::collections::HashMap<String, JobEntry>>>,
events_tx: broadcast::Sender<String>,
second_lsp_by_cargo_root: Arc<std::sync::Mutex<HashMap<PathBuf, SecondLsp>>>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceQuery {
#[serde(default)]
pub workspace: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListFilesQuery {
#[serde(default)]
pub workspace: Option<String>,
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SearchRequest {
query: String,
#[serde(default)]
path: Option<String>,
#[serde(default)]
case_sensitive: bool,
}
#[derive(Debug, Serialize)]
struct SearchResultItem {
path: String,
line_number: u32,
line: String,
}
fn search_workspace_blocking(
root: &std::path::Path,
subpath: &str,
query: &str,
case_sensitive: bool,
) -> Vec<SearchResultItem> {
let dir = if subpath.is_empty() {
root.to_path_buf()
} else {
match path_under_base(root, subpath) {
Some(d) => d,
None => return Vec::new(),
}
};
if !dir.exists() || !dir.is_dir() {
return Vec::new();
}
let query_lower = if case_sensitive {
String::new()
} else {
query.to_lowercase()
};
let query = query.as_bytes();
let mut results = Vec::new();
let skip_dirs = ["node_modules", "target", ".git", ".venv", "venv", "dist"];
let text_extensions = [
"dal", "rs", "js", "ts", "jsx", "tsx", "json", "toml", "md", "txt", "html", "css", "yml",
"yaml", "sh", "py",
];
let mut stack = vec![(dir, subpath.to_string())];
while let Some((dir_path, rel_prefix)) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&dir_path) else {
continue;
};
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') && name != ".dal" {
continue;
}
let meta = match e.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let rel = if rel_prefix.is_empty() {
name.clone()
} else {
format!("{}/{}", rel_prefix, name)
};
if meta.is_dir() {
if skip_dirs.contains(&name.as_str()) {
continue;
}
stack.push((dir_path.join(&name), rel));
continue;
}
let ext = std::path::Path::new(&name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if !text_extensions.contains(&ext) {
continue;
}
let full = dir_path.join(&name);
let content = match std::fs::read(&full) {
Ok(c) => c,
Err(_) => continue,
};
let Ok(text) = std::str::from_utf8(&content) else {
continue;
};
for (i, line) in text.lines().enumerate() {
let line_number = (i + 1) as u32;
let matched = if case_sensitive {
line.contains(std::str::from_utf8(query).unwrap_or(""))
} else {
line.to_lowercase().contains(&query_lower)
};
if matched {
results.push(SearchResultItem {
path: rel.clone(),
line_number,
line: line.to_string(),
});
}
}
}
}
results
}
async fn get_orchestration(
State(state): State<AppState>,
Query(query): Query<WorkspaceQuery>,
) -> impl IntoResponse {
let root = match &query.workspace {
Some(ws) if !ws.trim().is_empty() => {
let ws = ws.trim();
let p = PathBuf::from(ws);
if p.is_absolute() {
if let (Ok(canon_root), Ok(canon_p)) =
(state.workspace_root.canonicalize(), p.canonicalize())
{
if canon_p.starts_with(&canon_root) {
canon_p
} else {
state.workspace_root.clone()
}
} else {
state.workspace_root.clone()
}
} else {
path_under_base(&state.workspace_root, ws)
.unwrap_or_else(|| state.workspace_root.clone())
}
}
_ => state.workspace_root.clone(),
};
if !path_is_within_root(&state.workspace_root, &root) || !root.exists() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Workspace path does not exist"})),
)
.into_response();
}
let resp = discover_workspace(&root);
*state.orchestration.write().await = Some(resp.clone());
let root_uri = lsp_client::path_to_file_uri(&root);
(
StatusCode::OK,
Json(serde_json::json!({
"root_uri": root_uri,
"projects": resp.projects,
"run_configs": resp.run_configs,
"scripts": resp.scripts,
"workflows": resp.workflows,
"agent_evolve": resp.agent_evolve
})),
)
.into_response()
}
async fn get_files(
State(state): State<AppState>,
Query(query): Query<ListFilesQuery>,
) -> impl IntoResponse {
let root = match &query.workspace {
Some(ws) if !ws.trim().is_empty() => {
let ws = ws.trim();
let p = PathBuf::from(ws);
if p.is_absolute() {
if let (Ok(canon_root), Ok(canon_p)) =
(state.workspace_root.canonicalize(), p.canonicalize())
{
if canon_p.starts_with(&canon_root) {
canon_p
} else {
state.workspace_root.clone()
}
} else {
state.workspace_root.clone()
}
} else {
path_under_base(&state.workspace_root, ws)
.unwrap_or_else(|| state.workspace_root.clone())
}
}
_ => state.workspace_root.clone(),
};
let subpath = query.path.as_deref().unwrap_or("");
let dir = if subpath.is_empty() {
root.clone()
} else {
match path_under_base(&root, subpath) {
Some(d) => d,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid path"})),
)
.into_response();
}
}
};
if !path_is_within_root(&root, &dir) || !dir.exists() || !dir.is_dir() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Path does not exist or is not a directory"})),
)
.into_response();
}
let mut entries = Vec::new();
match std::fs::read_dir(&dir) {
Ok(rd) => {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') && name != ".dal" {
continue; }
if ["node_modules", "target", ".git", ".venv", "venv"].contains(&name.as_str()) {
continue; }
let meta = match e.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let is_dir = meta.is_dir();
let rel = if subpath.is_empty() {
name.clone()
} else {
format!("{}/{}", subpath, name)
};
entries.push(serde_json::json!({
"name": name,
"path": rel,
"is_dir": is_dir,
}));
}
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response()
}
}
entries.sort_by(|a, b| {
let a_dir = a["is_dir"].as_bool().unwrap_or(false);
let b_dir = b["is_dir"].as_bool().unwrap_or(false);
match (a_dir, b_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a["name"]
.as_str()
.unwrap_or("")
.cmp(b["name"].as_str().unwrap_or("")),
}
});
(
StatusCode::OK,
Json(serde_json::json!({"entries": entries})),
)
.into_response()
}
async fn post_search(
State(state): State<AppState>,
Query(query): Query<WorkspaceQuery>,
Json(req): Json<SearchRequest>,
) -> impl IntoResponse {
let root = query
.workspace
.as_ref()
.filter(|s| !s.trim().is_empty())
.and_then(|ws| {
let ws = ws.trim();
let p = PathBuf::from(ws);
if p.is_absolute() {
let (cr, cp) = (
state.workspace_root.canonicalize().ok()?,
p.canonicalize().ok()?,
);
if cp.starts_with(&cr) {
Some(cp)
} else {
None
}
} else {
path_under_base(&state.workspace_root, ws)
}
})
.unwrap_or_else(|| state.workspace_root.clone());
if !path_is_within_root(&state.workspace_root, &root) || !root.exists() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Workspace path does not exist"})),
)
.into_response();
}
let q = req.query.trim().to_string();
if q.is_empty() {
return (StatusCode::OK, Json(serde_json::json!({"results": []}))).into_response();
}
let subpath = req.path.clone().unwrap_or_default();
let root_clone = root.clone();
let case_sensitive = req.case_sensitive;
let results = match tokio::task::spawn_blocking(move || {
search_workspace_blocking(&root_clone, &subpath, &q, case_sensitive)
})
.await
{
Ok(r) => r,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response();
}
};
(
StatusCode::OK,
Json(serde_json::json!({"results": results})),
)
.into_response()
}
#[derive(Debug, Deserialize)]
struct RunConfigRequest {
config_id: String,
}
async fn post_run_stream(
State(state): State<AppState>,
Json(req): Json<RunConfigRequest>,
) -> impl IntoResponse {
let orch = state.orchestration.read().await;
let orch = match orch.as_ref() {
Some(o) => o,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Orchestration not loaded"})),
)
.into_response()
}
};
let (cmd, args, cwd) = match resolve_run_config(&req.config_id, &orch.run_configs) {
Some(x) => x,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Config not found"})),
)
.into_response()
}
};
let cmd = if cmd == "dal" {
super::run_backend::dal_binary_path()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "dal".to_string())
} else {
cmd
};
let cwd_path = Some(std::path::Path::new(&cwd));
match spawn_run_streaming(&cmd, &args, cwd_path) {
Ok((output_tx, kill_tx)) => {
let job_id = format!(
"run-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
);
state
.jobs
.write()
.await
.insert(job_id.clone(), JobEntry { output_tx, kill_tx });
emit_activity(
&state.events_tx,
"run_started",
serde_json::json!({ "job_id": job_id, "config_id": req.config_id }),
);
(
StatusCode::OK,
Json(serde_json::json!({
"job_id": job_id,
"config_id": req.config_id
})),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e})),
)
.into_response(),
}
}
async fn get_run_stream(
State(state): State<AppState>,
Path(job_id): Path<String>,
) -> impl IntoResponse {
let rx = {
let jobs = state.jobs.read().await;
match jobs.get(&job_id) {
Some(entry) => entry.output_tx.subscribe(),
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Job not found"})),
)
.into_response()
}
}
};
enum StreamState {
Receiving(broadcast::Receiver<String>),
Done,
}
let stream = futures_util::stream::unfold(StreamState::Receiving(rx), |state| async move {
match state {
StreamState::Done => None,
StreamState::Receiving(rx) => {
let mut rx = rx;
match rx.recv().await {
Ok(msg) => {
let event = Event::default().data(msg.clone());
let next = if msg == "[DONE]" {
StreamState::Done
} else {
StreamState::Receiving(rx)
};
Some((Ok::<_, std::convert::Infallible>(event), next))
}
Err(_) => None,
}
}
}
});
Sse::new(stream)
.keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keepalive"),
)
.into_response()
}
#[derive(Debug, Deserialize)]
struct StopJobRequest {
job_id: String,
}
async fn post_run_stop(
State(state): State<AppState>,
Json(req): Json<StopJobRequest>,
) -> impl IntoResponse {
let killed = {
let mut jobs = state.jobs.write().await;
if let Some(entry) = jobs.remove(&req.job_id) {
let _ = entry.kill_tx.send(());
true
} else {
false
}
};
if killed {
emit_activity(
&state.events_tx,
"run_stopped",
serde_json::json!({ "job_id": req.job_id }),
);
(
StatusCode::OK,
Json(serde_json::json!({"ok": true, "message": "Job stopped"})),
)
.into_response()
} else {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Job not found"})),
)
.into_response()
}
}
async fn post_run(
State(state): State<AppState>,
Json(req): Json<RunConfigRequest>,
) -> impl IntoResponse {
let orch = state.orchestration.read().await;
let orch = match orch.as_ref() {
Some(o) => o,
None => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Orchestration not loaded"})),
)
.into_response()
}
};
let (cmd, args, cwd) = match resolve_run_config(&req.config_id, &orch.run_configs) {
Some(x) => x,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Config not found"})),
)
.into_response()
}
};
let cmd = if cmd == "dal" {
super::run_backend::dal_binary_path()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "dal".to_string())
} else {
cmd
};
let cwd_path = Some(std::path::Path::new(&cwd));
match run_command_blocking(&cmd, &args, cwd_path) {
Ok((stdout, stderr, code)) => (
StatusCode::OK,
Json(serde_json::json!({
"job_id": format!("run-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis()),
"exit_code": code,
"stdout": stdout,
"stderr": stderr
})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e})),
)
.into_response(),
}
}
async fn post_agent_run_command(
State(state): State<AppState>,
Json(req): Json<RunCommandRequest>,
) -> impl IntoResponse {
let (cmd, args) = if req.args.is_empty() {
let parts: Vec<&str> = req.cmd.split_whitespace().collect();
if parts.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Empty command"})),
);
}
let cmd = parts[0].to_string();
let args: Vec<String> = parts[1..].iter().map(|s| (*s).to_string()).collect();
(cmd, args)
} else {
(req.cmd, req.args)
};
let cwd = req.cwd.as_ref().and_then(|s| {
let s = s.trim();
if s.is_empty() {
None
} else {
let p = PathBuf::from(s);
if p.is_absolute() {
state
.workspace_root
.canonicalize()
.ok()
.and_then(|cr| p.canonicalize().ok().filter(|cp| cp.starts_with(&cr)))
} else {
path_under_base(&state.workspace_root, s)
}
}
});
let cwd = cwd.unwrap_or_else(|| {
if cmd == "dal" && args.first().map(|a| a.as_str()) == Some("new") {
projects_root()
} else {
state.workspace_root.clone()
}
});
let cwd_path = if path_is_within_root(&state.workspace_root, &cwd) && cwd.exists() {
Some(cwd.as_path())
} else {
None
};
match run_command_blocking(&cmd, &args, cwd_path) {
Ok((stdout, stderr, code)) => (
StatusCode::OK,
Json(serde_json::json!({
"exit_code": code,
"stdout": stdout,
"stderr": stderr
})),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e})),
),
}
}
async fn post_agent_run_command_stream(
State(state): State<AppState>,
Json(req): Json<RunCommandRequest>,
) -> impl IntoResponse {
let cmd_string = req.cmd.clone();
let (cmd, args) = if req.args.is_empty() {
let parts: Vec<&str> = req.cmd.split_whitespace().collect();
if parts.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Empty command"})),
)
.into_response();
}
let cmd = parts[0].to_string();
let args: Vec<String> = parts[1..].iter().map(|s| (*s).to_string()).collect();
(cmd, args)
} else {
(req.cmd, req.args)
};
let cwd = req.cwd.as_ref().and_then(|s| {
let s = s.trim();
if s.is_empty() {
None
} else {
let p = PathBuf::from(s);
if p.is_absolute() {
state
.workspace_root
.canonicalize()
.ok()
.and_then(|cr| p.canonicalize().ok().filter(|cp| cp.starts_with(&cr)))
} else {
path_under_base(&state.workspace_root, s)
}
}
});
let cwd = cwd.unwrap_or_else(|| state.workspace_root.clone());
let cwd_path = if path_is_within_root(&state.workspace_root, &cwd) && cwd.exists() {
Some(cwd.as_path())
} else {
None
};
let cmd_resolved = if cmd == "dal" {
super::run_backend::dal_binary_path()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "dal".to_string())
} else {
cmd
};
match spawn_run_streaming(&cmd_resolved, &args, cwd_path) {
Ok((output_tx, kill_tx)) => {
let job_id = format!(
"run-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
);
state
.jobs
.write()
.await
.insert(job_id.clone(), JobEntry { output_tx, kill_tx });
emit_activity(
&state.events_tx,
"run_started",
serde_json::json!({ "job_id": job_id, "cmd": cmd_string }),
);
(
StatusCode::OK,
Json(serde_json::json!({
"job_id": job_id,
})),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e})),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
struct LspDiagnosticsRequest {
contents: String,
#[serde(default)]
path: Option<String>,
#[serde(default)]
workspace: Option<String>,
}
async fn post_lsp_diagnostics(
State(state): State<AppState>,
Json(req): Json<LspDiagnosticsRequest>,
) -> impl IntoResponse {
let path = req.path.as_deref().unwrap_or("").trim();
let use_rust = path.ends_with(".rs");
if use_rust && !path.is_empty() {
let root = resolve_workspace_root(&state, req.workspace.as_deref());
let full_path = match resolve_path_under_root(&root, path) {
Some(p) => p,
None => {
return (
StatusCode::OK,
Json(serde_json::json!({ "diagnostics": [] })),
)
.into_response();
}
};
let cargo_root = find_cargo_root(&full_path, &state.workspace_root);
let uri = lsp_client::path_to_file_uri(&full_path);
let contents = req.contents.clone();
let state_clone = state.clone();
if let Ok(Some(diags)) = tokio::task::spawn_blocking(move || {
let mut guard = state_clone.second_lsp_by_cargo_root.lock().unwrap();
if !guard.contains_key(&cargo_root) {
if let Some(lsp) = SecondLsp::spawn(&cargo_root) {
guard.insert(cargo_root.clone(), lsp);
}
}
guard
.get(&cargo_root)
.and_then(|lsp| lsp.request_diagnostics(uri, contents))
})
.await
{
let out: Vec<serde_json::Value> = diags
.into_iter()
.map(|d| {
serde_json::json!({
"line": d.line,
"column": d.column,
"end_line": d.end_line,
"end_column": d.end_column,
"message": d.message,
"severity": d.severity
})
})
.collect();
return (
StatusCode::OK,
Json(serde_json::json!({ "diagnostics": out })),
)
.into_response();
}
}
let diags = diagnostics::diagnostics_from_source(&req.contents);
(
StatusCode::OK,
Json(serde_json::json!({ "diagnostics": diags })),
)
.into_response()
}
async fn get_lsp_ws(
ws: WebSocketUpgrade,
State(state): State<AppState>,
) -> axum::response::Response {
let workspace_root = state.workspace_root.clone();
ws.on_upgrade(move |socket| lsp_bridge::run_lsp_bridge(socket, workspace_root))
}
fn resolve_workspace_root(state: &AppState, workspace: Option<&str>) -> PathBuf {
match workspace {
Some(ws) if !ws.trim().is_empty() => {
let ws = ws.trim();
let p = PathBuf::from(ws);
if p.is_absolute() {
if let (Ok(canon_root), Ok(canon_p)) =
(state.workspace_root.canonicalize(), p.canonicalize())
{
if canon_p.starts_with(&canon_root) {
return canon_p;
}
}
state.workspace_root.clone()
} else {
path_under_base(&state.workspace_root, ws)
.unwrap_or_else(|| state.workspace_root.clone())
}
}
_ => state.workspace_root.clone(),
}
}
fn find_cargo_root(file_path: &std::path::Path, workspace_root: &std::path::Path) -> PathBuf {
let mut dir = file_path
.parent()
.filter(|p: &&std::path::Path| !p.as_os_str().is_empty())
.unwrap_or(file_path);
loop {
let cargo_toml = dir.join("Cargo.toml");
if path_is_within_root(workspace_root, &cargo_toml) && cargo_toml.exists() {
return dir.to_path_buf();
}
if dir == workspace_root {
return workspace_root.to_path_buf();
}
match dir.parent() {
Some(p) => dir = p,
None => return workspace_root.to_path_buf(),
}
}
}
#[derive(Debug, Deserialize)]
struct LspHoverRequest {
contents: String,
line: u32,
character: u32,
#[serde(default)]
path: Option<String>,
#[serde(default)]
workspace: Option<String>,
}
async fn post_lsp_hover(
State(state): State<AppState>,
Json(req): Json<LspHoverRequest>,
) -> impl IntoResponse {
let path = req.path.as_deref().unwrap_or("").trim();
let use_rust = path.ends_with(".rs");
if use_rust && !path.is_empty() {
let root = resolve_workspace_root(&state, req.workspace.as_deref());
let full_path = match resolve_path_under_root(&root, path) {
Some(p) => p,
None => {
return (StatusCode::OK, Json(serde_json::json!({ "contents": [] })))
.into_response();
}
};
let cargo_root = find_cargo_root(&full_path, &state.workspace_root);
let uri = lsp_client::path_to_file_uri(&full_path);
let contents = req.contents.clone();
let line = req.line;
let character = req.character;
let state_clone = state.clone();
if let Ok(Some(hover_content)) = tokio::task::spawn_blocking(move || {
let mut guard = state_clone.second_lsp_by_cargo_root.lock().unwrap();
if !guard.contains_key(&cargo_root) {
if let Some(lsp) = SecondLsp::spawn(&cargo_root) {
guard.insert(cargo_root.clone(), lsp);
}
}
guard
.get(&cargo_root)
.and_then(|lsp| lsp.request_hover(uri, contents, line, character))
})
.await
{
return (
StatusCode::OK,
Json(serde_json::json!({ "contents": hover_content })),
)
.into_response();
}
}
let content = diagnostics::hover_at_position(&req.contents, req.line, req.character);
(
StatusCode::OK,
Json(serde_json::json!({ "contents": content })),
)
.into_response()
}
#[derive(Debug, Deserialize)]
struct LspCompletionRequest {
contents: String,
line: u32,
character: u32,
#[serde(default)]
path: Option<String>,
#[serde(default)]
workspace: Option<String>,
}
async fn post_lsp_completion(
State(state): State<AppState>,
Json(req): Json<LspCompletionRequest>,
) -> impl IntoResponse {
let path = req.path.as_deref().unwrap_or("").trim();
let use_rust = path.ends_with(".rs");
if use_rust && !path.is_empty() {
let root = resolve_workspace_root(&state, req.workspace.as_deref());
let full_path = match resolve_path_under_root(&root, path) {
Some(p) => p,
None => {
return (StatusCode::OK, Json(serde_json::json!({ "items": [] }))).into_response();
}
};
let cargo_root = find_cargo_root(&full_path, &state.workspace_root);
let uri = lsp_client::path_to_file_uri(&full_path);
let contents = req.contents.clone();
let line = req.line;
let character = req.character;
let state_clone = state.clone();
if let Ok(Some(items)) = tokio::task::spawn_blocking(move || {
let mut guard = state_clone.second_lsp_by_cargo_root.lock().unwrap();
if !guard.contains_key(&cargo_root) {
if let Some(lsp) = SecondLsp::spawn(&cargo_root) {
guard.insert(cargo_root.clone(), lsp);
}
}
guard
.get(&cargo_root)
.and_then(|lsp| lsp.request_completion(uri, contents, line, character))
})
.await
{
let out: Vec<serde_json::Value> = items
.into_iter()
.map(|c| {
serde_json::json!({
"label": c.label,
"kind": c.kind,
"detail": c.detail,
"insertText": c.insert_text
})
})
.collect();
return (StatusCode::OK, Json(serde_json::json!({ "items": out }))).into_response();
}
}
let items = diagnostics::completion_at_position(&req.contents, req.line, req.character);
(StatusCode::OK, Json(serde_json::json!({ "items": items }))).into_response()
}
#[derive(Debug, Deserialize)]
struct DocumentSymbolsRequest {
#[serde(default)]
contents: String,
}
async fn post_lsp_document_symbols(Json(req): Json<DocumentSymbolsRequest>) -> impl IntoResponse {
let symbols = symbols::document_symbols_from_source(&req.contents);
(
StatusCode::OK,
Json(serde_json::json!({ "symbols": symbols })),
)
.into_response()
}
#[derive(Debug, Deserialize)]
struct ReferencesRequest {
#[serde(default)]
contents: String,
name: String,
}
async fn post_lsp_references(Json(req): Json<ReferencesRequest>) -> impl IntoResponse {
let references = symbols::references_in_source(&req.contents, req.name.trim());
(
StatusCode::OK,
Json(serde_json::json!({ "references": references })),
)
.into_response()
}
async fn post_agent_read_file(
State(state): State<AppState>,
Json(req): Json<ReadFileRequest>,
) -> impl IntoResponse {
let path_str = req.path.trim();
if path_str.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "path is required"})),
)
.into_response();
}
let root = match &req.workspace {
Some(ws) if !ws.trim().is_empty() => {
let ws = ws.trim();
let p = PathBuf::from(ws);
if p.is_absolute() {
if let (Ok(cr), Ok(cp)) = (state.workspace_root.canonicalize(), p.canonicalize()) {
if cp.starts_with(&cr) {
cp
} else {
state.workspace_root.clone()
}
} else {
state.workspace_root.clone()
}
} else {
path_under_base(&state.workspace_root, ws)
.unwrap_or_else(|| state.workspace_root.clone())
}
}
_ => state.workspace_root.clone(),
};
let path = match resolve_path_under_root(&root, path_str) {
Some(p) => p,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid path"})),
)
.into_response();
}
};
if !path_is_within_root(&root, &path) || !path.exists() {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "file not found", "path": path.to_string_lossy()})),
)
.into_response();
}
if !path_is_within_root(&root, &path) || path.is_dir() {
return (
StatusCode::BAD_REQUEST,
Json(
serde_json::json!({"error": "path is a directory", "path": path.to_string_lossy()}),
),
)
.into_response();
}
if !path_is_within_root(&root, &path) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid path"})),
)
.into_response();
}
match std::fs::read_to_string(&path) {
Ok(contents) => (
StatusCode::OK,
Json(serde_json::json!({"ok": true, "contents": contents})),
)
.into_response(),
Err(e) => {
let code = if e.kind() == std::io::ErrorKind::NotFound {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(code, Json(serde_json::json!({"error": e.to_string()}))).into_response()
}
}
}
async fn post_agent_write_file(
State(state): State<AppState>,
Json(req): Json<WriteFileRequest>,
) -> impl IntoResponse {
let root = match &req.workspace {
Some(ws) if !ws.trim().is_empty() => {
let ws = ws.trim();
let p = PathBuf::from(ws);
if p.is_absolute() {
if let (Ok(cr), Ok(cp)) = (state.workspace_root.canonicalize(), p.canonicalize()) {
if cp.starts_with(&cr) {
cp
} else {
state.workspace_root.clone()
}
} else {
state.workspace_root.clone()
}
} else {
path_under_base(&state.workspace_root, ws)
.unwrap_or_else(|| state.workspace_root.clone())
}
}
_ => state.workspace_root.clone(),
};
let path = match resolve_path_under_root(&root, &req.path) {
Some(p) => p,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid path"})),
)
.into_response();
}
};
if !path_is_within_root(&root, &path) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid path"})),
)
.into_response();
}
if let Some(parent) = path.parent() {
if !path_is_within_root(&root, parent) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Invalid path"})),
)
.into_response();
}
if let Err(e) = std::fs::create_dir_all(parent) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": format!("Failed to create parent dir: {}", e)})),
)
.into_response();
}
}
match std::fs::write(&path, &req.contents) {
Ok(()) => {
emit_activity(
&state.events_tx,
"file_written",
serde_json::json!({ "path": req.path }),
);
(
StatusCode::OK,
Json(serde_json::json!({"ok": true, "path": path.to_string_lossy()})),
)
.into_response()
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
struct AgentPromptRequest {
text: String,
#[serde(default)]
context: Option<String>,
#[serde(default)]
workspace: Option<String>,
}
async fn post_agent_prompt(
State(state): State<AppState>,
Json(req): Json<AgentPromptRequest>,
) -> impl IntoResponse {
let text = req.text.trim().to_string();
if text.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "Prompt text is required"})),
)
.into_response();
}
let workspace_root = match req.workspace.as_deref() {
Some(ws) if !ws.trim().is_empty() => {
let ws = ws.trim();
let p = PathBuf::from(ws);
if p.is_absolute() {
if let (Ok(cr), Ok(cp)) = (state.workspace_root.canonicalize(), p.canonicalize()) {
if cp.starts_with(&cr) {
cp
} else {
state.workspace_root.clone()
}
} else {
state.workspace_root.clone()
}
} else {
path_under_base(&state.workspace_root, ws)
.unwrap_or_else(|| state.workspace_root.clone())
}
}
_ => state.workspace_root.clone(),
};
let job_id = uuid::Uuid::new_v4().to_string();
let job_id_response = job_id.clone();
let context = req.context.clone();
let events_tx = state.events_tx.clone();
tokio::task::spawn_blocking(move || {
let _ =
agent_runner::run_agent_prompt_sync(&workspace_root, text, context, job_id, events_tx);
});
(
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"job_id": job_id_response
})),
)
.into_response()
}
#[derive(Debug, Deserialize)]
struct AgentChatRequest {
text: String,
}
async fn post_agent_chat(
State(state): State<AppState>,
Json(req): Json<AgentChatRequest>,
) -> impl IntoResponse {
let text = req.text.trim().to_string();
if text.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "text is required"})),
)
.into_response();
}
let events_tx = state.events_tx.clone();
emit_activity(&events_tx, "command", serde_json::json!({ "text": text }));
let result = tokio::task::spawn_blocking(move || crate::stdlib::ai::generate_text(text)).await;
match result {
Ok(Ok(reply)) => {
let reply_trimmed = reply.trim().to_string();
emit_activity(
&events_tx,
"chat_reply",
serde_json::json!({ "reply": reply_trimmed }),
);
(
StatusCode::OK,
Json(serde_json::json!({ "reply": reply_trimmed })),
)
.into_response()
}
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
#[derive(Debug, Deserialize)]
struct CommandRequest {
text: String,
#[serde(default)]
context: Option<String>,
}
async fn post_command(
State(state): State<AppState>,
Json(req): Json<CommandRequest>,
) -> impl IntoResponse {
let payload = serde_json::json!({
"text": req.text,
"context": req.context,
});
emit_activity(&state.events_tx, "command", payload);
(StatusCode::OK, Json(serde_json::json!({"ok": true}))).into_response()
}
async fn get_config() -> impl IntoResponse {
let root = projects_root().to_string_lossy().to_string();
(
StatusCode::OK,
Json(serde_json::json!({ "projects_root": root })),
)
.into_response()
}
async fn get_events_stream(State(state): State<AppState>) -> impl IntoResponse {
let rx = state.events_tx.subscribe();
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
match rx.recv().await {
Ok(msg) => {
let event = Event::default().data(msg);
Some((Ok::<_, std::convert::Infallible>(event), rx))
}
Err(_) => None,
}
});
Sse::new(stream)
.keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keepalive"),
)
.into_response()
}
pub fn build_router(workspace_root: PathBuf) -> Router {
let (events_tx, _) = broadcast::channel(256);
let state = AppState {
workspace_root: workspace_root.clone(),
orchestration: Arc::new(RwLock::new(Some(discover_workspace(&workspace_root)))),
jobs: Arc::new(RwLock::new(std::collections::HashMap::new())),
events_tx,
second_lsp_by_cargo_root: Arc::new(std::sync::Mutex::new(HashMap::new())),
};
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers(Any);
Router::new()
.route("/api/config", get(get_config))
.route("/api/orchestration", get(get_orchestration))
.route("/api/files", get(get_files))
.route("/api/search", post(post_search))
.route("/api/run", post(post_run))
.route("/api/run/stream", post(post_run_stream))
.route("/api/run/stream/:job_id", get(get_run_stream))
.route("/api/run/stop", post(post_run_stop))
.route("/api/lsp/diagnostics", post(post_lsp_diagnostics))
.route("/api/lsp/hover", post(post_lsp_hover))
.route("/api/lsp/completion", post(post_lsp_completion))
.route("/api/lsp/document_symbols", post(post_lsp_document_symbols))
.route(
"/api/lsp/document_symbols/",
post(post_lsp_document_symbols),
)
.route("/api/lsp/references", post(post_lsp_references))
.route("/api/lsp/stream", get(get_lsp_ws))
.route("/api/agent/run_command", post(post_agent_run_command))
.route(
"/api/agent/run_command_stream",
post(post_agent_run_command_stream),
)
.route("/api/agent/write_file", post(post_agent_write_file))
.route("/api/agent/read_file", post(post_agent_read_file))
.route("/api/agent/prompt", post(post_agent_prompt))
.route("/api/agent/chat", post(post_agent_chat))
.route("/api/command", post(post_command))
.route("/api/events/stream", get(get_events_stream))
.route("/health", get(|| async { "OK" }))
.route("/metrics", get(crate::observability::metrics_http_response))
.layer(NormalizePathLayer::trim_trailing_slash())
.layer(middleware::from_fn(
crate::observability::http_observability_middleware,
))
.layer(cors)
.with_state(state)
}