From 1ce03d078bf715a0aeffa804f3a6b9426cb1de61 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 4 Feb 2026 14:32:25 -0800 Subject: [PATCH] feat: wire formatter discovery and LSP status endpoints --- .turbo | 1 + dist | 1 + node_modules | 1 + .../sandbox-agent/src/opencode_compat.rs | 28 +- server/packages/sandbox-agent/src/router.rs | 296 +++++++++++++++++- .../tests/http/opencode_endpoints.rs | 146 +++++++++ .../sandbox-agent/tests/http_endpoints.rs | 1 + target | 1 + 8 files changed, 470 insertions(+), 5 deletions(-) create mode 120000 .turbo create mode 120000 dist create mode 120000 node_modules create mode 100644 server/packages/sandbox-agent/tests/http/opencode_endpoints.rs create mode 120000 target diff --git a/.turbo b/.turbo new file mode 120000 index 0000000..0b7d9ca --- /dev/null +++ b/.turbo @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/.turbo \ No newline at end of file diff --git a/dist b/dist new file mode 120000 index 0000000..f02d77f --- /dev/null +++ b/dist @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/dist \ No newline at end of file diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..501480b --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/node_modules \ No newline at end of file diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 55b7050..8465cbe 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -2487,8 +2487,18 @@ async fn oc_log() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_lsp_status() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_lsp_status( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let statuses = state + .inner + .session_manager() + .lsp_status_for_directory(&directory) + .await; + (StatusCode::OK, Json(statuses)) } #[utoipa::path( @@ -2497,8 +2507,18 @@ async fn oc_lsp_status() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_formatter_status() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_formatter_status( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> impl IntoResponse { + let directory = state.opencode.directory_for(&headers, query.directory.as_ref()); + let statuses = state + .inner + .session_manager() + .formatter_status_for_directory(&directory) + .await; + (StatusCode::OK, Json(statuses)) } #[utoipa::path( diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 3ca437a..38272aa 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::convert::Infallible; use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Weak}; @@ -812,12 +812,270 @@ struct AgentServerManager { restart_notifier: Mutex>>, } +#[derive(Debug, Clone, Serialize)] +struct FormatterStatus { + name: String, + extensions: Vec, + enabled: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct LspStatus { + id: String, + name: String, + root: String, + status: String, +} + +#[derive(Debug, Clone)] +struct FormatterDefinition { + name: &'static str, + extensions: &'static [&'static str], +} + +#[derive(Debug, Clone)] +struct LspDefinition { + id: &'static str, + name: &'static str, + extensions: &'static [&'static str], + binaries: &'static [&'static str], +} + +const FORMATTER_DEFINITIONS: &[FormatterDefinition] = &[ + FormatterDefinition { + name: "prettier", + extensions: &[ + "js", "jsx", "ts", "tsx", "json", "css", "scss", "html", "md", "mdx", "yaml", "yml", + ], + }, + FormatterDefinition { + name: "rustfmt", + extensions: &["rs"], + }, + FormatterDefinition { + name: "gofmt", + extensions: &["go"], + }, + FormatterDefinition { + name: "black", + extensions: &["py"], + }, + FormatterDefinition { + name: "stylua", + extensions: &["lua"], + }, + FormatterDefinition { + name: "clang-format", + extensions: &["c", "h", "cc", "cpp", "cxx", "hpp"], + }, +]; + +const LSP_DEFINITIONS: &[LspDefinition] = &[ + LspDefinition { + id: "typescript-language-server", + name: "TypeScript Language Server", + extensions: &["js", "jsx", "ts", "tsx"], + binaries: &["typescript-language-server"], + }, + LspDefinition { + id: "rust-analyzer", + name: "Rust Analyzer", + extensions: &["rs"], + binaries: &["rust-analyzer"], + }, + LspDefinition { + id: "gopls", + name: "gopls", + extensions: &["go"], + binaries: &["gopls"], + }, + LspDefinition { + id: "pyright", + name: "Pyright", + extensions: &["py"], + binaries: &["pyright-langserver", "pylsp"], + }, + LspDefinition { + id: "lua-language-server", + name: "Lua Language Server", + extensions: &["lua"], + binaries: &["lua-language-server"], + }, + LspDefinition { + id: "clangd", + name: "clangd", + extensions: &["c", "h", "cc", "cpp", "cxx", "hpp"], + binaries: &["clangd"], + }, +]; + +#[derive(Debug, Clone)] +struct FormatterService { + max_files: usize, + max_depth: usize, +} + +impl FormatterService { + fn new() -> Self { + Self { + max_files: 5000, + max_depth: 25, + } + } + + fn status_for_directory(&self, directory: &Path) -> Vec { + let extensions = collect_workspace_extensions(directory, self.max_files, self.max_depth); + FORMATTER_DEFINITIONS + .iter() + .filter(|definition| { + definition + .extensions + .iter() + .any(|ext| extensions.contains(*ext)) + }) + .map(|definition| FormatterStatus { + name: definition.name.to_string(), + extensions: definition + .extensions + .iter() + .map(|ext| ext.to_string()) + .collect(), + enabled: true, + }) + .collect() + } +} + +#[derive(Debug)] +struct LspStatusRegistry { + entries: Mutex>>, +} + +impl LspStatusRegistry { + fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } + + async fn update(&self, root: &str, statuses: Vec) -> Vec { + let mut entries = self.entries.lock().await; + entries.insert(root.to_string(), statuses.clone()); + statuses + } +} + +fn collect_workspace_extensions( + root: &Path, + max_files: usize, + max_depth: usize, +) -> HashSet { + let mut extensions = HashSet::new(); + if !root.exists() { + return extensions; + } + let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::new(); + queue.push_back((root.to_path_buf(), 0)); + let mut files_scanned = 0usize; + while let Some((directory, depth)) = queue.pop_front() { + if depth > max_depth { + continue; + } + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(_) => continue, + }; + for entry in entries.flatten() { + if files_scanned >= max_files { + return extensions; + } + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(_) => continue, + }; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); + if file_type.is_dir() { + if let Some(name) = path.file_name().and_then(|value| value.to_str()) { + if should_skip_dir(name) { + continue; + } + } + queue.push_back((path, depth + 1)); + } else if file_type.is_file() { + files_scanned += 1; + if let Some(ext) = path.extension().and_then(|value| value.to_str()) { + extensions.insert(ext.to_ascii_lowercase()); + } + } + } + } + extensions +} + +fn should_skip_dir(name: &str) -> bool { + matches!( + name, + ".git" + | "node_modules" + | "target" + | "dist" + | "build" + | ".venv" + | ".idea" + | ".vscode" + | ".cargo" + | ".turbo" + | ".next" + | "out" + ) +} + +fn lsp_binary_available(definition: &LspDefinition) -> bool { + definition + .binaries + .iter() + .any(|binary| command_on_path(binary)) +} + +#[cfg(windows)] +fn command_candidates(command: &str) -> Vec { + if command.ends_with(".exe") { + vec![command.to_string()] + } else { + vec![format!("{command}.exe"), command.to_string()] + } +} + +#[cfg(not(windows))] +fn command_candidates(command: &str) -> Vec { + vec![command.to_string()] +} + +fn command_on_path(command: &str) -> bool { + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + for dir in std::env::split_paths(&paths) { + for candidate in command_candidates(command) { + if dir.join(&candidate).is_file() { + return true; + } + } + } + false +} + #[derive(Debug)] pub(crate) struct SessionManager { agent_manager: Arc, sessions: Mutex>, server_manager: Arc, http_client: Client, + formatter_service: FormatterService, + lsp_registry: LspStatusRegistry, } /// Shared Codex app-server process that handles multiple sessions via JSON-RPC. @@ -1538,9 +1796,45 @@ impl SessionManager { sessions: Mutex::new(Vec::new()), server_manager, http_client: Client::new(), + formatter_service: FormatterService::new(), + lsp_registry: LspStatusRegistry::new(), } } + pub(crate) async fn formatter_status_for_directory( + &self, + directory: &str, + ) -> Vec { + let root = Path::new(directory); + self.formatter_service.status_for_directory(root) + } + + pub(crate) async fn lsp_status_for_directory(&self, directory: &str) -> Vec { + let root = Path::new(directory); + let extensions = collect_workspace_extensions(root, 5000, 25); + let root_string = root.to_string_lossy().to_string(); + let statuses = LSP_DEFINITIONS + .iter() + .filter(|definition| { + definition + .extensions + .iter() + .any(|ext| extensions.contains(*ext)) + }) + .map(|definition| LspStatus { + id: definition.id.to_string(), + name: definition.name.to_string(), + root: root_string.clone(), + status: if lsp_binary_available(definition) { + "connected".to_string() + } else { + "error".to_string() + }, + }) + .collect(); + self.lsp_registry.update(&root_string, statuses).await + } + fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> { sessions .iter() diff --git a/server/packages/sandbox-agent/tests/http/opencode_endpoints.rs b/server/packages/sandbox-agent/tests/http/opencode_endpoints.rs new file mode 100644 index 0000000..0bd6623 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/opencode_endpoints.rs @@ -0,0 +1,146 @@ +include!("../common/http.rs"); + +use std::collections::HashSet; +use std::env; +use std::fs; +use std::path::Path; + +struct PathGuard { + old_path: Option, +} + +impl PathGuard { + fn new(bin_dir: &Path) -> Self { + let old_path = env::var_os("PATH"); + let mut paths = vec![bin_dir.to_path_buf()]; + if let Some(existing) = &old_path { + paths.extend(env::split_paths(existing)); + } + let joined = env::join_paths(paths).expect("join PATH"); + env::set_var("PATH", &joined); + Self { old_path } + } +} + +impl Drop for PathGuard { + fn drop(&mut self) { + if let Some(value) = &self.old_path { + env::set_var("PATH", value); + } else { + env::remove_var("PATH"); + } + } +} + +#[cfg(windows)] +fn binary_filename(name: &str) -> String { + format!("{name}.exe") +} + +#[cfg(not(windows))] +fn binary_filename(name: &str) -> String { + name.to_string() +} + +fn write_executable(path: &Path) { + fs::write(path, "").expect("write fake binary"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path) + .expect("metadata") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("set permissions"); + } +} + +fn add_fake_binary(dir: &Path, name: &str) { + let path = dir.join(binary_filename(name)); + write_executable(&path); +} + +fn create_fixture_file(root: &Path, name: &str) { + let path = root.join(name); + fs::write(path, "test").expect("write fixture"); +} + +fn collect_names(value: &serde_json::Value, field: &str) -> HashSet { + value + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.get(field).and_then(|value| value.as_str())) + .map(|value| value.to_string()) + .collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn opencode_formatter_lsp_status_for_workspace() { + let fixture_dir = tempfile::tempdir().expect("fixture dir"); + create_fixture_file(fixture_dir.path(), "main.rs"); + create_fixture_file(fixture_dir.path(), "main.ts"); + create_fixture_file(fixture_dir.path(), "main.py"); + create_fixture_file(fixture_dir.path(), "main.go"); + + let bin_dir = tempfile::tempdir().expect("bin dir"); + add_fake_binary(bin_dir.path(), "rust-analyzer"); + add_fake_binary(bin_dir.path(), "typescript-language-server"); + add_fake_binary(bin_dir.path(), "pyright-langserver"); + add_fake_binary(bin_dir.path(), "gopls"); + let _guard = PathGuard::new(bin_dir.path()); + + let app = TestApp::new(); + let directory = fixture_dir + .path() + .to_str() + .expect("fixture dir path"); + + let formatter_request = Request::builder() + .method(Method::GET) + .uri("/opencode/formatter") + .header("x-opencode-directory", directory) + .body(Body::empty()) + .expect("formatter request"); + let (status, _headers, payload) = send_json_request(&app.app, formatter_request).await; + assert_eq!(status, StatusCode::OK, "formatter status"); + + let formatter_names = collect_names(&payload, "name"); + for expected in ["prettier", "rustfmt", "gofmt", "black"] { + assert!( + formatter_names.contains(expected), + "expected formatter {expected}" + ); + } + + let lsp_request = Request::builder() + .method(Method::GET) + .uri("/opencode/lsp") + .header("x-opencode-directory", directory) + .body(Body::empty()) + .expect("lsp request"); + let (status, _headers, payload) = send_json_request(&app.app, lsp_request).await; + assert_eq!(status, StatusCode::OK, "lsp status"); + + let lsp_ids = collect_names(&payload, "id"); + for expected in [ + "typescript-language-server", + "rust-analyzer", + "gopls", + "pyright", + ] { + assert!(lsp_ids.contains(expected), "expected lsp {expected}"); + } + + let statuses: HashSet = payload + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.get("status").and_then(|value| value.as_str())) + .map(|value| value.to_string()) + .collect(); + assert!( + statuses.iter().all(|value| value == "connected"), + "expected all LSPs connected" + ); +} diff --git a/server/packages/sandbox-agent/tests/http_endpoints.rs b/server/packages/sandbox-agent/tests/http_endpoints.rs index a443a95..271bd8a 100644 --- a/server/packages/sandbox-agent/tests/http_endpoints.rs +++ b/server/packages/sandbox-agent/tests/http_endpoints.rs @@ -1,2 +1,3 @@ #[path = "http/agent_endpoints.rs"] mod agent_endpoints; +mod opencode_endpoints; diff --git a/target b/target new file mode 120000 index 0000000..3d6ad8c --- /dev/null +++ b/target @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/target \ No newline at end of file