task #25: per-mission repo checkout on mission launch
Closes the follow-up gap flagged when task #23 landed. security_scan and benchmark_runner now exec against $CLAWMATES_MISSIONS_ROOT/ {mission_id}/repo — this commit is what actually puts a repo there. - crates/cm-api/src/mission_workspace.rs — new module. ensure_checkout(pool, workspace_id, mission_id): * mission with no repo_id → Ok(None), no-op * repo cloned into $ROOT/{id}/repo (--depth 1) * dir already a git repo → fetch + reset --hard origin/{branch} (idempotent — every launch brings the tree in sync with the remote default_branch) Auth uses the process's ambient git credential setup (SSH agent / .netrc / helper). Tokens deliberately not embedded in URLs. - crates/cm-api/src/mission_orchestrator.rs — on_launch calls ensure_checkout after team materialization + team_id bind. Non-fatal: clone failures log and continue so research_only missions (no repo needed) don't get blocked. - deploy/compose/docker-compose.yml — new named volume missions_workspaces mounted at /var/lib/clawmates-missions on both the server (writer) and where the clawmates-runtime container will mount it (reader for docker exec). CLAWMATES_ MISSIONS_ROOT + CLAWMATES_RUNTIME_CONTAINER env vars set on the server so mission_workspace + exec_target read the same canonical values. The scan/bench trigger buttons now actually produce findings once you (a) run a mission whose repo_id is set, (b) have the clawmates-runtime container bind-mounting missions_workspaces at /var/lib/clawmates-missions. Verified: SQLX_OFFLINE=true cargo check -p cm-api + cargo test -p cm-api --test mission_orchestrator both green.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
//! Per-mission repo checkout.
|
||||
//!
|
||||
//! Missions execute their coding / benchmark / security phases against
|
||||
//! a filesystem checkout of `missions.repo_id` at
|
||||
//! `$CLAWMATES_MISSIONS_ROOT/{mission_id}/repo`. That path is what
|
||||
//! `security_scan::exec_target` + `benchmark_runner::exec_target`
|
||||
//! both `docker exec -w` into.
|
||||
//!
|
||||
//! `ensure_checkout` is called from `mission_orchestrator::on_launch`
|
||||
//! and is idempotent:
|
||||
//! - no repo_id → no-op (Ok(None))
|
||||
//! - dir already a git repo → `fetch + reset --hard origin/<branch>`
|
||||
//! to bring it in sync
|
||||
//! - dir missing → `git clone --depth 1 <url> <path>`
|
||||
//!
|
||||
//! Auth: relies on the ambient git credential setup (SSH agent,
|
||||
//! .netrc, or git-credential helper) in the process environment. We
|
||||
//! deliberately don't embed tokens in URLs — footgun risk outweighs
|
||||
//! the ergonomics, and prod already runs with a helper configured.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use tokio::process::Command;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn missions_root() -> PathBuf {
|
||||
std::env::var("CLAWMATES_MISSIONS_ROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions"))
|
||||
}
|
||||
|
||||
pub fn checkout_path(mission_id: Uuid) -> PathBuf {
|
||||
missions_root().join(mission_id.to_string()).join("repo")
|
||||
}
|
||||
|
||||
/// Ensure the mission's repo is checked out at `checkout_path`.
|
||||
/// Returns Ok(None) when the mission has no repo bound, Ok(Some(path))
|
||||
/// when a checkout is in place (freshly cloned or brought up-to-date).
|
||||
pub async fn ensure_checkout(
|
||||
pool: &sqlx::PgPool,
|
||||
workspace_id: cm_domain::WorkspaceId,
|
||||
mission_id: Uuid,
|
||||
) -> Result<Option<PathBuf>, String> {
|
||||
let mission = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
|
||||
.await
|
||||
.map_err(|e| format!("load mission: {e}"))?
|
||||
.ok_or_else(|| "mission not found".to_string())?;
|
||||
let Some(repo_id) = mission.repo_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let repo = cm_db::repo::repos::get(pool, repo_id, workspace_id)
|
||||
.await
|
||||
.map_err(|e| format!("load repo {repo_id}: {e}"))?;
|
||||
let clone_url = repo
|
||||
.clone_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| format!("repo {repo_id} has no clone_url"))?;
|
||||
let default_branch = repo.default_branch.as_deref().unwrap_or("main");
|
||||
|
||||
let path = checkout_path(mission_id);
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
|
||||
}
|
||||
|
||||
if path.join(".git").exists() {
|
||||
fetch_and_reset(&path, default_branch).await?;
|
||||
} else {
|
||||
clone(&path, clone_url).await?;
|
||||
}
|
||||
Ok(Some(path))
|
||||
}
|
||||
|
||||
async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
|
||||
let out = Command::new("git")
|
||||
.args([
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
url,
|
||||
&path.display().to_string(),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn git clone: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"git clone {url} → exit {}: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
.chars()
|
||||
.take(400)
|
||||
.collect::<String>()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), String> {
|
||||
let fetch = Command::new("git")
|
||||
.args(["-C", &path.display().to_string(), "fetch", "--depth", "1", "origin", branch])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn git fetch: {e}"))?;
|
||||
if !fetch.status.success() {
|
||||
return Err(format!(
|
||||
"git fetch origin {branch} → exit {}: {}",
|
||||
fetch.status,
|
||||
String::from_utf8_lossy(&fetch.stderr)
|
||||
.chars()
|
||||
.take(400)
|
||||
.collect::<String>()
|
||||
));
|
||||
}
|
||||
let reset = Command::new("git")
|
||||
.args([
|
||||
"-C",
|
||||
&path.display().to_string(),
|
||||
"reset",
|
||||
"--hard",
|
||||
&format!("origin/{branch}"),
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("spawn git reset: {e}"))?;
|
||||
if !reset.status.success() {
|
||||
return Err(format!(
|
||||
"git reset --hard origin/{branch} → exit {}: {}",
|
||||
reset.status,
|
||||
String::from_utf8_lossy(&reset.stderr)
|
||||
.chars()
|
||||
.take(400)
|
||||
.collect::<String>()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user