diff --git a/apps/server/src/cursor_worker.rs b/apps/server/src/cursor_worker.rs index 20e2dec6c..2a9062bb1 100644 --- a/apps/server/src/cursor_worker.rs +++ b/apps/server/src/cursor_worker.rs @@ -520,6 +520,64 @@ pub fn build_repo_worker_args(name: &str, dir: &str, data_dir: &str) -> Vec, path: &str) -> String { + let from_remote = remote + .map(|r| r.trim().trim_end_matches('/').trim_end_matches(".git")) + .and_then(|r| r.rsplit(['/', ':']).next()) + .map(slug_name) + .filter(|s| !s.is_empty()); + from_remote + .or_else(|| { + Path::new(path.trim_end_matches('/')) + .file_name() + .and_then(|s| s.to_str()) + .map(slug_name) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_else(|| "repo".into()) +} + +/// Un nom de machine Cursor par dépôt : `-`. +/// +/// Le CLI mémorise l'identifiant du worker par nom affiché +/// (`workerIdsByDisplayName` dans son HOME) : des process qui partagent un +/// nom partagent donc le même workerId, et Cursor confie la session à +/// n'importe lequel d'entre eux. Un nom distinct par dépôt donne un workerId +/// distinct et une cible explicite (`worker=devforge-template-studio`). +pub fn repo_worker_names_from(base: &str, labels: &[String]) -> Vec { + let base = slug_name(base); + let mut seen: Vec = Vec::with_capacity(labels.len()); + for label in labels { + let raw = if base.is_empty() { + label.clone() + } else { + format!("{base}-{label}") + }; + let mut name = slug_name(&raw); + if name.is_empty() { + name = "repo".into(); + } + let mut candidate = name.clone(); + let mut n = 2; + while seen.contains(&candidate) { + candidate = format!("{name}-{n}"); + n += 1; + } + seen.push(candidate); + } + seen +} + +/// Noms de machine des dépôts servis (lit le remote `origin` de chaque dossier). +pub fn repo_worker_names(base: &str, workdirs: &[String]) -> Vec { + let labels: Vec = workdirs + .iter() + .map(|d| repo_label(read_git_remote(Path::new(d)).as_deref(), d)) + .collect(); + repo_worker_names_from(base, &labels) +} + /// Workdirs effectifs : repos éligibles, sinon un espace vide géré par DevForge /// (le worker démarre quand même et les repos s'ajoutent au prochain relancement). pub fn workdirs_or_fallback(paths: &[String]) -> Vec { @@ -627,15 +685,30 @@ command -v agent >/dev/null 2>&1 || {{ echo "CLI agent absent"; exit 5; }} SETSID="" command -v setsid >/dev/null 2>&1 && SETSID="setsid" K={key} +# Un nom de machine par dépôt (-) : le CLI associe le workerId au nom. +SEEN=" " +dfname() {{ + r=$(git -C "$1" remote get-url origin 2>/dev/null) + r=${{r%/}}; r=${{r%.git}}; r=${{r##*/}}; r=${{r##*:}} + [ -n "$r" ] || r=$(basename "$1") + n=$(printf '%s-%s' {name} "$r" | tr 'A-Z' 'a-z' | tr -cs 'a-z0-9' '-' | sed 's/^-*//; s/-*$//' | cut -c1-48) + [ -n "$n" ] || n=repo + c="$n"; i=2 + while case "$SEEN" in *" $c "*) true;; *) false;; esac; do c="$n-$i"; i=$((i+1)); done + SEEN="$SEEN$c " + DFN="$c" +}} FIRST="$1"; shift # Un process par dépôt : le CLI exécute chaque session dans son premier --worker-dir. -CURSOR_API_KEY="$K" $SETSID nohup agent worker --name {name} --worker-dir "$FIRST" --management-addr {mgmt} start "$D/worker.out" 2>"$D/worker.err" & +dfname "$FIRST" 2>/dev/null; echo "DF_NAME=$DFN" +CURSOR_API_KEY="$K" $SETSID nohup agent worker --name "$DFN" --worker-dir "$FIRST" --management-addr {mgmt} start "$D/worker.out" 2>"$D/worker.err" & echo $! >"$PIDF" I=1 for d in "$@"; do S="$D/repos/$I" mkdir -p "$S/data" - CURSOR_API_KEY="$K" $SETSID nohup agent worker --name {name} --worker-dir "$d" --data-dir "$S/data" start "$S/worker.out" 2>"$S/worker.err" & + dfname "$d" 2>/dev/null; echo "DF_NAME=$DFN" + CURSOR_API_KEY="$K" $SETSID nohup agent worker --name "$DFN" --worker-dir "$d" --data-dir "$S/data" start "$S/worker.out" 2>"$S/worker.err" & echo $! >"$S/worker.pid" I=$((I+1)) done @@ -719,6 +792,8 @@ pub struct ScriptReport { pub dirs: Vec, /// Process actifs (un par dépôt) après démarrage. pub repos_up: Option, + /// Noms de machine Cursor (un par dépôt) annoncés au démarrage. + pub names: Vec, pub logs: String, } @@ -748,6 +823,7 @@ pub fn parse_script_output(out: &str) -> ScriptReport { "DF_READY" => r.ready = Some(v == "1"), "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_STOPPED" | "DF_AGENT" | "DF_VERSION" => {} _ => logs.push(line), } @@ -879,7 +955,8 @@ fn stop_repo_workers() -> Vec { /// État par dépôt servi : le premier est le worker principal, les autres ont /// chacun leur process. -pub fn repo_workers_state(registered: &[String]) -> Vec { +pub fn repo_workers_state(base_name: &str, registered: &[String]) -> Vec { + let names = repo_worker_names(base_name, registered); registered .iter() .enumerate() @@ -891,6 +968,7 @@ pub fn repo_workers_state(registered: &[String]) -> Vec { }; json!({ "path": path, + "name": names.get(i), "primary": i == 0, "running": pid.is_some(), "pid": pid, @@ -1053,21 +1131,22 @@ pub fn start_named( } ensure_state_dir()?; let workdirs = workdirs_or_fallback(workdirs); - let args = build_worker_args(name, &workdirs[..1])?; + 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())?; std::fs::write(pid_path(), pid.to_string()).map_err(|e| e.to_string())?; save_registered(&workdirs)?; let mut extra: Vec<(String, u32)> = Vec::new(); let mut extra_errors: Vec = Vec::new(); - for dir in workdirs.iter().skip(1) { + for (dir, repo_name) in workdirs.iter().zip(names.iter()).skip(1) { let slot = repo_slot_dir(dir); let data = slot.join("data"); if let Err(e) = std::fs::create_dir_all(&data) { extra_errors.push(format!("{dir} : {e}")); continue; } - let args = build_repo_worker_args(name, dir, &data.display().to_string()); + let args = build_repo_worker_args(repo_name, dir, &data.display().to_string()); match spawn_worker_process( bin, &args, @@ -1111,10 +1190,13 @@ pub fn start_named( "binary": bin.display().to_string(), "workdirs": workdirs, "workdirs_count": workdirs.len(), + "machine_names": names, "processes": 1 + extra.len(), "repo_errors": extra_errors, "message": if extra_errors.is_empty() { - format!("Worker « {name} » démarré : {serving} repo(s), un process par repo") + format!( + "Worker démarré : {serving} repo(s), une machine Cursor par dépôt ({name}-)" + ) } else { format!( "Worker « {name} » démarré : {serving}/{} repo(s) actifs", @@ -1158,7 +1240,7 @@ pub async fn status_json(pool: &PgPool, node_name: &str) -> Result) -> } else if ready == Some(false) { "Worker démarré, connexion à Cursor en cours." } else { - "Worker connecté : choisis cette machine dans Cursor (My Machines) ou Grok Bot (worker=)." + "Worker connecté : une machine par dépôt. Dans Cursor (My Machines) ou Grok Bot, choisis - (ex. worker=-mon-depot)." } } diff --git a/apps/server/src/cursor_worker_routes.rs b/apps/server/src/cursor_worker_routes.rs index dde50ad6a..aabcd0db9 100644 --- a/apps/server/src/cursor_worker_routes.rs +++ b/apps/server/src/cursor_worker_routes.rs @@ -452,7 +452,10 @@ async fn install( "name": name, "steps": steps, "message": if ok { - format!("Worker « {name} » installé et démarré sur {}", node.name) + format!( + "Worker installé et démarré sur {} : une machine Cursor par dépôt ({name}-)", + node.name + ) } else if key_invalid { cursor_worker::INVALID_KEY_HINT.to_string() } else { @@ -687,6 +690,7 @@ async fn start_on( "running": rep.running, "pid": rep.pid, "name": name, + "machine_names": rep.names, "workdirs_count": rep.dirs.len(), "message": format!("Worker « {name} » démarré sur {}", node.name), })) diff --git a/apps/server/src/cursor_worker_tests.rs b/apps/server/src/cursor_worker_tests.rs index 143380385..8f620a11f 100644 --- a/apps/server/src/cursor_worker_tests.rs +++ b/apps/server/src/cursor_worker_tests.rs @@ -168,7 +168,35 @@ fn scripts_run_end_to_end_with_fake_agent() { let repo = tmp.join("repo a"); std::fs::create_dir_all(repo.join(".git")).unwrap(); let repo_b = tmp.join("repo-b"); - std::fs::create_dir_all(repo_b.join(".git")).unwrap(); + 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") + .arg("-C") + .arg(&repo_b) + .args([ + "remote", + "add", + "origin", + "https://github.com/o/Template-Studio.git", + ]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !git_ok { + std::fs::create_dir_all(repo_b.join(".git")).unwrap(); + } + let name_b = if git_ok { + "demo-template-studio" + } else { + "demo-repo-b" + }; let base = ScriptBase::Literal(base_dir.display().to_string()); let run = |script: &str| { @@ -209,13 +237,15 @@ fn scripts_run_end_to_end_with_fake_agent() { // Un process par dépôt : le principal sert UNIQUEMENT le premier dépôt. assert_eq!(r.repos_up, Some(2)); let log = std::fs::read_to_string(base_dir.join("worker.out")).unwrap(); - assert!(log.contains("--name demo")); + // 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("--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("--name demo"), "{log_b}"); + assert!(log_b.contains(&format!("--name {name_b} ")), "{log_b}"); assert!(log_b.contains(&format!("--worker-dir {}", repo_b.display()))); assert!(log_b.contains("--data-dir")); assert!(!log_b.contains("--management-addr")); @@ -395,3 +425,76 @@ fn git_remote_is_redacted() { assert_eq!(r, "https://***@github.com/o/r.git"); let _ = std::fs::remove_dir_all(&tmp); } + +#[test] +fn one_machine_name_per_repo() { + assert_eq!( + repo_label( + Some("https://***@github.com/bobdivx/template-studio.git"), + "/data/x/02792add" + ), + "template-studio" + ); + assert_eq!(repo_label(Some("git@github.com:o/Repo.git"), "/x"), "repo"); + assert_eq!( + repo_label(None, "/data/devforge/applications/jeser"), + "jeser" + ); + assert_eq!(repo_label(Some(""), "/"), "repo"); + let names = repo_worker_names_from( + "DevForge", + &[ + "vigie".into(), + "template-studio".into(), + "jeser".into(), + "jeser".into(), + ], + ); + assert_eq!( + names, + vec![ + "devforge-vigie", + "devforge-template-studio", + "devforge-jeser", + "devforge-jeser-2" + ] + ); + // Tous distincts : c'est ce qui donne un workerId par dépôt. + let mut d = names.clone(); + d.dedup(); + assert_eq!(d.len(), names.len()); + assert_eq!(repo_worker_names_from("", &["a".into()]), vec!["a"]); +} + +#[cfg(unix)] +#[test] +fn repo_worker_names_read_origin_remote() { + let tmp = std::env::temp_dir().join(format!("cw-names-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let a = tmp.join("02792add-bf8d"); + std::fs::create_dir_all(&a).unwrap(); + let ok = std::process::Command::new("git") + .arg("-C") + .arg(&a) + .args(["init", "-q"]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !ok { + return; + } + std::process::Command::new("git") + .arg("-C") + .arg(&a) + .args([ + "remote", + "add", + "origin", + "https://x-access-token:ghp_FAKE@github.com/bobdivx/template-studio.git", + ]) + .status() + .unwrap(); + let names = repo_worker_names("devforge", &[a.display().to_string()]); + assert_eq!(names, vec!["devforge-template-studio"]); + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/apps/web/src/components/CursorWorkerSettingsPanel.tsx b/apps/web/src/components/CursorWorkerSettingsPanel.tsx index 16dd1776d..d044d5ea0 100644 --- a/apps/web/src/components/CursorWorkerSettingsPanel.tsx +++ b/apps/web/src/components/CursorWorkerSettingsPanel.tsx @@ -592,8 +592,8 @@ function InstallModal({ ) : null} {result.ok ? (

- Dans Cursor (My Machines) ou Grok Bot, choisis la machine{' '} - {result.name}. + Une machine par dépôt : dans Cursor (My Machines) ou Grok Bot, choisis{' '} + {result.name}-<dépôt> (détail dans « Statut »).

) : null} @@ -817,6 +817,25 @@ function StatusModal({ } (un process par dépôt)`} /> ) : null} + {status.repo_workers?.length ? ( + + {status.repo_workers.map((w) => ( +
  • + + ● + + {w.name || w.path} +
  • + ))} + + } + /> + ) : null} ; + /** Un process et une machine Cursor (`-`) par dépôt servi. */ + repo_workers?: Array<{ + path: string; + name?: string | null; + primary: boolean; + running: boolean; + pid: number | null; + }>; logs_tail: string; hint: string; }; diff --git a/docs/MODULES.md b/docs/MODULES.md index 68767f243..859c7a14d 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 `agent worker start` avec un `--worker-dir` par checkout projet (`.git` présent, max 20 ; sinon un espace vide), puis vérifie 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 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). ## Base de données DevForge (métadonnées)