diff --git a/apps/server/src/auto_deploy.rs b/apps/server/src/auto_deploy.rs index 1efa1ac76..1d390f584 100644 --- a/apps/server/src/auto_deploy.rs +++ b/apps/server/src/auto_deploy.rs @@ -210,8 +210,7 @@ pub(crate) async fn deploy_project( if let Some(sha) = target_sha { crate::auto_deploy_guard::set_target_sha(&state.pool, &dep_uuid, sha).await; } - // Le déploiement fait un `reset --hard` du workdir : le brouillon en cours est sauvegardé. - crate::auto_deploy_guard::backup_draft_before_reset(state, project).await; + // Le brouillon en cours est sauvegardé par `run_real_deploy_at` (tous les déploiements). let _ = sqlx::query("UPDATE projects SET status = 'deploying', updated_at = $1 WHERE id = $2") .bind(&now) diff --git a/apps/server/src/auto_deploy_guard.rs b/apps/server/src/auto_deploy_guard.rs index 295b60ba9..bcbe01a9e 100644 --- a/apps/server/src/auto_deploy_guard.rs +++ b/apps/server/src/auto_deploy_guard.rs @@ -241,11 +241,12 @@ pub async fn escalate_once( /// Le reset du workdir par le déploiement ne doit pas effacer un brouillon en cours : /// sauvegarde restaurable (même mécanisme que « Supprimer le brouillon »). -pub async fn backup_draft_before_reset(state: &AppState, project: &Project) { +/// +/// Appelé par tous les déploiements prod (`run_real_deploy_at` : manuel, auto, GitHub). +/// `origin` : libellé de la sauvegarde (`deploy`, `auto-deploy`…). +pub async fn backup_draft_before_reset(state: &AppState, project: &Project, origin: &str) { + // Même résolution que le déploiement (vide → `/applications/`). let configured = project.workdir.as_deref().unwrap_or("").trim().to_string(); - if configured.is_empty() { - return; - } let wd = std::path::PathBuf::from(devforge_deploy::resolve_project_workdir( &configured, &project.uuid, @@ -259,28 +260,45 @@ pub async fn backup_draft_before_reset(state: &AppState, project: &Project) { std::env::var("DEVFORGE_DATA_DIR").unwrap_or_else(|_| "/data".into()), ); let root = crate::draft::backups_root(&data, &project.uuid); - let res = tokio::task::spawn_blocking(move || { - let st = crate::draft::status(&wd, &branch, None); - if !st.available || !st.dirty { - return Ok(None); + let reason = origin.to_string(); + let res = + tokio::task::spawn_blocking(move || backup_dirty_checkout(&wd, &branch, &root, &reason)) + .await; + match res { + Ok(Ok(Some((id, n)))) => { + crate::deploy_queue::record_event( + &state.pool, + &project.uuid, + "draft", + "backed_up", + &id, + &format!("{origin} · {n} fichier(s) du brouillon sauvegardés avant mise à jour"), + ) + .await; } - crate::draft::backup(&wd, &root, &st.files, "auto-deploy") - .map(|b| Some((b.id, st.files.len()))) - }) - .await; - if let Ok(Ok(Some((id, n)))) = res { - crate::deploy_queue::record_event( - &state.pool, - &project.uuid, - "draft", - "backed_up", - &id, - &format!("auto-deploy · {n} fichier(s) du brouillon sauvegardés avant mise à jour"), - ) - .await; + Ok(Err(e)) => { + tracing::warn!(project = %project.uuid, error = %e, "sauvegarde du brouillon avant déploiement impossible"); + } + _ => {} } } +/// Sauvegarde le brouillon d'un checkout s'il en a un. `Ok(None)` = rien à sauver. +pub fn backup_dirty_checkout( + workdir: &std::path::Path, + branch: &str, + root: &std::path::Path, + reason: &str, +) -> Result, String> { + let st = crate::draft::status(workdir, branch, None); + if !st.available || !st.dirty { + return Ok(None); + } + crate::draft::backup(workdir, root, &st.files, reason) + .map(|b| Some((b.id, st.files.len()))) + .map_err(|e| e.0) +} + #[cfg(test)] mod tests { use super::*; @@ -452,4 +470,45 @@ mod tests { && t.contains("Réessayer la mise en ligne") ); } + + /// Déploiement manuel ou automatique : un brouillon présent est sauvegardé + /// avant le `reset --hard`, un checkout propre ne crée rien. + #[cfg(unix)] + #[test] + fn backup_dirty_checkout_before_reset() { + let tmp = std::env::temp_dir().join(format!("guard-bk-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + std::fs::create_dir_all(&tmp).unwrap(); + let git = |args: &[&str]| { + std::process::Command::new("git") + .arg("-C") + .arg(&tmp) + .args(["-c", "user.name=t", "-c", "user.email=t@t"]) + .args(args) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }; + std::fs::write(tmp.join("a.txt"), "v1").unwrap(); + if !(git(&["init", "-q", "-b", "main"]) + && git(&["add", "."]) + && git(&["commit", "-q", "-m", "v1"])) + { + return; // git absent + } + let root = tmp.join("backups"); + assert_eq!( + backup_dirty_checkout(&tmp, "main", &root, "deploy").unwrap(), + None + ); + std::fs::write(tmp.join("a.txt"), "draft").unwrap(); + let (id, n) = backup_dirty_checkout(&tmp, "main", &root, "deploy") + .unwrap() + .expect("brouillon sauvegardé"); + assert_eq!(n, 1); + assert!(root.join(&id).join("files.tar.gz").exists()); + let manifest = std::fs::read_to_string(root.join(&id).join("manifest.json")).unwrap(); + assert!(manifest.contains("\"deploy\""), "{manifest}"); + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/apps/server/src/cursor_worker.rs b/apps/server/src/cursor_worker.rs index 2a9062bb1..a5fb78b6e 100644 --- a/apps/server/src/cursor_worker.rs +++ b/apps/server/src/cursor_worker.rs @@ -30,12 +30,21 @@ pub const INSTALL_URL: &str = "https://cursor.com/install"; pub struct WorkerDir { pub project_uuid: String, pub project_name: String, + /// Dossier servi au worker : le worktree `-cursor` (jamais le checkout de déploiement). pub path: String, + /// Checkout du projet (celui que les déploiements remettent à `origin/`). + pub source_path: String, + /// Branche de production du projet (base du worktree). + pub branch: String, pub exists: bool, pub has_git: bool, + /// Le worktree `-cursor` existe déjà (sinon il est créé au démarrage). + pub worktree_ready: bool, pub git_remote: Option, - /// Inclus dans le prochain / dernier démarrage (existe + .git). + /// Inclus dans le prochain / dernier démarrage (checkout du projet présent). pub eligible: bool, + /// Raison affichée quand le projet n'est pas servi. + pub note: Option, } #[derive(Debug, Clone)] @@ -398,8 +407,9 @@ pub fn agent_version(bin: &Path) -> Option { } 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, '') + let rows: Vec<(String, String, String, String, String)> = sqlx::query_as( + r#"SELECT uuid, name, COALESCE(workdir, ''), COALESCE(git_repository, ''), + COALESCE(git_branch, '') FROM projects ORDER BY name COLLATE "C""#, ) .fetch_all(pool) @@ -407,26 +417,245 @@ pub async fn list_project_workdirs(pool: &PgPool) -> Result, Stri .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, - }); + for (uuid, name, workdir, _repo, branch) in rows { + let source = devforge_deploy::resolve_project_workdir(&workdir, &uuid); + dirs.push(worker_dir_for(uuid, name, source, &branch)); } Ok(dirs) } +/// Décrit un projet pour le worker : le dossier servi est toujours le worktree +/// `-cursor`, jamais le checkout que les déploiements remettent à zéro. +pub fn worker_dir_for(uuid: String, name: String, source: String, branch: &str) -> WorkerDir { + let p = Path::new(&source); + 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 path = cursor_worktree_path(&source); + let worktree_ready = Path::new(&path).join(".git").exists(); + WorkerDir { + project_uuid: uuid, + project_name: name, + path, + source_path: source, + branch: safe_branch(branch).unwrap_or_else(|| "main".into()), + exists, + has_git, + worktree_ready, + git_remote, + eligible: has_git, + note: (!has_git).then(|| NO_CHECKOUT_NOTE.to_string()), + } +} + +// ───────────────────────── Worktree `-cursor` ───────────────────────── + +/// Suffixe du worktree dédié aux agents Cursor, à côté du checkout du projet +/// (comme `-dev` pour l'environnement dev). +pub const CURSOR_WORKTREE_SUFFIX: &str = "-cursor"; +/// Branche locale du worktree (les agents y travaillent, puis poussent / ouvrent une PR). +pub const CURSOR_BRANCH: &str = "cursor/devforge"; +pub const NO_CHECKOUT_NOTE: &str = "pas encore de checkout : déployer une fois"; + +/// `` → `-cursor`. +pub fn cursor_worktree_path(source: &str) -> String { + let s = source.trim_end_matches('/'); + format!("{s}{CURSOR_WORKTREE_SUFFIX}") +} + +/// Nom de branche sûr pour une commande git (sinon `None`). +pub fn safe_branch(raw: &str) -> Option { + let b = raw.trim(); + let ok = !b.is_empty() + && b.len() <= 200 + && !b.starts_with('-') + && !b.starts_with('/') + && !b.ends_with('/') + && !b.contains("..") + && !b.ends_with(".lock") + && b.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/')); + ok.then(|| b.to_string()) +} + +fn git_in(dir: &Path, args: &[&str]) -> Result { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .env("GIT_TERMINAL_PROMPT", "0") + .stdin(Stdio::null()) + .output() + .map_err(|e| format!("git introuvable : {e}"))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + let msg = String::from_utf8_lossy(&out.stderr).trim().to_string(); + Err(devforge_shared::redact::redact_urls_in_text(&msg)) + } +} + +/// Résultat de [`ensure_cursor_worktree`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CursorWorktree { + pub path: String, + pub created: bool, +} + +/// Crée le worktree `-cursor` s'il manque (`git worktree add` depuis +/// le checkout du projet, branche `cursor/devforge` partant de +/// `origin/`). Un worktree existant n'est jamais modifié. Un dossier +/// présent mais qui n'est pas un worktree est laissé tel quel (erreur). +/// +/// `branch` : branche de production ; vide → branche courante du checkout. +pub fn ensure_cursor_worktree( + source: &Path, + branch: Option<&str>, +) -> Result { + if !source.join(".git").exists() { + return Err(NO_CHECKOUT_NOTE.into()); + } + let src = source.to_string_lossy().to_string(); + let wt = cursor_worktree_path(&src); + let wt_path = Path::new(&wt); + if wt_path.join(".git").exists() { + return match git_in(wt_path, &["rev-parse", "--is-inside-work-tree"]) { + Ok(_) => Ok(CursorWorktree { + path: wt, + created: false, + }), + Err(e) => Err(format!("{wt} : worktree illisible, laissé tel quel ({e})")), + }; + } + if wt_path.exists() + && std::fs::read_dir(wt_path) + .map(|mut d| d.next().is_some()) + .unwrap_or(true) + { + return Err(format!( + "{wt} existe déjà sans dépôt Git : laissé tel quel, à vider ou renommer" + )); + } + let branch = branch + .and_then(safe_branch) + .or_else(|| { + git_in(source, &["symbolic-ref", "--short", "-q", "HEAD"]) + .ok() + .and_then(|b| safe_branch(&b)) + }) + .unwrap_or_else(|| "main".into()); + // Entrées orphelines (dossier `-cursor` supprimé à la main). + let _ = git_in(source, &["worktree", "prune"]); + let remote_ref = format!("refs/remotes/origin/{branch}"); + let base = if git_in(source, &["rev-parse", "--verify", "--quiet", &remote_ref]).is_ok() { + format!("origin/{branch}") + } else { + "HEAD".to_string() + }; + let local_branch = format!("refs/heads/{CURSOR_BRANCH}"); + let res = if git_in(source, &["show-ref", "--verify", "--quiet", &local_branch]).is_ok() { + // La branche existe (worktree recréé) : on la reprend telle quelle, sans perdre ses commits. + git_in(source, &["worktree", "add", "--quiet", &wt, CURSOR_BRANCH]) + } else { + git_in( + source, + &[ + "worktree", + "add", + "--quiet", + "--no-track", + "-b", + CURSOR_BRANCH, + &wt, + &base, + ], + ) + }; + res.map_err(|e| format!("{wt} : création du worktree impossible ({e})"))?; + Ok(CursorWorktree { + path: wt, + created: true, + }) +} + +/// Prépare les dossiers à servir : un worktree `-cursor` par projet cloné +/// (créé si besoin), puis les dossiers extra tels quels. +/// Renvoie (dossiers servis, erreurs lisibles). +pub fn prepare_worker_paths(dirs: &[WorkerDir], extra_raw: &str) -> (Vec, Vec) { + let mut paths = Vec::new(); + let mut errors = Vec::new(); + for d in dirs.iter().filter(|d| d.eligible) { + match ensure_cursor_worktree(Path::new(&d.source_path), Some(&d.branch)) { + Ok(w) => paths.push(w.path), + Err(e) => errors.push(format!("{} : {e}", d.project_name)), + } + } + for extra in extra_paths(extra_raw, &paths) { + paths.push(extra); + } + (paths, errors) +} + +fn extra_paths(extra_raw: &str, existing: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + for extra in parse_extra_dirs(extra_raw) { + let p = Path::new(&extra); + if p.is_dir() + && p.join(".git").exists() + && !existing.iter().any(|x| x == &extra) + && !out.contains(&extra) + { + out.push(extra); + } + } + out +} + +/// « Remettre à jour » : replace le worktree `-cursor` sur `origin/` +/// (branche `cursor/devforge`). Si des changements non publiés existent, ils +/// sont d'abord sauvegardés (même format que les sauvegardes du brouillon). +/// Renvoie l'identifiant de la sauvegarde éventuelle. +pub fn refresh_cursor_worktree( + worktree: &Path, + branch: &str, + backup_root: &Path, +) -> Result, String> { + if !worktree.join(".git").exists() { + return Err("worktree absent : il sera créé au prochain démarrage".into()); + } + let branch = safe_branch(branch).unwrap_or_else(|| "main".into()); + // Best effort : les refs `origin/*` sont partagées avec le checkout (déploiements). + let _ = crate::draft::fetch(worktree, &branch, None); + let st = crate::draft::status(worktree, &branch, None); + let mut backup_id = None; + let has_local = st.dirty || st.ahead > 0; + if has_local { + let b = crate::draft::backup(worktree, backup_root, &st.files, "cursor-worker") + .map_err(|e| format!("sauvegarde impossible, rien n'a été modifié ({e})"))?; + backup_id = Some(b.id); + } + let remote_ref = format!("refs/remotes/origin/{branch}"); + let target = if git_in(worktree, &["rev-parse", "--verify", "--quiet", &remote_ref]).is_ok() { + format!("origin/{branch}") + } else { + "HEAD".to_string() + }; + git_in( + worktree, + &[ + "checkout", + "--quiet", + "-f", + "--no-track", + "-B", + CURSOR_BRANCH, + &target, + ], + )?; + crate::draft::discard(worktree, &branch, None).map_err(|e| e.0)?; + Ok(backup_id) +} + fn read_git_remote(workdir: &Path) -> Option { let out = Command::new("git") .args([ @@ -455,18 +684,15 @@ pub fn parse_extra_dirs(raw: &str) -> Vec { .collect() } +/// Dossiers servis (sans rien créer) : worktrees `-cursor` des projets clonés + extra. 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); - } - } + let extras = extra_paths(extra_raw, &paths); + paths.extend(extras); paths } @@ -662,15 +888,50 @@ echo "DF_RUNNING=0" /// Démarre le worker en arrière-plan sur le nœud et vérifie qu'il tient. /// -/// `candidates` : chemins projets ; seuls ceux avec `.git` sont servis -/// (sinon un espace vide). La clé est passée via l'environnement, jamais -/// écrite sur disque ni affichée. -pub fn start_script(base: &ScriptBase, name: &str, candidates: &[String], api_key: &str) -> String { - let dirs: Vec = candidates.iter().map(|c| shell_quote(c)).collect(); +/// `projects` : (checkout du projet, branche de prod). Chaque projet cloné est +/// servi depuis son worktree `-cursor`, créé sur place s'il manque +/// (jamais le checkout de déploiement). `extras` : dossiers servis tels quels. +/// La clé est passée via l'environnement, jamais écrite sur disque ni affichée. +pub fn start_script( + base: &ScriptBase, + name: &str, + projects: &[(String, String)], + extras: &[String], + api_key: &str, +) -> String { + let specs: Vec = projects + .iter() + .map(|(p, b)| { + let b = safe_branch(b).unwrap_or_default(); + shell_quote(&format!("{p}|{b}")) + }) + .collect(); + let extra_dirs: Vec = extras.iter().map(|c| shell_quote(c)).collect(); format!( r#"{stop}set -- N=0 -for d in {dirs}; do +for spec in {specs}; do + d=${{spec%|*}}; b=${{spec##*|}} + [ -n "$d" ] && [ -d "$d/.git" ] || continue + [ "$N" -ge {max} ] && break + w="$d{suffix}" + if [ ! -e "$w/.git" ]; then + [ -n "$b" ] || b=$(git -C "$d" symbolic-ref --short -q HEAD 2>/dev/null) + [ -n "$b" ] || b=main + git -C "$d" worktree prune >/dev/null 2>&1 + base="origin/$b" + git -C "$d" rev-parse --verify --quiet "refs/remotes/origin/$b" >/dev/null 2>&1 || base=HEAD + if git -C "$d" show-ref --verify --quiet refs/heads/{branch} 2>/dev/null; then + git -C "$d" worktree add --quiet "$w" {branch} >/dev/null 2>&1 + else + git -C "$d" worktree add --quiet --no-track -b {branch} "$w" "$base" >/dev/null 2>&1 + fi || {{ echo "DF_WT_ERR=$w"; continue; }} + fi + set -- "$@" "$w" + N=$((N+1)) + echo "DF_DIR=$w" +done +for d in {extra_dirs}; do [ -n "$d" ] && [ -d "$d/.git" ] || continue [ "$N" -ge {max} ] && break set -- "$@" "$d" @@ -736,11 +997,18 @@ else fi "#, stop = stop_script(base).replace("echo \"DF_RUNNING=0\"\n", ""), - dirs = if dirs.is_empty() { + specs = if specs.is_empty() { + "''".to_string() + } else { + specs.join(" ") + }, + extra_dirs = if extra_dirs.is_empty() { "''".to_string() } else { - dirs.join(" ") + extra_dirs.join(" ") }, + suffix = CURSOR_WORKTREE_SUFFIX, + branch = CURSOR_BRANCH, max = MAX_WORKER_DIRS, key = shell_quote(api_key), name = shell_quote(name), @@ -794,6 +1062,8 @@ pub struct ScriptReport { pub repos_up: Option, /// Noms de machine Cursor (un par dépôt) annoncés au démarrage. pub names: Vec, + /// Worktrees `-cursor` impossibles à créer (projets non servis). + pub worktree_errors: Vec, pub logs: String, } @@ -824,6 +1094,7 @@ pub fn parse_script_output(out: &str) -> ScriptReport { "DF_DIR" => r.dirs.push(v.to_string()), "DF_REPOS_UP" => r.repos_up = v.parse().ok(), "DF_NAME" if !v.is_empty() => r.names.push(v.to_string()), + "DF_WT_ERR" if !v.is_empty() => r.worktree_errors.push(v.to_string()), "DF_STOPPED" | "DF_AGENT" | "DF_VERSION" => {} _ => logs.push(line), } @@ -1130,7 +1401,9 @@ pub fn start_named( ); } ensure_state_dir()?; - let workdirs = workdirs_or_fallback(workdirs); + // Chaque projet est servi depuis son worktree `-cursor`, créé ici s'il manque. + let (workdirs, worktree_errors) = ensure_worktrees_for(workdirs); + let workdirs = workdirs_or_fallback(&workdirs); let names = repo_worker_names(name, &workdirs); let args = build_worker_args(&names[0], &workdirs[..1])?; let pid = spawn_worker_process(bin, &args, &out_path(), &err_path(), api_key.as_deref())?; @@ -1193,6 +1466,7 @@ pub fn start_named( "machine_names": names, "processes": 1 + extra.len(), "repo_errors": extra_errors, + "worktree_errors": worktree_errors, "message": if extra_errors.is_empty() { format!( "Worker démarré : {serving} repo(s), une machine Cursor par dépôt ({name}-)" @@ -1206,6 +1480,45 @@ pub fn start_named( })) } +/// Crée les worktrees `-cursor` manquants (branche courante du checkout) ; +/// un chemin `-cursor` impossible à créer n'est pas servi (jamais de repli sur +/// le checkout de déploiement). +pub fn ensure_worktrees_for(workdirs: &[String]) -> (Vec, Vec) { + let mut out = Vec::with_capacity(workdirs.len()); + let mut errors = Vec::new(); + for d in workdirs { + match d.strip_suffix(CURSOR_WORKTREE_SUFFIX) { + Some(src) if !Path::new(d).join(".git").exists() => { + match ensure_cursor_worktree(Path::new(src), None) { + Ok(w) => out.push(w.path), + Err(e) => errors.push(e), + } + } + _ => out.push(d.clone()), + } + } + (out, errors) +} + +/// Relance avec les projets : worktrees `-cursor` préparés (branche de prod du +/// projet), puis un process par dossier. Les erreurs de worktree sont jointes +/// au résultat (`worktree_errors`). +pub fn restart_with_dirs( + settings: &WorkerSettings, + bin: &Path, + name: &str, + dirs: &[WorkerDir], +) -> Result { + let (paths, errors) = prepare_worker_paths(dirs, &settings.extra_dirs); + let mut v = restart_named(settings, bin, name, &paths)?; + if let Some(arr) = v.get_mut("worktree_errors").and_then(Value::as_array_mut) { + let later = std::mem::take(arr); + arr.extend(errors.into_iter().map(Value::String)); + arr.extend(later); + } + Ok(v) +} + pub fn restart_named( settings: &WorkerSettings, bin: &Path, @@ -1472,13 +1785,21 @@ pub fn spawn_autostart(pool: PgPool, node_name: String) { return; } }; - let paths = eligible_paths(&dirs, &settings.extra_dirs); let Some(bin) = resolve_agent_binary(&settings) else { tracing::warn!("cursor-worker autostart: CLI absent"); return; }; let name = resolve_worker_name_for_node(&settings, &node_name); - match start_named(&settings, &bin, &name, &paths) { + let started = tokio::task::spawn_blocking(move || { + let (paths, errors) = prepare_worker_paths(&dirs, &settings.extra_dirs); + for e in &errors { + tracing::warn!(error = %e, "cursor-worker autostart: worktree"); + } + start_named(&settings, &bin, &name, &paths) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())); + match started { Ok(v) => tracing::info!(name = ?v.get("name"), "cursor-worker autostart ok"), Err(e) => tracing::warn!(error = %tail_lines(&e, 3), "cursor-worker autostart"), } diff --git a/apps/server/src/cursor_worker_routes.rs b/apps/server/src/cursor_worker_routes.rs index aabcd0db9..3683fb0dd 100644 --- a/apps/server/src/cursor_worker_routes.rs +++ b/apps/server/src/cursor_worker_routes.rs @@ -32,6 +32,7 @@ pub fn router() -> Router { .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/refresh", post(refresh)) .route( "/api/v1/cursor-worker/settings", get(get_settings).patch(patch_settings), @@ -183,21 +184,26 @@ async fn exec_on( Ok(output) } -/// Chemins candidats (projets + extra) pour un nœud distant : l'existence -/// de `.git` est vérifiée sur place par le script. -async fn remote_candidates(state: &AppState, settings: &WorkerSettings) -> Vec { - let mut out: Vec = cursor_worker::list_project_workdirs(&state.pool) +/// Candidats pour un nœud distant : (checkout, branche de prod) par projet, +/// puis les dossiers extra. L'existence de `.git` et le worktree `-cursor` +/// sont vérifiés / créés sur place par le script. +async fn remote_candidates( + state: &AppState, + settings: &WorkerSettings, +) -> (Vec<(String, String)>, Vec) { + let projects: Vec<(String, String)> = cursor_worker::list_project_workdirs(&state.pool) .await .unwrap_or_default() .into_iter() - .map(|d| d.path) + .map(|d| (d.source_path, d.branch)) .collect(); + let mut extras: Vec = Vec::new(); for extra in cursor_worker::parse_extra_dirs(&settings.extra_dirs) { - if !out.contains(&extra) { - out.push(extra); + if !projects.iter().any(|(p, _)| p == &extra) && !extras.contains(&extra) { + extras.push(extra); } } - out + (projects, extras) } // ───────────────────────── Statut ───────────────────────── @@ -505,12 +511,11 @@ async fn install_leader( let dirs = cursor_worker::list_project_workdirs(&state.pool) .await .unwrap_or_default(); - let paths = cursor_worker::eligible_paths(&dirs, &settings.extra_dirs); let s2 = settings.clone(); let n2 = name.to_string(); let b2 = bin.clone(); let started = - tokio::task::spawn_blocking(move || cursor_worker::restart_named(&s2, &b2, &n2, &paths)) + tokio::task::spawn_blocking(move || cursor_worker::restart_with_dirs(&s2, &b2, &n2, &dirs)) .await .map_err(|e| e.to_string()) .and_then(|r| r); @@ -609,8 +614,8 @@ async fn install_remote( return Err(e); } } - let candidates = remote_candidates(state, settings).await; - let script = cursor_worker::start_script(&base, name, &candidates, api_key); + let (projects, extras) = remote_candidates(state, settings).await; + let script = cursor_worker::start_script(&base, name, &projects, &extras, api_key); match exec_on(state, node, &script, 60).await { Ok(out) => { let rep = cursor_worker::parse_script_output(&out); @@ -666,21 +671,20 @@ async fn start_on( "CLI Cursor absent : utilise « Installer le worker Cursor ».".to_string() })?; let dirs = cursor_worker::list_project_workdirs(&state.pool).await?; - let paths = cursor_worker::eligible_paths(&dirs, &settings.extra_dirs); let s2 = settings.clone(); return tokio::task::spawn_blocking(move || { - cursor_worker::restart_named(&s2, &bin, &name, &paths) + cursor_worker::restart_with_dirs(&s2, &bin, &name, &dirs) }) .await .map_err(|e| e.to_string())?; } let (key, _) = cursor_worker::effective_api_key(settings) .ok_or_else(|| "Clé API Cursor manquante.".to_string())?; - let candidates = remote_candidates(state, settings).await; + let (projects, extras) = remote_candidates(state, settings).await; let out = exec_on( state, node, - &cursor_worker::start_script(&ScriptBase::NodeDataDir, &name, &candidates, &key), + &cursor_worker::start_script(&ScriptBase::NodeDataDir, &name, &projects, &extras, &key), 60, ) .await?; @@ -691,6 +695,8 @@ async fn start_on( "pid": rep.pid, "name": name, "machine_names": rep.names, + "workdirs": rep.dirs, + "worktree_errors": rep.worktree_errors, "workdirs_count": rep.dirs.len(), "message": format!("Worker « {name} » démarré sur {}", node.name), })) @@ -732,6 +738,69 @@ async fn restart(State(state): State, headers: HeaderMap) -> Result` +/// (changements non publiés sauvegardés d'abord). Leader uniquement. +async fn refresh(State(state): State, headers: HeaderMap) -> Result, ApiErr> { + require_admin(&state, &headers).await?; + let (_, node) = target(&state).await?; + if !node.leader { + return Err(map_err( + "« Remettre à jour » est disponible quand le worker tourne sur ce serveur.".into(), + )); + } + let dirs = cursor_worker::list_project_workdirs(&state.pool) + .await + .map_err(map_err)?; + let data = std::path::PathBuf::from( + std::env::var("DEVFORGE_DATA_DIR").unwrap_or_else(|_| "/data".into()), + ); + let results = tokio::task::spawn_blocking(move || { + dirs.iter() + .filter(|d| d.eligible && d.worktree_ready) + .map(|d| { + // Sauvegardes à part du brouillon du projet (jamais restaurées dans le checkout). + let root = crate::draft::backups_root( + &data, + &format!( + "{}{}", + d.project_uuid, + cursor_worker::CURSOR_WORKTREE_SUFFIX + ), + ); + let r = cursor_worker::refresh_cursor_worktree( + std::path::Path::new(&d.path), + &d.branch, + &root, + ); + json!({ + "project": d.project_name, + "path": d.path, + "ok": r.is_ok(), + "backup": r.as_ref().ok().cloned().flatten(), + "error": r.err(), + }) + }) + .collect::>() + }) + .await + .map_err(|e| map_err(e.to_string()))?; + let ok = results.iter().filter(|r| r["ok"] == true).count(); + let saved = results.iter().filter(|r| !r["backup"].is_null()).count(); + Ok(Json(json!({ + "ok": ok == results.len(), + "results": results, + "message": format!( + "{ok}/{} dossier(s) remis à jour{}", + results.len(), + if saved > 0 { + format!(" · {saved} sauvegarde(s) des changements en cours") + } else { + String::new() + } + ), + }))) +} + async fn stop(State(state): State, headers: HeaderMap) -> Result, ApiErr> { require_admin(&state, &headers).await?; let (_, node) = target(&state).await?; diff --git a/apps/server/src/cursor_worker_tests.rs b/apps/server/src/cursor_worker_tests.rs index 8f620a11f..558b470f2 100644 --- a/apps/server/src/cursor_worker_tests.rs +++ b/apps/server/src/cursor_worker_tests.rs @@ -90,7 +90,8 @@ fn scripts_never_print_key_and_use_env() { let s = start_script( &ScriptBase::NodeDataDir, "demeter", - &["/data/applications/a".into()], + &[("/data/applications/a".into(), "main".into())], + &[], key, ); assert!(s.contains("K='key_SECRET_0123456789'")); @@ -169,34 +170,30 @@ fn scripts_run_end_to_end_with_fake_agent() { std::fs::create_dir_all(repo.join(".git")).unwrap(); let repo_b = tmp.join("repo-b"); std::fs::create_dir_all(&repo_b).unwrap(); - // Vrai dépôt : le nom de machine vient du remote `origin`. - let git_ok = std::process::Command::new("git") - .arg("-C") - .arg(&repo_b) - .args(["init", "-q"]) - .status() - .map(|s| s.success()) - .unwrap_or(false) - && std::process::Command::new("git") + // Vrai dépôt (avec un commit) : servi via son worktree `-cursor`, le nom + // de machine vient du remote `origin`. + let g = |args: &[&str]| { + std::process::Command::new("git") .arg("-C") .arg(&repo_b) - .args([ - "remote", - "add", - "origin", - "https://github.com/o/Template-Studio.git", - ]) - .status() - .map(|s| s.success()) - .unwrap_or(false); + .args(["-c", "user.name=t", "-c", "user.email=t@t"]) + .args(args) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }; + let git_ok = g(&["init", "-q", "-b", "main"]) + && g(&[ + "remote", + "add", + "origin", + "https://github.com/o/Template-Studio.git", + ]) + && g(&["commit", "-q", "--allow-empty", "-m", "init"]); if !git_ok { - std::fs::create_dir_all(repo_b.join(".git")).unwrap(); + return; // git absent : le reste est couvert par les autres tests. } - let name_b = if git_ok { - "demo-template-studio" - } else { - "demo-repo-b" - }; + let wt_b = format!("{}-cursor", repo_b.display()); let base = ScriptBase::Literal(base_dir.display().to_string()); let run = |script: &str| { @@ -220,36 +217,48 @@ fn scripts_run_end_to_end_with_fake_agent() { &base, "demo", &[ - repo.display().to_string(), - "/nope".into(), - repo_b.display().to_string(), + ("/nope".into(), "main".into()), + (repo_b.display().to_string(), "main".into()), ], + &[repo.display().to_string()], "key_0123456789abcdef", )); let stdout = String::from_utf8_lossy(&out.stdout); assert!(out.status.success(), "{stdout}"); let r = parse_script_output(&stdout); assert!(r.running); - assert_eq!( - r.dirs, - vec![repo.display().to_string(), repo_b.display().to_string()] - ); - // Un process par dépôt : le principal sert UNIQUEMENT le premier dépôt. + // Le projet est servi depuis son worktree, jamais depuis le checkout. + assert_eq!(r.dirs, vec![wt_b.clone(), repo.display().to_string()]); + assert!(r.worktree_errors.is_empty(), "{stdout}"); + assert!(Path::new(&wt_b).join(".git").exists()); + let head = std::process::Command::new("git") + .arg("-C") + .arg(&wt_b) + .args(["symbolic-ref", "--short", "HEAD"]) + .output() + .unwrap(); + assert_eq!(String::from_utf8_lossy(&head.stdout).trim(), CURSOR_BRANCH); + // Un process par dépôt : le principal sert UNIQUEMENT le premier dossier. assert_eq!(r.repos_up, Some(2)); let log = std::fs::read_to_string(base_dir.join("worker.out")).unwrap(); // Un nom de machine par dépôt (sinon tous partagent le même workerId). - assert!(log.contains("--name demo-repo-a "), "{log}"); - assert_eq!(r.names, vec!["demo-repo-a".to_string(), name_b.to_string()]); - assert!(log.contains(&format!("--worker-dir {}", repo.display()))); - assert!(!log.contains("repo-b"), "{log}"); + assert!(log.contains("--name demo-template-studio "), "{log}"); + assert_eq!( + r.names, + vec![ + "demo-template-studio".to_string(), + "demo-repo-a".to_string() + ] + ); + assert!(log.contains(&format!("--worker-dir {wt_b} ")), "{log}"); assert!(log.contains("--management-addr")); assert!(!log.contains("key_0123456789abcdef")); let log_b = std::fs::read_to_string(base_dir.join("repos/1/worker.out")).unwrap(); - assert!(log_b.contains(&format!("--name {name_b} ")), "{log_b}"); - assert!(log_b.contains(&format!("--worker-dir {}", repo_b.display()))); + assert!(log_b.contains("--name demo-repo-a "), "{log_b}"); + assert!(log_b.contains(&format!("--worker-dir {}", repo.display()))); assert!(log_b.contains("--data-dir")); assert!(!log_b.contains("--management-addr")); - assert!(!log_b.contains("repo a")); + assert!(!log_b.contains("repo-b")); let pid_b: u32 = std::fs::read_to_string(base_dir.join("repos/1/worker.pid")) .unwrap() .trim() @@ -498,3 +507,194 @@ fn repo_worker_names_read_origin_remote() { assert_eq!(names, vec!["devforge-template-studio"]); let _ = std::fs::remove_dir_all(&tmp); } + +#[test] +fn cursor_worktree_path_and_branch_rules() { + assert_eq!( + cursor_worktree_path("/data/devforge/applications/02792add/"), + "/data/devforge/applications/02792add-cursor" + ); + assert_eq!(safe_branch(" main "), Some("main".into())); + assert_eq!(safe_branch("release/2.0"), Some("release/2.0".into())); + assert_eq!(safe_branch(""), None); + assert_eq!(safe_branch("-x"), None); + assert_eq!(safe_branch("a..b"), None); + assert_eq!(safe_branch("main; rm -rf /"), None); + let d = worker_dir_for("u".into(), "Cheval".into(), "/nope/cheval".into(), ""); + assert!(!d.eligible); + assert_eq!(d.path, "/nope/cheval-cursor"); + assert_eq!(d.note.as_deref(), Some(NO_CHECKOUT_NOTE)); + assert_eq!(d.branch, "main"); + // Les projets sans checkout ne sont jamais servis. + assert!(eligible_paths(&[d], "").is_empty()); +} + +/// Dépôt « GitHub » (bare) + checkout de déploiement, comme sur le serveur. +#[cfg(unix)] +fn deploy_like_checkout(tmp: &Path) -> Option { + let run = |dir: &Path, args: &[&str]| { + std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(["-c", "user.name=t", "-c", "user.email=t@t"]) + .args(args) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }; + let _ = std::fs::remove_dir_all(tmp); + std::fs::create_dir_all(tmp).ok()?; + let origin = tmp.join("origin.git"); + let src = tmp.join("app"); + let seed = tmp.join("seed"); + std::fs::create_dir_all(&seed).ok()?; + if !run(tmp, &["init", "-q", "--bare", "-b", "main", "origin.git"]) { + return None; + } + std::fs::write(seed.join("index.html"), "v1").ok()?; + let ok = run(&seed, &["init", "-q", "-b", "main"]) + && run(&seed, &["add", "."]) + && run(&seed, &["commit", "-q", "-m", "v1"]) + && run( + &seed, + &["remote", "add", "origin", &origin.display().to_string()], + ) + && run(&seed, &["push", "-q", "origin", "main"]) + && run( + tmp, + &[ + "clone", + "-q", + "-b", + "main", + &origin.display().to_string(), + "app", + ], + ); + ok.then_some(src) +} + +#[cfg(unix)] +fn git_out(dir: &Path, args: &[&str]) -> String { + let o = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(["-c", "user.name=t", "-c", "user.email=t@t"]) + .args(args) + .output() + .unwrap(); + String::from_utf8_lossy(&o.stdout).trim().to_string() +} + +#[cfg(unix)] +#[test] +fn cursor_worktree_is_separate_from_deploy_checkout() { + let tmp = std::env::temp_dir().join(format!("cw-wt-{}", std::process::id())); + let Some(src) = deploy_like_checkout(&tmp) else { + return; // git absent + }; + let wt = PathBuf::from(cursor_worktree_path(&src.display().to_string())); + + // Pas de checkout : rien n'est créé. + let missing = tmp.join("cheval"); + std::fs::create_dir_all(&missing).unwrap(); + assert_eq!( + ensure_cursor_worktree(&missing, Some("main")).unwrap_err(), + NO_CHECKOUT_NOTE + ); + assert!(!tmp.join("cheval-cursor").exists()); + + // Création : branche cursor/devforge partant d'origin/main. + let w = ensure_cursor_worktree(&src, Some("main")).unwrap(); + assert!(w.created); + assert_eq!(w.path, wt.display().to_string()); + assert_eq!( + git_out(&wt, &["symbolic-ref", "--short", "HEAD"]), + CURSOR_BRANCH + ); + assert_eq!( + git_out(&wt, &["rev-parse", "HEAD"]), + git_out(&src, &["rev-parse", "origin/main"]) + ); + // Le checkout de déploiement reste sur sa branche, propre. + assert_eq!(git_out(&src, &["symbolic-ref", "--short", "HEAD"]), "main"); + assert_eq!(git_out(&src, &["status", "--porcelain"]), ""); + + // Un agent travaille dans le worktree… + std::fs::write(wt.join("index.html"), "agent").unwrap(); + // …un déploiement (même commande que git_sync_command) ne le touche pas. + for args in [ + &["fetch", "-q", "origin"][..], + &["checkout", "-q", "main"][..], + &["reset", "-q", "--hard", "origin/main"][..], + ] { + git_out(&src, args); + } + assert_eq!( + std::fs::read_to_string(wt.join("index.html")).unwrap(), + "agent" + ); + assert_eq!(git_out(&src, &["status", "--porcelain"]), ""); + assert_eq!( + std::fs::read_to_string(src.join("index.html")).unwrap(), + "v1" + ); + + // Déjà présent : laissé tel quel (changements conservés). + let again = ensure_cursor_worktree(&src, Some("main")).unwrap(); + assert!(!again.created); + assert_eq!( + std::fs::read_to_string(wt.join("index.html")).unwrap(), + "agent" + ); + // Chemin `-cursor` déjà présent : start_named ne recrée rien. + let (paths, errs) = ensure_worktrees_for(&[wt.display().to_string()]); + assert_eq!(paths, vec![wt.display().to_string()]); + assert!(errs.is_empty()); + + // « Remettre à jour » : sauvegarde puis retour sur origin/main. + let root = tmp.join("backups"); + let id = refresh_cursor_worktree(&wt, "main", &root).unwrap(); + let id = id.expect("changements sauvegardés"); + assert!(root.join(&id).join("files.tar.gz").exists()); + assert_eq!( + std::fs::read_to_string(wt.join("index.html")).unwrap(), + "v1" + ); + assert_eq!( + git_out(&wt, &["symbolic-ref", "--short", "HEAD"]), + CURSOR_BRANCH + ); + // Propre : pas de nouvelle sauvegarde. + assert_eq!(refresh_cursor_worktree(&wt, "main", &root).unwrap(), None); + + // Dossier `-cursor` supprimé à la main : recréé, la branche est reprise. + std::fs::remove_dir_all(&wt).unwrap(); + let w = ensure_cursor_worktree(&src, None).unwrap(); + assert!(w.created); + assert_eq!( + git_out(&wt, &["symbolic-ref", "--short", "HEAD"]), + CURSOR_BRANCH + ); + + // Dossier occupé (pas un dépôt) : jamais écrasé. + let other = tmp.join("other"); + let other_src = tmp.join("app2"); + std::fs::create_dir_all(&other).unwrap(); + git_out( + &tmp, + &[ + "clone", + "-q", + &tmp.join("origin.git").display().to_string(), + "app2", + ], + ); + let busy = tmp.join("app2-cursor"); + std::fs::create_dir_all(&busy).unwrap(); + std::fs::write(busy.join("keep.txt"), "x").unwrap(); + assert!(ensure_cursor_worktree(&other_src, Some("main")).is_err()); + assert_eq!(std::fs::read_to_string(busy.join("keep.txt")).unwrap(), "x"); + + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/apps/server/src/routes.rs b/apps/server/src/routes.rs index c18acb175..99cc074e8 100644 --- a/apps/server/src/routes.rs +++ b/apps/server/src/routes.rs @@ -1487,6 +1487,9 @@ pub(crate) async fn run_real_deploy_at( deployment_uuid: &str, pin_sha: Option<&str>, ) -> DeployRunOutcome { + // Le déploiement fait un `reset --hard` du checkout : le brouillon en cours est + // d'abord sauvegardé, pour les déploiements manuels comme automatiques. + crate::auto_deploy_guard::backup_draft_before_reset(state, project, "deploy").await; let gh_lifecycle = crate::github_checks::begin(state, project, deployment_uuid, pin_sha); let run = run_real_deploy_inner( state, diff --git a/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx b/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx index dd3e38def..d81f0ff37 100644 --- a/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx +++ b/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx @@ -3,6 +3,7 @@ import { cursorKeyProblem, cursorKeyWarning, installNeeds, + repoBadge, workerSummary, } from './CursorWorkerSettingsPanel'; @@ -82,3 +83,28 @@ describe('Cursor login (no key)', () => { expect(workerSummary(s).detail).toMatch(/grok_/); }); }); + +describe('repoBadge', () => { + const w = { + project_uuid: 'u', + project_name: 'template-studio', + path: '/data/devforge/applications/u-cursor', + exists: true, + has_git: true, + eligible: true, + }; + it('montre le dossier -cursor servi, créé au démarrage s’il manque', () => { + expect(repoBadge({ ...w, worktree_ready: true })).toEqual({ label: 'Servi', tone: 'ok' }); + expect(repoBadge({ ...w, worktree_ready: false }).label).toBe('Créé au démarrage'); + }); + it('signale un projet sans checkout', () => { + expect( + repoBadge({ + ...w, + has_git: false, + eligible: false, + note: 'pas encore de checkout : déployer une fois', + }).label, + ).toBe('Pas de checkout'); + }); +}); diff --git a/apps/web/src/components/CursorWorkerSettingsPanel.tsx b/apps/web/src/components/CursorWorkerSettingsPanel.tsx index d044d5ea0..1e1879111 100644 --- a/apps/web/src/components/CursorWorkerSettingsPanel.tsx +++ b/apps/web/src/components/CursorWorkerSettingsPanel.tsx @@ -449,7 +449,12 @@ export function CursorWorkerSettingsPanel({ isAdmin }: { isAdmin: boolean }) { onClose={() => setModal(null)} onChanged={() => void load()} /> - setModal(null)} /> + setModal(null)} + onChanged={() => void load()} + /> void; + onChanged: () => void; }) { + const toast = useToast(); + const [confirm, setConfirm] = useState(false); + const [busy, setBusy] = useState(false); const rows = status.workdirs ?? []; + const leader = status.eligible_count != null; + const canRefresh = leader && rows.some((w) => w.eligible && w.worktree_ready); + + useEffect(() => { + if (open) setConfirm(false); + }, [open]); + + async function refresh() { + setBusy(true); + try { + const r = await api.cursorWorkerRefresh(); + const failed = r.results.filter((x) => !x.ok); + toast.push({ + title: r.ok ? 'Dossiers remis à jour' : 'Remise à jour partielle', + detail: + [r.message, ...failed.map((x) => `${x.project} : ${x.error ?? 'échec'}`)] + .filter(Boolean) + .join('\n') || undefined, + tone: r.ok ? 'ok' : 'danger', + }); + setConfirm(false); + onChanged(); + } catch (e) { + toast.push({ title: 'Échec', detail: String((e as Error).message || e), tone: 'danger' }); + } finally { + setBusy(false); + } + } + return ( !busy && onClose()} + title={confirm ? 'Remettre à jour les dossiers ?' : 'Dépôts servis'} + description={ + confirm + ? undefined + : 'Chaque projet cloné a son propre dossier pour les agents Cursor (…-cursor). Les déploiements n’y touchent pas.' + } size="lg" fullOnMobile + footer={ + canRefresh ? ( +
+ {confirm ? ( + <> + + + + ) : ( + + )} +
+ ) : undefined + } > - {status.eligible_count == null ? ( + {confirm ? ( +

+ Chaque dossier …-cursor repart de la version en ligne sur GitHub. Les changements pas + encore publiés sont d’abord sauvegardés. À éviter pendant qu’un agent Cursor travaille. +

+ ) : status.eligible_count == null ? ( Sur {status.node?.name ?? 'ce nœud'}, les dépôts clonés sont détectés à chaque démarrage. @@ -897,19 +975,20 @@ function ReposModal({ Aucun projet pour l’instant. ) : (
    - {rows.map((w) => ( -
  • -
    -
    {w.project_name}
    -
    - {w.git_remote || w.path} + {rows.map((w) => { + const b = repoBadge(w); + return ( +
  • +
    +
    {w.project_name}
    +
    + {w.eligible ? w.path : w.note || w.git_remote || w.path} +
    - - - {w.eligible ? 'Servi' : !w.exists ? 'Pas cloné' : 'Sans .git'} - -
  • - ))} + {b.label} + + ); + })}
)}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 6bb751839..20d3d3648 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -161,11 +161,18 @@ export type CursorWorkerStatus = { workdirs: Array<{ project_uuid: string; project_name: string; + /** Dossier servi : worktree `-cursor` (jamais le checkout de déploiement). */ path: string; + /** Checkout du projet (remis à `origin/` par les déploiements). */ + source_path?: string; + branch?: string; exists: boolean; has_git: boolean; + /** Le worktree `-cursor` existe (sinon créé au démarrage). */ + worktree_ready?: boolean; git_remote?: string | null; eligible: boolean; + note?: string | null; }>; /** null pour un nœud distant (compté au démarrage). */ eligible_count: number | null; @@ -1972,6 +1979,19 @@ export const api = { message?: string; error?: string; }>('/cursor-worker/restart', { method: 'POST', body: '{}' }), + /** « Remettre à jour » : worktrees `-cursor` sur `origin/` (sauvegarde d'abord). */ + cursorWorkerRefresh: () => + request<{ + ok: boolean; + message?: string; + results: Array<{ + project: string; + path: string; + ok: boolean; + backup?: string | null; + error?: string | null; + }>; + }>('/cursor-worker/refresh', { method: 'POST', body: '{}' }), cursorWorkerSettings: () => request<{ ok: boolean; diff --git a/docs/MODULES.md b/docs/MODULES.md index 859c7a14d..76b2e87b2 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -24,7 +24,7 @@ Facades derrière traits ; runtime réel activé via env (sinon stubs). ## Worker Cursor (My Machines) -Admin → **Worker Cursor** (`/app/admin?tab=cursor-worker`) : bouton unique « Installer le worker Cursor » (`POST /api/v1/cursor-worker/install`) — DevForge installe le CLI Cursor dans `/cursor-worker/home` s'il manque, démarre **un process `agent worker start` par checkout projet** (`.git` présent, max 20 ; sinon un espace vide), chacun avec son `--worker-dir`, son `--data-dir` et **son propre nom de machine `-`** (dépôt = dernier segment du remote `origin`, ex. `devforge-template-studio`). Le CLI mémorise le workerId par nom affiché : un nom partagé donnerait un seul workerId et Cursor enverrait la session à n'importe quel process. Pour viser un dépôt : machine `-` dans My Machines, ou `worker=-`. DevForge vérifie ensuite le process et la sonde `/readyz`. Nœud : leader par défaut (processus enfant), ou un nœud worker via l'executor du cluster. Seule la clé API personnelle Cursor est demandée si elle n'est pas déjà enregistrée (ou `CURSOR_API_KEY` côté serveur) ; elle est passée par l'environnement, jamais en argument. Nom, binaire et dossiers en plus : modale « Avancé ». Autostart via `cursor_worker_enabled` (activé par l'installation). +Admin → **Worker Cursor** (`/app/admin?tab=cursor-worker`) : bouton unique « Installer le worker Cursor » (`POST /api/v1/cursor-worker/install`) — DevForge installe le CLI Cursor dans `/cursor-worker/home` s'il manque, démarre **un process `agent worker start` par projet cloné** (`.git` présent, max 20 ; sinon un espace vide). Chaque process sert le **worktree dédié `-cursor`** (créé au démarrage par `git worktree add --no-track -b cursor/devforge -cursor origin/`, jamais modifié ensuite), pas le checkout que les déploiements remettent à `origin/` : un déploiement n'efface plus le travail d'un agent et un changement de branche de l'agent ne gêne plus le build. Projet sans checkout : « pas encore de checkout : déployer une fois ». « Remettre à jour » (`POST /api/v1/cursor-worker/refresh`, leader) replace chaque worktree sur `origin/` après sauvegarde des changements non publiés (`/draft-backups/-cursor/`). Chaque process a son `--worker-dir`, son `--data-dir` et **son propre nom de machine `-`** (dépôt = dernier segment du remote `origin`, ex. `devforge-template-studio`). Le CLI mémorise le workerId par nom affiché : un nom partagé donnerait un seul workerId et Cursor enverrait la session à n'importe quel process. Pour viser un dépôt : machine `-` dans My Machines, ou `worker=-`. DevForge vérifie ensuite le process et la sonde `/readyz`. Nœud : leader par défaut (processus enfant), ou un nœud worker via l'executor du cluster. Seule la clé API personnelle Cursor est demandée si elle n'est pas déjà enregistrée (ou `CURSOR_API_KEY` côté serveur) ; elle est passée par l'environnement, jamais en argument. Nom, binaire et dossiers en plus : modale « Avancé ». Autostart via `cursor_worker_enabled` (activé par l'installation). ## Base de données DevForge (métadonnées)