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:
@@ -12,6 +12,7 @@ mod mcp_door;
|
|||||||
mod mcp_skills;
|
mod mcp_skills;
|
||||||
pub mod mission_orchestrator;
|
pub mod mission_orchestrator;
|
||||||
pub mod mission_refiner;
|
pub mod mission_refiner;
|
||||||
|
pub mod mission_workspace;
|
||||||
pub mod node_rules;
|
pub mod node_rules;
|
||||||
pub mod pdf_renderer;
|
pub mod pdf_renderer;
|
||||||
pub mod quota;
|
pub mod quota;
|
||||||
|
|||||||
@@ -82,6 +82,24 @@ pub async fn on_launch(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("bind team on mission: {e}"))?;
|
.map_err(|e| format!("bind team on mission: {e}"))?;
|
||||||
|
|
||||||
|
// Ensure the mission's repo is checked out at
|
||||||
|
// $CLAWMATES_MISSIONS_ROOT/{mission_id}/repo — where
|
||||||
|
// security_scan + benchmark_runner exec against. Non-fatal:
|
||||||
|
// missions without a repo (research_only, custom) skip cleanly,
|
||||||
|
// and clone failures log without blocking launch (the operator
|
||||||
|
// sees the error on the canvas via the run's failed status when
|
||||||
|
// a repo-dependent phase tries to fire).
|
||||||
|
match crate::mission_workspace::ensure_checkout(pool, workspace_id, mission_id).await {
|
||||||
|
Ok(Some(path)) => eprintln!(
|
||||||
|
"mission_orchestrator: repo checked out at {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => eprintln!(
|
||||||
|
"mission_orchestrator: repo checkout for {mission_id} failed (continuing): {e}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Some(team_id))
|
Ok(Some(team_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ volumes:
|
|||||||
# the unix-socket equivalent of the K8s sidecar topology.
|
# the unix-socket equivalent of the K8s sidecar topology.
|
||||||
broker_run: {}
|
broker_run: {}
|
||||||
broker_key: {}
|
broker_key: {}
|
||||||
|
# Per-mission repo checkouts + generated PDFs. Server writes to
|
||||||
|
# this via mission_workspace + pdf_renderer; the (separately-managed)
|
||||||
|
# clawmates-runtime container bind-mounts the same path so scans
|
||||||
|
# + benches exec at the same tree.
|
||||||
|
missions_workspaces: {}
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -118,9 +123,15 @@ services:
|
|||||||
CLAWMATES_CONFIG: /etc/clawmates/clawmates.toml
|
CLAWMATES_CONFIG: /etc/clawmates/clawmates.toml
|
||||||
CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates
|
CLAWMATES_DATABASE__URL: postgres://postgres:${POSTGRES_PASSWORD:?set in .env}@postgres:5432/clawmates
|
||||||
DOCKER_HOST: tcp://socket-proxy:2375
|
DOCKER_HOST: tcp://socket-proxy:2375
|
||||||
|
CLAWMATES_MISSIONS_ROOT: /var/lib/clawmates-missions
|
||||||
|
CLAWMATES_RUNTIME_CONTAINER: ${CLAWMATES_RUNTIME_CONTAINER:-clawmates-runtime}
|
||||||
volumes:
|
volumes:
|
||||||
- ./clawmates.toml:/etc/clawmates/clawmates.toml:ro
|
- ./clawmates.toml:/etc/clawmates/clawmates.toml:ro
|
||||||
- broker_run:/run/clawmates
|
- broker_run:/run/clawmates
|
||||||
|
# Per-mission repo checkouts. Bind-mounted host path so the
|
||||||
|
# separately-managed clawmates-runtime container can see the
|
||||||
|
# same trees at the same path when it exec's for scans/benches.
|
||||||
|
- missions_workspaces:/var/lib/clawmates-missions
|
||||||
networks: [edge, core, engine_net]
|
networks: [edge, core, engine_net]
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
|||||||
Reference in New Issue
Block a user