use wsb::prelude::*;
pub mod github;
pub mod handlers;
pub mod fs_scope;
pub mod notes;
pub mod log_janitor;
pub mod machine_web;
pub mod op_log;
pub mod ops;
pub mod parser;
#[cfg(test)]
#[path = "proxy_tests.rs"]
mod proxy_tests;
pub mod search_sources;
pub mod state;
pub mod sync;
use anyhow::{Context, Result};
use axum::{
body::{boxed, Full},
http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
routing::{delete, get, patch, post, put},
Router,
};
use rust_embed::RustEmbed;
use wsb::fleet::config::gitmodules::validate_gitmodules_structure;
use std::env;
use std::net::{SocketAddr, TcpListener};
use tower_http::cors::CorsLayer;
use state::WebState;
#[derive(RustEmbed)]
#[folder = "$OUT_DIR/workbench/dist"]
struct FrontendAssets;
pub(crate) fn bind_failure(addr: SocketAddr, error: std::io::Error) -> anyhow::Error {
if error.kind() == std::io::ErrorKind::AddrInUse {
anyhow::anyhow!(
"cannot serve the workbench on {addr}: the port is already in use. Stop \
whatever holds it (another wsb daemon?), or choose another with --port."
)
} else {
anyhow::anyhow!(
"cannot serve the workbench on {addr}: this machine cannot bind that address \
({error}). Choose another with --listen/--port."
)
}
}
pub async fn start_server(
machine: Arc<crate::machine::MachineState>,
parent_pid: Option<u32>,
bound: tokio::sync::oneshot::Sender<SocketAddr>,
) -> Result<()> {
let listen = machine.listen;
let port = machine.port;
let addr = SocketAddr::new(listen, port);
let listener = TcpListener::bind(addr).map_err(|error| bind_failure(addr, error))?;
if !listen.is_loopback() {
wsb::errln!(
"[wsb] WARNING: the workbench is reachable at {addr}, not only from this \
machine.\n It has no authentication: anyone who can reach this \
address can read every workspace on it and start operations on this \
host.\n\x20 Put it behind a tunnel (Tailscale, SSH, a TLS reverse \
proxy), or drop --listen to serve only 127.0.0.1."
);
}
listener.set_nonblocking(true)?;
let actual = listener
.local_addr()
.context("the workbench listener has no address")?;
let _ = bound.send(actual);
match &machine.github {
Ok(github) => github::spawn(github.clone()),
Err(reason) => wsb::errln!("[wsb] GitHub is unavailable: {reason}"),
}
machine.web.spawn_services();
handlers::prj::spawn_listing(machine.clone());
handlers::prj::spawn_watcher(machine.clone());
let app = machine_router(machine.clone());
wsb::errln!("[wsb] listening on http://{}", addr);
if let Some(pid) = parent_pid {
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if !wsb::ops::process::is_alive(pid) {
wsb::errln!("[wsb] parent process {} exited — shutting down", pid);
crate::remove_rendezvous();
std::process::exit(0);
}
}
});
}
axum::Server::from_tcp(listener)?
.serve(app.into_make_service())
.await?;
Ok(())
}
pub fn spawn_worktree_services_once(runtime: &Arc<crate::machine::WorktreeRuntime>) {
static STARTED: std::sync::OnceLock<std::sync::Mutex<std::collections::BTreeSet<String>>> =
std::sync::OnceLock::new();
let started = STARTED.get_or_init(|| std::sync::Mutex::new(Default::default()));
let Ok(mut started) = started.lock() else {
return;
};
if !started.insert(runtime.registered.id.to_string()) {
return;
}
spawn_worktree_services(runtime.web.clone());
}
pub fn spawn_worktree_services(state: WebState) {
let reconcile = state.ops.clone();
tokio::spawn(async move {
let mut every = tokio::time::interval(std::time::Duration::from_secs(5));
loop {
every.tick().await;
reconcile.resolve_finished_adopted().await;
}
});
handlers::fleet::spawn_git_watcher(state.clone());
handlers::fleet::spawn_git_fs_watcher(state.clone());
spawn_workspace_file_watcher(state);
}
fn machine_router(machine: Arc<crate::machine::MachineState>) -> Router {
Router::new()
.merge(handlers::machine::routes())
.merge(handlers::prj::routes())
.merge(crate::peer::routes())
.merge(machine_web::routes(machine.web.clone()))
.route(
"/api/v1/worktrees/:worktree/*rest",
axum::routing::any(worktree_proxy),
)
.fallback(spa_handler)
.layer(
tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any),
)
.merge(crate::control::routes())
.with_state(machine)
}
async fn worktree_proxy(
axum::extract::State(machine): axum::extract::State<Arc<crate::machine::MachineState>>,
axum::extract::Path((worktree, rest)): axum::extract::Path<(String, String)>,
request: axum::http::Request<axum::body::Body>,
) -> axum::response::Response {
use axum::response::IntoResponse;
use tower::ServiceExt;
let refuse = |status: axum::http::StatusCode, error: String| {
(status, axum::Json(serde_json::json!({ "error": error }))).into_response()
};
let id = match wsb::daemon::WorktreeId::parse(&worktree) {
Ok(id) => id,
Err(error) => return refuse(axum::http::StatusCode::BAD_REQUEST, error),
};
let runtime = match machine.open(&id).await {
Ok(runtime) => runtime,
Err(error) => return refuse(axum::http::StatusCode::NOT_FOUND, error),
};
spawn_worktree_services_once(&runtime);
let request = match inner_request(&rest, request) {
Ok(request) => request,
Err(error) => return refuse(axum::http::StatusCode::BAD_REQUEST, error),
};
match runtime.router().oneshot(request).await {
Ok(response) => response,
Err(error) => match error {},
}
}
pub(crate) fn inner_request(
rest: &str,
request: axum::http::Request<axum::body::Body>,
) -> Result<axum::http::Request<axum::body::Body>, String> {
let query = request
.uri()
.query()
.map(|q| format!("?{q}"))
.unwrap_or_default();
let uri: axum::http::Uri = format!("/api/v1/{}{}", rest.trim_start_matches('/'), query)
.parse()
.map_err(|error| format!("{error}"))?;
let (parts, body) = request.into_parts();
let mut inner = axum::http::Request::builder()
.method(parts.method)
.uri(uri)
.version(parts.version)
.body(body)
.map_err(|error| format!("{error}"))?;
*inner.headers_mut() = parts.headers;
Ok(inner)
}
pub fn validate_gitmodules_if_present(workspace_root: &Path) -> Result<()> {
let path = workspace_root.join(".gitmodules");
if !path.exists() {
return Ok(());
}
let bytes =
std::fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?;
validate_gitmodules_structure(&bytes, &path).map_err(|e| anyhow::anyhow!("{}", e))
}
fn spawn_workspace_file_watcher(state: WebState) {
use std::time::Duration;
let workspace_root = state.workspace_root.clone();
let tx = state.file_changed_tx.clone();
let root_clone = workspace_root.clone();
let outcome = fs_scope::spawn_scoped_watcher(
"workspace-files".to_string(),
vec![workspace_root.clone()],
true,
Duration::from_millis(300),
move |events| {
for event in events {
let path = &event.path;
let rel = match path.strip_prefix(&root_clone) {
Ok(r) => r,
Err(_) => continue,
};
let rel_str = rel.to_string_lossy();
if rel_str.split('/').any(|seg| seg.starts_with('.')) {
continue;
}
let mtime_secs = std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
let _ = tx.send(crate::web::state::FileChangedEvent {
path: rel_str.into_owned(),
mtime_secs,
});
}
},
);
if let Err(e) = outcome {
wsb::errln!("[wsb] workspace file watcher not running: {}", e);
}
}
pub(crate) fn home_dir() -> Result<PathBuf> {
if let Some(home) = env::var_os("HOME") {
let path = PathBuf::from(home);
if !path.as_os_str().is_empty() {
return Ok(path);
}
}
std::panic::catch_unwind(dirs::home_dir)
.ok()
.flatten()
.context("cannot determine home directory")
}
pub fn create_router(state: WebState) -> Router {
let api = Router::new()
.route(
"/overview/stats",
get(handlers::overview::get_overview_stats),
)
.route("/search", get(handlers::search::file_search))
.route("/path-notes", get(handlers::path_notes::list_path_notes))
.route("/path-notes", post(handlers::path_notes::create_path_note))
.route("/catalogue/tests", get(handlers::catalogue::list_tests))
.route("/catalogue/survey", get(handlers::catalogue::survey))
.route("/catalogue/parity", get(handlers::catalogue::parity))
.route("/catalogue/free", get(handlers::catalogue::free_numbers))
.route("/catalogue/scan", post(handlers::catalogue::scan))
.route("/settings", get(handlers::settings::get_settings))
.route("/settings", put(handlers::settings::update_settings))
.route("/workspace/trees", post(handlers::workspace::list_trees))
.route("/events", get(handlers::events::events_stream))
.route("/workspace/file", get(handlers::workspace::read_file))
.route("/workspace/file", post(handlers::workspace::save_file))
.route("/workspace/update", post(handlers::workspace::run_update))
.route(
"/workspace/create-file",
post(handlers::workspace::create_file),
)
.route(
"/workspace/create-directory",
post(handlers::workspace::create_directory),
)
.route("/workspace/delete", post(handlers::workspace::delete_entry))
.route("/workspace/rename", post(handlers::workspace::rename_entry))
.route("/workspace/copy", post(handlers::workspace::copy_entry))
.route(
"/workspace/workbench-model",
get(handlers::extensions::get_workbench_model),
)
.route(
"/workspace/extensions/execute",
post(handlers::extensions::execute_extension_command),
)
.route(
"/workspace/extensions/:extension_id/assets",
get(handlers::extensions::get_extension_asset),
)
.route(
"/workspace/extensions/:extension_id/readme",
get(handlers::extensions::get_extension_readme),
)
.route(
"/marketplace/search",
get(handlers::marketplace::search_marketplace),
)
.route(
"/marketplace/readme",
get(handlers::marketplace::get_marketplace_readme),
)
.route(
"/marketplace/install",
post(handlers::marketplace::install_extension),
)
.route(
"/marketplace/inspect",
post(handlers::marketplace::inspect_marketplace_extension),
)
.route(
"/marketplace/uninstall",
post(handlers::marketplace::uninstall_extension),
)
.route(
"/marketplace/disable",
post(handlers::marketplace::disable_extension),
)
.route(
"/marketplace/enable",
post(handlers::marketplace::enable_extension),
)
.route(
"/workbench/state",
get(handlers::workbench_state::get_workbench_state),
)
.route(
"/workbench/state",
put(handlers::workbench_state::put_workbench_state),
)
.route("/version", get(handlers::version::get_version))
.route("/issues", get(handlers::issues::list_issues))
.route("/issues/refresh", post(handlers::issues::refresh_issues))
.route(
"/issues/:owner/:repo",
get(handlers::issues::list_repo_issues),
)
.route("/issues/:owner/:repo", post(handlers::issues::create_issue))
.route(
"/issues/:owner/:repo/:number",
patch(handlers::issues::update_issue),
)
.route(
"/issues/:owner/:repo/:number/comments",
get(handlers::issues::list_comments),
)
.route(
"/issues/:owner/:repo/:number/comments",
post(handlers::issues::add_comment),
)
.route("/github/overview", get(handlers::github::overview))
.route("/github/refresh", post(handlers::github::refresh))
.route(
"/github/notifications",
get(handlers::github::notifications),
)
.route("/github/rate-limit", get(handlers::github::rate_limit))
.route("/github/:owner/:repo", get(handlers::github::repository))
.route(
"/github/:owner/:repo/pulls",
get(handlers::github::list_pulls),
)
.route(
"/github/:owner/:repo/releases",
get(handlers::github::list_releases),
)
.route(
"/github/:owner/:repo/tags",
get(handlers::github::list_tags),
)
.route(
"/github/:owner/:repo/milestones",
get(handlers::github::list_milestones),
)
.route(
"/github/:owner/:repo/labels",
get(handlers::github::list_labels),
)
.route("/github/:owner/:repo/checks", get(handlers::github::checks))
.route(
"/github/:owner/:repo/traffic",
get(handlers::github::traffic),
)
.route("/github/projects", get(handlers::projects::list_projects))
.route("/github/projects/refresh", post(handlers::projects::refresh_projects))
.route(
"/github/projects/:project/columns",
post(handlers::projects::add_column),
)
.route(
"/github/projects/:project/columns/:option",
axum::routing::delete(handlers::projects::remove_column),
)
.route(
"/github/projects/:project/items",
get(handlers::projects::project_items).post(handlers::projects::add_item),
)
.route(
"/github/projects/:project/items/:item/field",
post(handlers::projects::set_field),
)
.route(
"/github/projects/:project/items/:item/position",
post(handlers::projects::set_position),
)
.route(
"/github/projects/:project/items/:item/archived",
post(handlers::projects::set_archived),
)
.route(
"/github/projects/:project/items/:item/draft",
post(handlers::projects::edit_draft),
)
.route(
"/github/projects/:project/items/:item",
axum::routing::delete(handlers::projects::remove_item),
)
.route("/gists", get(handlers::gists::list_gists))
.route("/gists", post(handlers::gists::create_gist))
.route("/gists/refresh", post(handlers::gists::refresh_gists))
.route("/gists/:id", get(handlers::gists::get_gist))
.route("/gists/:id", patch(handlers::gists::update_gist))
.route("/fleet/status", get(handlers::fleet::fleet_status))
.route("/fleet/repos", get(handlers::fleet::fleet_repos))
.route(
"/fleet/detailed-status",
get(handlers::fleet::fleet_detailed_status),
)
.route("/fleet/branches", get(handlers::fleet::fleet_branches))
.route("/fleet/diff", get(handlers::fleet::fleet_diff))
.route("/fleet/watch", get(handlers::fleet::fleet_watch))
.route("/fleet/focus", post(handlers::fleet::fleet_focus))
.route("/fleet/stage", post(handlers::fleet::fleet_stage))
.route("/fleet/commit", post(handlers::fleet::fleet_commit))
.route("/fleet/push", post(handlers::fleet::fleet_push))
.route("/fleet/pull", post(handlers::fleet::fleet_pull))
.route("/fleet/checkout", post(handlers::fleet::fleet_checkout))
.route("/fleet/fetch", post(handlers::fleet::fleet_fetch))
.route("/fleet/discard", post(handlers::fleet::fleet_discard))
.route(
"/fleet/branch/delete",
post(handlers::fleet::fleet_delete_branch),
)
.route(
"/fleet/branch/create",
post(handlers::fleet::fleet_create_branch),
)
.route("/fleet/stage-all", post(handlers::fleet::fleet_stage_all))
.route("/fleet/commit-all", post(handlers::fleet::fleet_commit_all))
.route("/fleet/push-all", post(handlers::fleet::fleet_push_all))
.route("/fleet/pull-all", post(handlers::fleet::fleet_pull_all))
.route(
"/fleet/checkout-all",
post(handlers::fleet::fleet_checkout_all),
)
.route("/fleet/merge-back", post(handlers::fleet::fleet_merge_back))
.route("/logs/tail", get(handlers::log_tail::tail_stream))
.route("/refactor/preview", post(handlers::refactor::preview))
.route("/refactor/preview/:id", get(handlers::refactor::changes))
.route("/refactor/preview/:id", delete(handlers::refactor::discard))
.route("/refactor/preview/:id/apply", post(handlers::refactor::apply))
.route("/refactor/preview/:id/diff", get(handlers::refactor::diff))
.route("/ops", get(ops::ops_snapshot))
.route("/ops/:id/output", get(ops::op_output_stream))
.route("/ops/live", get(ops::op_live_ids))
.route("/ops/:id/processes", get(handlers::op_processes::list))
.route("/ops/:id/processes/kill", post(handlers::op_processes::kill))
.route("/ops/adopt", post(ops::op_adopt))
.route("/ops/:id/lines", post(ops::op_adopted_lines))
.route("/ops/:id/finished", post(ops::op_adopted_finished));
Router::new()
.nest("/api/v1", api)
.layer(CorsLayer::permissive())
.with_state(state)
}
async fn spa_handler(uri: Uri) -> impl IntoResponse {
let path = uri.path().trim_start_matches('/');
if path.starts_with("api/") {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.header(header::CONTENT_TYPE, "application/json")
.body(boxed(Full::from(
serde_json::json!({
"error": format!("no such endpoint: /{path}"),
})
.to_string(),
)))
.unwrap();
}
if let Some(content) = FrontendAssets::get(path) {
let mime = mime_guess::from_path(path).first_or_octet_stream();
let cache_control = if path == "index.html" {
"no-store"
} else {
"public, max-age=31536000, immutable"
};
return Response::builder()
.header(header::CONTENT_TYPE, mime.as_ref())
.header(header::CACHE_CONTROL, cache_control)
.body(boxed(Full::from(content.data)))
.unwrap();
}
match FrontendAssets::get("index.html") {
Some(content) => Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-store")
.body(boxed(Full::from(content.data)))
.unwrap(),
None => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(boxed(Full::from("frontend not found")))
.unwrap(),
}
}