From 30c0c96d6b57bd1d64c2e3dedf2d4642ae175885 Mon Sep 17 00:00:00 2001 From: Mathieu JESER Date: Wed, 21 Oct 2026 20:12:13 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(admin):=20g=C3=A9rer=20un=20worker=20C?= =?UTF-8?q?ursor=20Self-Hosted=20pour=20tous=20les=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose Admin → Worker Cursor : lance agent worker start avec un --worker-dir par checkout DevForge, pour que Cloud Agents / Grok Bot exécutent sur la machine qui a déjà les repos. Co-authored-by: Cursor --- apps/server/src/cursor_worker.rs | 612 ++++++++++++++++++ apps/server/src/cursor_worker_routes.rs | 226 +++++++ apps/server/src/db.rs | 6 + apps/server/src/main.rs | 4 + apps/web/src/components/AdminPage.tsx | 53 +- .../components/CursorWorkerSettingsPanel.tsx | 339 ++++++++++ apps/web/src/lib/api.ts | 92 +++ docs/MODULES.md | 4 + 8 files changed, 1332 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/cursor_worker.rs create mode 100644 apps/server/src/cursor_worker_routes.rs create mode 100644 apps/web/src/components/CursorWorkerSettingsPanel.tsx diff --git a/apps/server/src/cursor_worker.rs b/apps/server/src/cursor_worker.rs new file mode 100644 index 0000000000..b3f2a595e0 --- /dev/null +++ b/apps/server/src/cursor_worker.rs @@ -0,0 +1,612 @@ +//! Worker Cursor Self-Hosted (My Machines) géré par DevForge. +//! +//! Lance `agent worker … start` avec un `--worker-dir` par checkout projet, +//! pour que Cursor / Grok Bot exécutent les tool calls sur la machine +//! qui contient déjà tous les repos. + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sqlx::PgPool; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +const STATE_DIR_NAME: &str = "cursor-worker"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkerDir { + pub project_uuid: String, + pub project_name: String, + pub path: String, + pub exists: bool, + pub has_git: bool, + pub git_remote: Option, + /// Inclus dans le prochain / dernier démarrage (existe + .git). + pub eligible: bool, +} + +#[derive(Debug, Clone)] +pub struct WorkerSettings { + pub enabled: bool, + pub name: String, + pub api_key_sealed: String, + pub binary: String, + pub extra_dirs: String, +} + +impl Default for WorkerSettings { + fn default() -> Self { + Self { + enabled: false, + name: String::new(), + api_key_sealed: String::new(), + binary: String::new(), + extra_dirs: String::new(), + } + } +} + +pub fn state_dir() -> PathBuf { + data_dir_base().join(STATE_DIR_NAME) +} + +fn data_dir_base() -> PathBuf { + if let Ok(v) = std::env::var("DEVFORGE_DATA_DIR") { + return PathBuf::from(v); + } + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("data") +} + +fn pid_path() -> PathBuf { + state_dir().join("worker.pid") +} + +fn out_path() -> PathBuf { + state_dir().join("worker.out") +} + +fn err_path() -> PathBuf { + state_dir().join("worker.err") +} + +fn registered_path() -> PathBuf { + state_dir().join("registered.json") +} + +fn ensure_state_dir() -> Result<(), String> { + std::fs::create_dir_all(state_dir()).map_err(|e| e.to_string()) +} + +pub async fn load_settings(pool: &PgPool) -> WorkerSettings { + let row: Option<(i64, String, String, String, String)> = sqlx::query_as( + r#"SELECT COALESCE(cursor_worker_enabled, 0), + COALESCE(cursor_worker_name, ''), + COALESCE(cursor_worker_api_key, ''), + COALESCE(cursor_worker_binary, ''), + COALESCE(cursor_worker_extra_dirs, '') + FROM instance_settings WHERE id = 1"#, + ) + .fetch_optional(pool) + .await + .ok() + .flatten(); + match row { + Some((en, name, key, bin, extra)) => WorkerSettings { + enabled: en != 0, + name, + api_key_sealed: key, + binary: bin, + extra_dirs: extra, + }, + None => WorkerSettings::default(), + } +} + +pub fn resolve_worker_name(settings: &WorkerSettings) -> String { + let n = settings.name.trim(); + if !n.is_empty() { + return n.to_string(); + } + hostname_fallback() +} + +fn hostname_fallback() -> String { + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "devforge".into()) +} + +pub fn api_key_plain(settings: &WorkerSettings) -> String { + let raw = settings.api_key_sealed.trim(); + if raw.is_empty() { + return String::new(); + } + crate::secret_box::open_compat(raw) +} + +pub fn mask_key(plain: &str) -> String { + let t = plain.trim(); + if t.is_empty() { + return String::new(); + } + if t.len() <= 8 { + return "••••".into(); + } + format!("{}…{}", &t[..4], &t[t.len().saturating_sub(4)..]) +} + +/// Résout le binaire `agent` (chemin custom ou PATH). +pub fn resolve_agent_binary(settings: &WorkerSettings) -> Option { + let custom = settings.binary.trim(); + if !custom.is_empty() { + let p = PathBuf::from(custom); + if p.is_file() { + return Some(p); + } + return None; + } + which_agent() +} + +fn which_agent() -> Option { + #[cfg(windows)] + let cmd = Command::new("where").arg("agent").output(); + #[cfg(not(windows))] + let cmd = Command::new("sh") + .args(["-c", "command -v agent"]) + .output(); + let Ok(out) = cmd else { + return None; + }; + if !out.status.success() { + return None; + } + let line = String::from_utf8_lossy(&out.stdout); + let path = line.lines().next()?.trim(); + if path.is_empty() { + return None; + } + let p = PathBuf::from(path); + p.is_file().then_some(p) +} + +pub fn agent_version(bin: &Path) -> Option { + let out = Command::new(bin).arg("--version").output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout); + let line = s.lines().next()?.trim(); + (!line.is_empty()).then(|| line.to_string()) +} + +pub async fn list_project_workdirs(pool: &PgPool) -> Result, String> { + let rows: Vec<(String, String, String, String)> = sqlx::query_as( + r#"SELECT uuid, name, COALESCE(workdir, ''), COALESCE(git_repository, '') + FROM projects ORDER BY name COLLATE "C""#, + ) + .fetch_all(pool) + .await + .map_err(|e| e.to_string())?; + + let mut dirs = Vec::with_capacity(rows.len()); + for (uuid, name, workdir, _repo) in rows { + let path = devforge_deploy::resolve_project_workdir(&workdir, &uuid); + let p = Path::new(&path); + let exists = p.is_dir(); + let has_git = exists && (p.join(".git").exists()); + let git_remote = if has_git { + read_git_remote(p) + } else { + None + }; + let eligible = exists && has_git; + dirs.push(WorkerDir { + project_uuid: uuid, + project_name: name, + path, + exists, + has_git, + git_remote, + eligible, + }); + } + Ok(dirs) +} + +fn read_git_remote(workdir: &Path) -> Option { + let out = Command::new("git") + .args(["-C", &workdir.to_string_lossy(), "remote", "get-url", "origin"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!s.is_empty()).then_some(s) +} + +pub fn parse_extra_dirs(raw: &str) -> Vec { + raw.lines() + .flat_map(|line| line.split([',', ';'])) + .map(str::trim) + .filter(|s| !s.is_empty() && !s.starts_with('#')) + .map(str::to_string) + .collect() +} + +pub fn eligible_paths(dirs: &[WorkerDir], extra_raw: &str) -> Vec { + let mut paths: Vec = dirs + .iter() + .filter(|d| d.eligible) + .map(|d| d.path.clone()) + .collect(); + for extra in parse_extra_dirs(extra_raw) { + let p = Path::new(&extra); + if p.is_dir() && p.join(".git").exists() && !paths.iter().any(|x| x == &extra) { + paths.push(extra); + } + } + paths +} + +/// Construit les arguments CLI (sans le binaire) pour tests / démarrage. +pub fn build_worker_args(name: &str, workdirs: &[String], api_key: &str) -> Result, String> { + if workdirs.is_empty() { + return Err( + "Aucun workdir Git éligible. Clone les repos projets ou ajoute des chemins extra." + .into(), + ); + } + let mut args = vec!["worker".into()]; + let name = name.trim(); + if !name.is_empty() { + args.push("--name".into()); + args.push(name.to_string()); + } + for dir in workdirs { + args.push("--worker-dir".into()); + args.push(dir.clone()); + } + args.push("start".into()); + let key = api_key.trim(); + if !key.is_empty() { + args.push("--api-key".into()); + args.push(key.to_string()); + } + Ok(args) +} + +pub fn read_pid() -> Option { + let raw = std::fs::read_to_string(pid_path()).ok()?; + let pid: u32 = raw.trim().parse().ok()?; + (pid > 0).then_some(pid) +} + +pub fn pid_is_alive(pid: u32) -> bool { + if pid == 0 { + return false; + } + #[cfg(target_os = "linux")] + { + let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) else { + return false; + }; + for line in status.lines() { + if let Some(rest) = line.strip_prefix("State:") { + return !rest.trim().starts_with('Z'); + } + } + true + } + #[cfg(all(windows, not(target_os = "linux")))] + { + Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/NH"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) + .unwrap_or(true) + } + #[cfg(all(unix, not(target_os = "linux")))] + { + Command::new("kill") + .args(["-0", &pid.to_string()]) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } +} + +pub fn is_running() -> bool { + read_pid().is_some_and(pid_is_alive) +} + +fn kill_pid(pid: u32) -> Result<(), String> { + if pid == 0 { + return Ok(()); + } + #[cfg(windows)] + { + let status = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .map_err(|e| e.to_string())?; + if !status.success() && pid_is_alive(pid) { + return Err(format!("taskkill a échoué pour pid {pid}")); + } + } + #[cfg(unix)] + { + let _ = Command::new("kill") + .args(["-TERM", &pid.to_string()]) + .status(); + std::thread::sleep(std::time::Duration::from_millis(400)); + if pid_is_alive(pid) { + let _ = Command::new("kill") + .args(["-KILL", &pid.to_string()]) + .status(); + } + } + Ok(()) +} + +pub fn stop() -> Result { + let Some(pid) = read_pid() else { + let _ = std::fs::remove_file(pid_path()); + return Ok(json!({ + "ok": true, + "running": false, + "message": "Aucun worker en cours", + })); + }; + if !pid_is_alive(pid) { + let _ = std::fs::remove_file(pid_path()); + return Ok(json!({ + "ok": true, + "running": false, + "message": "Process déjà arrêté", + })); + } + kill_pid(pid)?; + let _ = std::fs::remove_file(pid_path()); + Ok(json!({ + "ok": true, + "running": false, + "stopped_pid": pid, + "message": format!("Worker arrêté (pid {pid})"), + })) +} + +pub fn read_logs_tail(max_lines: usize) -> String { + let mut chunks = Vec::new(); + for path in [out_path(), err_path()] { + if let Ok(raw) = std::fs::read_to_string(&path) { + let lines: Vec<&str> = raw.lines().collect(); + let start = lines.len().saturating_sub(max_lines); + if start < lines.len() { + chunks.push(format!( + "—— {} ——\n{}", + path.file_name().and_then(|s| s.to_str()).unwrap_or("log"), + lines[start..].join("\n") + )); + } + } + } + chunks.join("\n\n") +} + +pub fn load_registered() -> Vec { + let Ok(raw) = std::fs::read_to_string(registered_path()) else { + return Vec::new(); + }; + serde_json::from_str::>(&raw).unwrap_or_default() +} + +fn save_registered(paths: &[String]) -> Result<(), String> { + ensure_state_dir()?; + let raw = serde_json::to_string_pretty(paths).map_err(|e| e.to_string())?; + std::fs::write(registered_path(), raw).map_err(|e| e.to_string()) +} + +pub fn start(settings: &WorkerSettings, workdirs: &[String]) -> Result { + if is_running() { + return Err("Worker déjà en cours — arrête-le avant de relancer.".into()); + } + let bin = resolve_agent_binary(settings).ok_or_else(|| { + "CLI Cursor introuvable (`agent`). Installe-le sur la machine : curl https://cursor.com/install -fsS | bash — ou indique le chemin du binaire.".to_string() + })?; + let name = resolve_worker_name(settings); + let api_key = api_key_plain(settings); + if api_key.trim().is_empty() { + return Err( + "Clé API Cursor manquante. Dashboard Cursor → API Keys (clé personnelle), puis enregistre-la ici." + .into(), + ); + } + let args = build_worker_args(&name, workdirs, &api_key)?; + ensure_state_dir()?; + let out = std::fs::File::create(out_path()).map_err(|e| e.to_string())?; + let err = std::fs::File::create(err_path()).map_err(|e| e.to_string())?; + + let mut cmd = Command::new(&bin); + cmd.args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::from(out)) + .stderr(Stdio::from(err)); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + const DETACHED_PROCESS: u32 = 0x00000008; + cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS); + } + + let child = cmd + .spawn() + .map_err(|e| format!("Échec démarrage `{}`: {e}", bin.display()))?; + let pid = child.id(); + std::fs::write(pid_path(), pid.to_string()).map_err(|e| e.to_string())?; + save_registered(workdirs)?; + + let _ = std::thread::Builder::new() + .name("cursor-worker-reap".into()) + .spawn(move || { + let mut child = child; + let _ = child.wait(); + }); + + // Laisse le process s’initialiser un court instant. + std::thread::sleep(std::time::Duration::from_millis(600)); + if !pid_is_alive(pid) { + let logs = read_logs_tail(40); + let _ = std::fs::remove_file(pid_path()); + return Err(format!( + "Le worker s’est arrêté immédiatement. Vérifie la clé API et le réseau sortant (api2.cursor.sh).\n{logs}" + )); + } + + Ok(json!({ + "ok": true, + "running": true, + "pid": pid, + "name": name, + "binary": bin.display().to_string(), + "workdirs": workdirs, + "workdirs_count": workdirs.len(), + "message": format!("Worker « {name} » démarré avec {} repo(s)", workdirs.len()), + })) +} + +pub fn restart(settings: &WorkerSettings, workdirs: &[String]) -> Result { + let _ = stop(); + std::thread::sleep(std::time::Duration::from_millis(300)); + start(settings, workdirs) +} + +pub async fn status_json(pool: &PgPool) -> Result { + let settings = load_settings(pool).await; + let dirs = list_project_workdirs(pool).await?; + let eligible = eligible_paths(&dirs, &settings.extra_dirs); + let bin = resolve_agent_binary(&settings); + let version = bin.as_ref().and_then(|p| agent_version(p)); + let running = is_running(); + let pid = read_pid().filter(|_| running); + let plain = api_key_plain(&settings); + let name = resolve_worker_name(&settings); + + Ok(json!({ + "ok": true, + "running": running, + "pid": pid, + "name": name, + "cli_installed": bin.is_some(), + "cli_path": bin.as_ref().map(|p| p.display().to_string()), + "cli_version": version, + "api_key_set": !plain.is_empty(), + "api_key_masked": if plain.is_empty() { Value::Null } else { json!(mask_key(&plain)) }, + "enabled": settings.enabled, + "settings": { + "name": settings.name, + "binary": settings.binary, + "extra_dirs": settings.extra_dirs, + "enabled": settings.enabled, + }, + "workdirs": dirs, + "eligible_count": eligible.len(), + "eligible_paths": eligible, + "registered_paths": load_registered(), + "logs_tail": if running || err_path().is_file() || out_path().is_file() { + read_logs_tail(30) + } else { + String::new() + }, + "hint": if bin.is_none() { + "Installe le CLI Cursor sur cette machine : curl https://cursor.com/install -fsS | bash" + } else if plain.is_empty() { + "Ajoute une clé API personnelle Cursor (Dashboard → API Keys)." + } else if eligible.is_empty() { + "Aucun checkout Git prêt. Les workdirs projets doivent exister avec un dépôt .git." + } else if !running { + "Prêt à démarrer. Les agents Cursor / Grok Bot pourront cibler cette machine (My Machines)." + } else { + "Worker connecté. Dans cursor.com/agents ou Grok Bot, choisis cette machine (worker=)." + }, + })) +} + +/// Au boot : si auto-start activé, lance le worker après un délai. +pub fn spawn_autostart(pool: PgPool) { + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(20)).await; + let settings = load_settings(&pool).await; + if !settings.enabled { + return; + } + if is_running() { + tracing::info!("cursor-worker déjà actif"); + return; + } + let dirs = match list_project_workdirs(&pool).await { + Ok(d) => d, + Err(e) => { + tracing::warn!(error = %e, "cursor-worker autostart: list workdirs"); + return; + } + }; + let paths = eligible_paths(&dirs, &settings.extra_dirs); + match start(&settings, &paths) { + Ok(v) => tracing::info!(?v, "cursor-worker autostart ok"), + Err(e) => tracing::warn!(error = %e, "cursor-worker autostart"), + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_args_multi_dirs() { + let args = build_worker_args( + "demeter", + &[ + "/data/devforge/applications/a".into(), + "/data/devforge/applications/b".into(), + ], + "key_test", + ) + .unwrap(); + assert_eq!(args[0], "worker"); + assert!(args.contains(&"--name".into())); + assert!(args.contains(&"demeter".into())); + let dir_flags = args.iter().filter(|a| *a == "--worker-dir").count(); + assert_eq!(dir_flags, 2); + assert!(args.contains(&"start".into())); + assert!(args.contains(&"--api-key".into())); + assert!(args.contains(&"key_test".into())); + } + + #[test] + fn build_args_rejects_empty() { + assert!(build_worker_args("x", &[], "k").is_err()); + } + + #[test] + fn parse_extra_dirs_mixed() { + let v = parse_extra_dirs("/a\n# comment\n/b, /c ; /d"); + assert_eq!(v, vec!["/a", "/b", "/c", "/d"]); + } + + #[test] + fn mask_key_short_and_long() { + assert_eq!(mask_key(""), ""); + assert_eq!(mask_key("abcd"), "••••"); + let m = mask_key("abcdefghijklmnop"); + assert!(m.starts_with("abcd")); + assert!(m.ends_with("mnop")); + } +} diff --git a/apps/server/src/cursor_worker_routes.rs b/apps/server/src/cursor_worker_routes.rs new file mode 100644 index 0000000000..08492b5804 --- /dev/null +++ b/apps/server/src/cursor_worker_routes.rs @@ -0,0 +1,226 @@ +//! API admin : worker Cursor Self-Hosted (My Machines). + +use axum::{ + extract::State, + http::HeaderMap, + response::Json, + routing::{get, patch, post}, + Router, +}; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::cursor_worker::{self, WorkerSettings}; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/api/v1/cursor-worker/status", get(status)) + .route("/api/v1/cursor-worker/start", post(start)) + .route("/api/v1/cursor-worker/stop", post(stop)) + .route("/api/v1/cursor-worker/restart", post(restart)) + .route( + "/api/v1/cursor-worker/settings", + get(get_settings).patch(patch_settings), + ) +} + +async fn require_admin( + state: &AppState, + headers: &HeaderMap, +) -> Result<(), (axum::http::StatusCode, Json)> { + let (user, _) = crate::auth_routes::current_workspace(state, headers).await?; + if user.role != "instance_admin" { + return Err(( + axum::http::StatusCode::FORBIDDEN, + Json(json!({"error": "Réservé à l'administrateur d'instance"})), + )); + } + Ok(()) +} + +fn map_err(e: String) -> (axum::http::StatusCode, Json) { + ( + axum::http::StatusCode::BAD_REQUEST, + Json(json!({"ok": false, "error": e})), + ) +} + +async fn status( + State(state): State, + headers: HeaderMap, +) -> Result, (axum::http::StatusCode, Json)> { + require_admin(&state, &headers).await?; + let v = cursor_worker::status_json(&state.pool) + .await + .map_err(map_err)?; + Ok(Json(v)) +} + +async fn start( + State(state): State, + headers: HeaderMap, +) -> Result, (axum::http::StatusCode, Json)> { + require_admin(&state, &headers).await?; + let settings = cursor_worker::load_settings(&state.pool).await; + let dirs = cursor_worker::list_project_workdirs(&state.pool) + .await + .map_err(map_err)?; + let paths = cursor_worker::eligible_paths(&dirs, &settings.extra_dirs); + let v = cursor_worker::start(&settings, &paths).map_err(map_err)?; + Ok(Json(v)) +} + +async fn stop( + State(state): State, + headers: HeaderMap, +) -> Result, (axum::http::StatusCode, Json)> { + require_admin(&state, &headers).await?; + let v = cursor_worker::stop().map_err(map_err)?; + Ok(Json(v)) +} + +async fn restart( + State(state): State, + headers: HeaderMap, +) -> Result, (axum::http::StatusCode, Json)> { + require_admin(&state, &headers).await?; + let settings = cursor_worker::load_settings(&state.pool).await; + let dirs = cursor_worker::list_project_workdirs(&state.pool) + .await + .map_err(map_err)?; + let paths = cursor_worker::eligible_paths(&dirs, &settings.extra_dirs); + let v = cursor_worker::restart(&settings, &paths).map_err(map_err)?; + Ok(Json(v)) +} + +async fn get_settings( + State(state): State, + headers: HeaderMap, +) -> Result, (axum::http::StatusCode, Json)> { + require_admin(&state, &headers).await?; + let s = cursor_worker::load_settings(&state.pool).await; + let plain = cursor_worker::api_key_plain(&s); + Ok(Json(json!({ + "ok": true, + "enabled": s.enabled, + "name": s.name, + "binary": s.binary, + "extra_dirs": s.extra_dirs, + "resolved_name": cursor_worker::resolve_worker_name(&s), + "api_key_set": !plain.is_empty(), + "api_key_masked": if plain.is_empty() { + Value::Null + } else { + json!(cursor_worker::mask_key(&plain)) + }, + }))) +} + +#[derive(Deserialize)] +struct PatchBody { + #[serde(default)] + enabled: Option, + #[serde(default)] + name: Option, + #[serde(default)] + binary: Option, + #[serde(default)] + extra_dirs: Option, + /// Nouvelle clé API (clair). Chaîne vide = effacer. Absent = ne pas toucher. + #[serde(default)] + api_key: Option, +} + +async fn patch_settings( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Result, (axum::http::StatusCode, Json)> { + require_admin(&state, &headers).await?; + if body.enabled.is_none() + && body.name.is_none() + && body.binary.is_none() + && body.extra_dirs.is_none() + && body.api_key.is_none() + { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(json!({"error": "Rien à modifier"})), + )); + } + + let now = chrono::Utc::now().to_rfc3339(); + let current = cursor_worker::load_settings(&state.pool).await; + + let enabled = body.enabled.unwrap_or(current.enabled); + let name = body + .name + .map(|s| s.trim().to_string()) + .unwrap_or(current.name); + let binary = body + .binary + .map(|s| s.trim().to_string()) + .unwrap_or(current.binary); + let extra_dirs = body.extra_dirs.unwrap_or(current.extra_dirs); + + let api_key_sealed = match body.api_key { + None => current.api_key_sealed, + Some(k) => { + let t = k.trim(); + if t.is_empty() { + String::new() + } else { + crate::secret_box::seal_compat(t) + } + } + }; + + sqlx::query( + r#"UPDATE instance_settings SET + cursor_worker_enabled = $1, + cursor_worker_name = $2, + cursor_worker_api_key = $3, + cursor_worker_binary = $4, + cursor_worker_extra_dirs = $5, + updated_at = $6 + WHERE id = 1"#, + ) + .bind(if enabled { 1i64 } else { 0 }) + .bind(&name) + .bind(&api_key_sealed) + .bind(&binary) + .bind(&extra_dirs) + .bind(&now) + .execute(&state.pool) + .await + .map_err(|e| { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + })?; + + let s = WorkerSettings { + enabled, + name, + api_key_sealed, + binary, + extra_dirs, + }; + let plain = cursor_worker::api_key_plain(&s); + Ok(Json(json!({ + "ok": true, + "enabled": s.enabled, + "name": s.name, + "binary": s.binary, + "extra_dirs": s.extra_dirs, + "resolved_name": cursor_worker::resolve_worker_name(&s), + "api_key_set": !plain.is_empty(), + "api_key_masked": if plain.is_empty() { + Value::Null + } else { + json!(cursor_worker::mask_key(&plain)) + }, + }))) +} diff --git a/apps/server/src/db.rs b/apps/server/src/db.rs index 5adbb6f232..2000a29bab 100644 --- a/apps/server/src/db.rs +++ b/apps/server/src/db.rs @@ -485,6 +485,12 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { // Ancien webhook par dépôt (`/api/v1/webhooks/github`) : 1 = actif, 0 = 410 Gone. // Piloté depuis Réglages › GitHub (pas de variable d’environnement). ("legacy_webhook", "BIGINT NOT NULL DEFAULT 1"), + // Worker Cursor Self-Hosted (My Machines) — CLI `agent worker start`. + ("cursor_worker_enabled", "BIGINT NOT NULL DEFAULT 0"), + ("cursor_worker_name", "TEXT NOT NULL DEFAULT ''"), + ("cursor_worker_api_key", "TEXT NOT NULL DEFAULT ''"), + ("cursor_worker_binary", "TEXT NOT NULL DEFAULT ''"), + ("cursor_worker_extra_dirs", "TEXT NOT NULL DEFAULT ''"), ] { let sql = format!("ALTER TABLE instance_settings ADD COLUMN {col} {def}"); let _ = sqlx::query(&sql).execute(pool).await; diff --git a/apps/server/src/main.rs b/apps/server/src/main.rs index 1f9a6d1b80..327a18cfe6 100644 --- a/apps/server/src/main.rs +++ b/apps/server/src/main.rs @@ -16,6 +16,8 @@ mod cluster_store; mod control_pg; mod correction; mod cron_routes; +mod cursor_worker; +mod cursor_worker_routes; mod db; mod deploy_queue; mod detect_svc; @@ -354,6 +356,7 @@ async fn main() -> Result<(), Box> { } update_routes::spawn_auto_update(state.clone()); + cursor_worker::spawn_autostart(state.pool.clone()); // Advertise agent tools on the local MCP server surface. state @@ -399,6 +402,7 @@ async fn main() -> Result<(), Box> { .merge(mobile_routes::router()) .merge(android_routes::router()) .merge(cluster_routes::router()) + .merge(cursor_worker_routes::router()) .merge(worker::exec_route()) .route("/", get(auth_routes::marketing_home)) .route("/index.html", get(auth_routes::marketing_home)) diff --git a/apps/web/src/components/AdminPage.tsx b/apps/web/src/components/AdminPage.tsx index 3187b4f919..a5f61b8f44 100644 --- a/apps/web/src/components/AdminPage.tsx +++ b/apps/web/src/components/AdminPage.tsx @@ -9,6 +9,7 @@ import { BackupSettingsPanel } from './BackupSettingsPanel'; import { PostgresAdminPanel } from './PostgresAdminPanel'; import { SsoSettingsPanel } from './SsoSettingsPanel'; import { UpdateSettingsPanel } from './UpdateSettingsPanel'; +import { CursorWorkerSettingsPanel } from './CursorWorkerSettingsPanel'; import { Alert, Badge, @@ -70,7 +71,8 @@ type AdminSection = | 'backup' | 'update' | 'domaine' - | 'cluster'; + | 'cluster' + | 'cursor-worker'; const SECTION_TITLES: Record, string> = { workspaces: 'Workspaces', @@ -84,6 +86,7 @@ const SECTION_TITLES: Record, string> = { update: 'Mise à jour', domaine: 'Domaine principal', cluster: 'Cluster', + 'cursor-worker': 'Worker Cursor', }; const ADMIN_TABS: AdminSection[] = [ @@ -98,6 +101,7 @@ const ADMIN_TABS: AdminSection[] = [ 'update', 'domaine', 'cluster', + 'cursor-worker', ]; type BetaFeatures = { @@ -207,6 +211,11 @@ function AdminHub() { const [dns, setDns] = useState<{ configured: boolean; zone: string } | null>(null); const [wildcard, setWildcard] = useState(null); const [features, setFeatures] = useState(null); + const [cursorWorker, setCursorWorker] = useState<{ + running: boolean; + eligible: number; + cli: boolean; + } | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { @@ -228,6 +237,7 @@ function AdminHub() { api.backupS3Get(), api.dnsSettings(), api.bootstrap(), + api.cursorWorkerStatus(), ]); const [proxyRes, healthRes, adminRes] = await core; if (cancelled) return; @@ -237,8 +247,18 @@ function AdminHub() { if (adminRes.status === 'fulfilled') setStats(adminRes.value.stats); setLoading(false); - const [clusterRes, pgRes, sshRes, runnersRes, ssoRes, autoRes, s3Res, dnsRes, bootRes] = - await rest; + const [ + clusterRes, + pgRes, + sshRes, + runnersRes, + ssoRes, + autoRes, + s3Res, + dnsRes, + bootRes, + cursorRes, + ] = await rest; if (cancelled) return; if (clusterRes.status === 'fulfilled') { setNodes(clusterRes.value.nodes); @@ -247,6 +267,13 @@ function AdminHub() { interim: !!clusterRes.value.acting_leader, }); } + if (cursorRes.status === 'fulfilled') { + setCursorWorker({ + running: !!cursorRes.value.running, + eligible: cursorRes.value.eligible_count ?? 0, + cli: !!cursorRes.value.cli_installed, + }); + } if (pgRes.status === 'fulfilled') { setPostgres({ ready: !!pgRes.value.postgres?.ready, @@ -452,6 +479,14 @@ function AdminHub() { ? tileStatus('1/2', 'warn', '1 active') : tileStatus('Off', 'neutral', 'Masquées'); + const cursorTile = cursorWorker + ? cursorWorker.running + ? tileStatus('ON', 'ok', `${cursorWorker.eligible} repo(s)`) + : !cursorWorker.cli + ? tileStatus('CLI', 'warn', 'À installer') + : tileStatus('Off', 'neutral', `${cursorWorker.eligible} repo(s)`) + : null; + return ( - + } + badge={cursorTile?.badge} + subtitle={cursorTile?.subtitle} + /> + ); } @@ -1193,6 +1237,7 @@ function AdminPageInner() { {section === 'backup' && } {section === 'update' && } {section === 'cluster' && } + {section === 'cursor-worker' && } ); diff --git a/apps/web/src/components/CursorWorkerSettingsPanel.tsx b/apps/web/src/components/CursorWorkerSettingsPanel.tsx new file mode 100644 index 0000000000..fe6bee78cb --- /dev/null +++ b/apps/web/src/components/CursorWorkerSettingsPanel.tsx @@ -0,0 +1,339 @@ +import { useEffect, useState } from 'preact/hooks'; +import { api } from '../lib/api'; +import { + Alert, + Badge, + Button, + Card, + CardHeader, + FadeIn, + Input, + PulseDot, + Skeleton, + Switch, + Table, + Td, + Tr, + useToast, +} from './ui'; + +type WorkdirRow = { + project_uuid: string; + project_name: string; + path: string; + exists: boolean; + has_git: boolean; + git_remote?: string | null; + eligible: boolean; +}; + +type Status = { + ok: boolean; + running: boolean; + pid?: number | null; + name: string; + cli_installed: boolean; + cli_path?: string | null; + cli_version?: string | null; + api_key_set: boolean; + api_key_masked?: string | null; + enabled: boolean; + settings: { + name: string; + binary: string; + extra_dirs: string; + enabled: boolean; + }; + workdirs: WorkdirRow[]; + eligible_count: number; + eligible_paths: string[]; + registered_paths: string[]; + logs_tail: string; + hint: string; +}; + +export function CursorWorkerSettingsPanel({ isAdmin }: { isAdmin: boolean }) { + const toast = useToast(); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState<'start' | 'stop' | 'restart' | 'save' | null>(null); + const [status, setStatus] = useState(null); + const [name, setName] = useState(''); + const [binary, setBinary] = useState(''); + const [extraDirs, setExtraDirs] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [enabled, setEnabled] = useState(false); + const [showLogs, setShowLogs] = useState(false); + + async function load() { + setLoading(true); + try { + const s = await api.cursorWorkerStatus(); + setStatus(s); + setName(s.settings?.name || ''); + setBinary(s.settings?.binary || ''); + setExtraDirs(s.settings?.extra_dirs || ''); + setEnabled(!!s.enabled); + } catch (e) { + toast.push({ title: 'Cursor Worker', detail: String(e), tone: 'danger' }); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (!isAdmin) { + setLoading(false); + return; + } + void load(); + const t = setInterval(() => void load(), 8000); + return () => clearInterval(t); + }, [isAdmin]); + + async function saveSettings(patch?: { enabled?: boolean }) { + setBusy('save'); + try { + const body: { + enabled?: boolean; + name?: string; + binary?: string; + extra_dirs?: string; + api_key?: string; + } = { + enabled: patch?.enabled ?? enabled, + name, + binary, + extra_dirs: extraDirs, + }; + if (apiKey.trim()) body.api_key = apiKey.trim(); + const r = await api.cursorWorkerPatchSettings(body); + setEnabled(!!r.enabled); + setApiKey(''); + toast.push({ title: 'Réglages enregistrés', tone: 'ok' }); + await load(); + } catch (e) { + toast.push({ title: 'Enregistrement KO', detail: String(e), tone: 'danger' }); + } finally { + setBusy(null); + } + } + + async function run(action: 'start' | 'stop' | 'restart') { + setBusy(action); + try { + const r = + action === 'start' + ? await api.cursorWorkerStart() + : action === 'stop' + ? await api.cursorWorkerStop() + : await api.cursorWorkerRestart(); + toast.push({ + title: action === 'stop' ? 'Worker arrêté' : 'Worker démarré', + detail: r.message || undefined, + tone: 'ok', + }); + await load(); + } catch (e) { + toast.push({ title: 'Action KO', detail: String(e), tone: 'danger' }); + await load(); + } finally { + setBusy(null); + } + } + + if (!isAdmin) { + return Réservé à l’administrateur d’instance.; + } + + if (loading && !status) { + return ( +
+ + +
+ ); + } + + const running = !!status?.running; + const eligible = status?.eligible_count ?? 0; + + return ( + + + + + + {running ? `En ligne · pid ${status?.pid ?? '?'}` : 'Arrêté'} + + + } + /> +
+

+ Machine annoncée :{' '} + {status?.name || '…'} + {status?.cli_installed ? ( + <> + {' '} + · CLI {status.cli_version || 'ok'} + {status.cli_path ? ( + {status.cli_path} + ) : null} + + ) : ( + <> · CLI absente + )} + {' · '} + {eligible} repo{eligible === 1 ? '' : 's'} éligible{eligible === 1 ? '' : 's'} +

+ {status?.hint ? {status.hint} : null} + + Le worker déplace l’exécution (fichiers, terminal) ici — la facturation des modèles reste + celle de Cursor. Pour Grok Bot / Slack :{' '} + worker={status?.name || 'nom'}. + +
+ + + + +
+
+
+ + + +
+
+
+
Démarrage auto
+
+ Relance le worker au boot de DevForge (si CLI + clé OK). +
+
+ { + const next = !enabled; + setEnabled(next); + void saveSettings({ enabled: next }); + }} + /> +
+ setName((e.target as HTMLInputElement).value)} + /> + setApiKey((e.target as HTMLInputElement).value)} + autocomplete="off" + /> + setBinary((e.target as HTMLInputElement).value)} + /> +