Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
541 changes: 441 additions & 100 deletions apps/server/src/cursor_worker.rs

Large diffs are not rendered by default.

818 changes: 745 additions & 73 deletions apps/server/src/cursor_worker_routes.rs

Large diffs are not rendered by default.

215 changes: 215 additions & 0 deletions apps/server/src/cursor_worker_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
use super::*;

#[test]
fn build_args_multi_dirs_without_key() {
let args = build_worker_args(
"demeter",
&[
"/data/devforge/applications/a".into(),
"/data/devforge/applications/b".into(),
],
)
.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_eq!(args.last().unwrap(), "start");
assert!(args.contains(&MANAGEMENT_ADDR.to_string()));
// La clé ne passe jamais en argument (visible dans `ps`).
assert!(!args.iter().any(|a| a == "--api-key"));
}

#[test]
fn build_args_caps_worker_dirs() {
let dirs: Vec<String> = (0..30).map(|i| format!("/r/{i}")).collect();
let args = build_worker_args("x", &dirs).unwrap();
let n = args.iter().filter(|a| *a == "--worker-dir").count();
assert_eq!(n, MAX_WORKER_DIRS);
}

#[test]
fn build_args_rejects_empty() {
assert!(build_worker_args("x", &[]).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"));
}

#[test]
fn slug_and_default_name() {
assert_eq!(slug_name("ZimaCube (NAS)"), "zimacube-nas");
assert_eq!(slug_name(" demeter "), "demeter");
assert_eq!(slug_name("Ce serveur"), "ce-serveur");
let mut s = WorkerSettings::default();
assert_eq!(resolve_worker_name_for_node(&s, "ZimaCube"), "zimacube");
s.name = "perso".into();
assert_eq!(resolve_worker_name_for_node(&s, "ZimaCube"), "perso");
}

#[test]
fn validate_api_key_rules() {
assert!(validate_api_key("").is_err());
assert!(validate_api_key("short").is_err());
assert!(validate_api_key("key_abc def ghi jkl mno").is_err());
assert_eq!(
validate_api_key(" key_0123456789abcdef ").unwrap(),
"key_0123456789abcdef"
);
}

#[test]
fn leader_target() {
assert!(is_leader_target(""));
assert!(is_leader_target("default"));
assert!(!is_leader_target("node-1"));
}

#[test]
fn shell_quote_escapes() {
assert_eq!(shell_quote("a b"), "'a b'");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
}

#[test]
fn scripts_never_print_key_and_use_env() {
let key = "key_SECRET_0123456789";
let s = start_script(
&ScriptBase::NodeDataDir,
"demeter",
&["/data/applications/a".into()],
key,
);
assert!(s.contains("CURSOR_API_KEY='key_SECRET_0123456789'"));
assert!(!s.contains("--api-key"));
// La clé n'apparaît qu'une fois (affectation), jamais dans un echo.
assert_eq!(s.matches(key).count(), 1);
assert!(!s.lines().any(|l| l.contains("echo") && l.contains(key)));
assert!(s.contains("${DEVFORGE_DATA_DIR:-/data}/cursor-worker"));
assert!(s.contains("--management-addr"));
assert!(s.contains("setsid"));
assert!(s.contains("</dev/null"));
}

#[test]
fn install_script_targets_managed_home() {
let s = install_script(&ScriptBase::Literal("/data/cursor-worker".into()));
assert!(s.starts_with("D='/data/cursor-worker'"));
assert!(s.contains("export HOME=\"$D/home\""));
assert!(s.contains(INSTALL_URL));
assert!(s.contains("DF_AGENT="));
}

#[test]
fn parse_output_reads_markers_and_logs() {
let out = "DF_AGENT=/data/cursor-worker/home/.local/bin/agent\nDF_VERSION=2026.10.1\nDF_DIR=/data/applications/a\nDF_DIR=/data/applications/b\nDF_RUNNING=1\nDF_PID=4242\nDF_READY=1\nDF_LOGS_BEGIN\nconnected\nDF_RUNNING=0\n";
let r = parse_script_output(out);
assert_eq!(
r.agent.as_deref(),
Some("/data/cursor-worker/home/.local/bin/agent")
);
assert_eq!(r.version.as_deref(), Some("2026.10.1"));
assert_eq!(r.dirs.len(), 2);
assert!(r.running);
assert_eq!(r.pid, Some(4242));
assert_eq!(r.ready, Some(true));
// Après DF_LOGS_BEGIN, tout est log (pas réinterprété).
assert!(r.logs.contains("connected"));
assert!(r.logs.contains("DF_RUNNING=0"));
}

#[test]
fn hint_never_asks_for_a_command() {
for (cli, key, run, ready) in [
(false, false, false, None),
(false, true, false, None),
(true, true, false, None),
(true, true, true, Some(false)),
(true, true, true, Some(true)),
] {
let h = status_hint(cli, key, run, ready);
assert!(!h.contains("curl"), "{h}");
assert!(!h.contains("| bash"), "{h}");
}
}

/// Exécute réellement les scripts (sh) avec un faux `agent` : installation
/// sautée (CLI présent), démarrage, statut, arrêt.
#[cfg(target_os = "linux")]
#[test]
fn scripts_run_end_to_end_with_fake_agent() {
use std::os::unix::fs::PermissionsExt;
let tmp = std::env::temp_dir().join(format!("cw-e2e-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let base_dir = tmp.join("cursor-worker");
let bin_dir = base_dir.join("home/.local/bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let fake = bin_dir.join("agent");
std::fs::write(
&fake,
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 9.9.9; exit 0; fi\n[ -n \"$CURSOR_API_KEY\" ] || { echo nokey >&2; exit 1; }\necho \"args: $*\"\nexec sleep 30\n",
)
.unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
let repo = tmp.join("repo a");
std::fs::create_dir_all(repo.join(".git")).unwrap();
let base = ScriptBase::Literal(base_dir.display().to_string());

let run = |script: &str| {
std::process::Command::new("sh")
.arg("-c")
.arg(script)
.output()
.unwrap()
};

let out = run(&install_script(&base));
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let r = parse_script_output(&String::from_utf8_lossy(&out.stdout));
assert_eq!(r.version.as_deref(), Some("9.9.9"));

let out = run(&start_script(
&base,
"demo",
&[repo.display().to_string(), "/nope".into()],
"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()]);
let log = std::fs::read_to_string(base_dir.join("worker.out")).unwrap();
assert!(log.contains("--name demo"));
assert!(log.contains("--worker-dir"));
assert!(!log.contains("key_0123456789abcdef"));

let out = run(&status_script(&base));
let r = parse_script_output(&String::from_utf8_lossy(&out.stdout));
assert!(r.running);
assert_eq!(r.version.as_deref(), Some("9.9.9"));

let out = run(&stop_script(&base));
let r = parse_script_output(&String::from_utf8_lossy(&out.stdout));
assert!(!r.running);
let out = run(&status_script(&base));
assert!(!parse_script_output(&String::from_utf8_lossy(&out.stdout)).running);
let _ = std::fs::remove_dir_all(&tmp);
}
2 changes: 2 additions & 0 deletions apps/server/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,8 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> {
("cursor_worker_api_key", "TEXT NOT NULL DEFAULT ''"),
("cursor_worker_binary", "TEXT NOT NULL DEFAULT ''"),
("cursor_worker_extra_dirs", "TEXT NOT NULL DEFAULT ''"),
// Nœud qui héberge le worker ('' = leader / ce serveur).
("cursor_worker_node", "TEXT NOT NULL DEFAULT ''"),
] {
let sql = format!("ALTER TABLE instance_settings ADD COLUMN {col} {def}");
let _ = sqlx::query(&sql).execute(pool).await;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}

update_routes::spawn_auto_update(state.clone());
cursor_worker::spawn_autostart(state.pool.clone());
cursor_worker_routes::spawn_autostart(state.clone());

// Advertise agent tools on the local MCP server surface.
state
Expand Down
23 changes: 15 additions & 8 deletions apps/web/src/components/AdminPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,9 @@ function AdminHub() {
const [features, setFeatures] = useState<BetaFeatures | null>(null);
const [cursorWorker, setCursorWorker] = useState<{
running: boolean;
eligible: number;
cli: boolean;
eligible: number | null;
installed: boolean;
node: string;
} | null>(null);
const [dockerGc, setDockerGc] = useState<{
enabled: boolean;
Expand Down Expand Up @@ -288,8 +289,9 @@ function AdminHub() {
if (cursorRes.status === 'fulfilled') {
setCursorWorker({
running: !!cursorRes.value.running,
eligible: cursorRes.value.eligible_count ?? 0,
cli: !!cursorRes.value.cli_installed,
eligible: cursorRes.value.eligible_count ?? null,
installed: !!cursorRes.value.cli_installed && !!cursorRes.value.api_key_set,
node: cursorRes.value.node?.name ?? '',
});
}
if (pgRes.status === 'fulfilled') {
Expand Down Expand Up @@ -497,12 +499,17 @@ function AdminHub() {
? tileStatus('1/2', 'warn', '1 active')
: tileStatus('Off', 'neutral', 'Masquées');

const cursorWhere = cursorWorker
? [cursorWorker.node, cursorWorker.eligible != null ? `${cursorWorker.eligible} repo(s)` : '']
.filter(Boolean)
.join(' · ')
: '';
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)`)
? tileStatus('ON', 'ok', cursorWhere)
: !cursorWorker.installed
? tileStatus('Off', 'warn', 'Un clic pour installer')
: tileStatus('Off', 'neutral', cursorWhere)
: null;

const gcTile = !dockerGc
Expand Down
36 changes: 36 additions & 0 deletions apps/web/src/components/CursorWorkerSettingsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { installNeeds, workerSummary } from './CursorWorkerSettingsPanel';

const base = {
running: false,
ready: null,
cli_installed: false,
api_key_set: false,
node_missing: false,
node_error: null,
};

describe('workerSummary', () => {
it('asks to install when key or CLI is missing', () => {
expect(workerSummary(base)).toMatchObject({ label: 'À installer', installed: false });
expect(workerSummary({ ...base, api_key_set: true })).toMatchObject({ installed: false });
});
it('reports running / connecting / stopped', () => {
const ok = { ...base, cli_installed: true, api_key_set: true };
expect(workerSummary({ ...ok, running: true, ready: true })).toMatchObject({ label: 'ON', tone: 'ok' });
expect(workerSummary({ ...ok, running: true, ready: false })).toMatchObject({ tone: 'accent' });
expect(workerSummary(ok)).toMatchObject({ label: 'Off', installed: true });
});
it('flags unreachable or missing nodes', () => {
expect(workerSummary({ ...base, node_error: 'down' }).tone).toBe('danger');
expect(workerSummary({ ...base, node_missing: true }).tone).toBe('danger');
});
});

describe('installNeeds', () => {
it('only asks for the Cursor API key, and only when none is stored', () => {
expect(installNeeds({ api_key_set: false })).toEqual(['api_key']);
expect(installNeeds({ api_key_set: true })).toEqual([]);
expect(installNeeds(null)).toEqual([]);
});
});
Loading
Loading