missions: fix repo checkout for retries + tokenize git.redclaw.dev clones
ci / gates (push) Successful in 7s
ci / frontend (push) Successful in 37s
ci / rust (push) Successful in 3m40s
ci / e2e (push) Skipped
ci / publish (push) Successful in 2m37s

- mission_orchestrator: run ensure_checkout BEFORE the team_id
  short-circuit. Previously, a re-launched or retried mission bailed
  out at the team_id=already-bound guard and skipped repo checkout
  entirely, so agents ran against an empty workspace.
- mission_workspace: inject GITEA_TOKEN into git.redclaw.dev URLs so
  clone auth works from the server container. Redact any token
  echoed back on failure.
- refresh buttons on MissionCanvas + MissionsList now spin the icon
  while loading so clicks are visibly acknowledged.
- refresh-spinner keyframe added to motion.css.

Requires operator on gw-04: sudo chown 65532:65532 /var/lib/clawmates-missions
(applied 2026-07-21 pre-commit).
This commit is contained in:
Omar Sobh
2026-07-21 20:33:33 -07:00
parent f1f3de4db0
commit 1e91a19707
5 changed files with 87 additions and 30 deletions
+23 -19
View File
@@ -39,6 +39,7 @@ pub async fn on_launch(
mission_id: Uuid, mission_id: Uuid,
node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>, node_hub: Option<std::sync::Arc<crate::fleet::NodeHub>>,
) -> Result<Option<Uuid>, String> { ) -> Result<Option<Uuid>, String> {
eprintln!("mission_orchestrator::on_launch fired mission_id={mission_id}");
let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid()) let Some(mission) = cm_db::repo::missions::get(pool, mission_id, workspace_id.as_uuid())
.await .await
.map_err(|e| format!("load mission: {e}"))? .map_err(|e| format!("load mission: {e}"))?
@@ -46,8 +47,29 @@ pub async fn on_launch(
return Err("mission not found".into()); return Err("mission not found".into());
}; };
// Skip if already bound. // ensure_checkout is idempotent (fetch+reset on existing clones,
// clone on missing dirs) so we run it BEFORE the team_id short-
// circuit: a re-launched or retried mission still needs a fresh
// repo checkout even though its team was minted on the first
// launch. Non-fatal — logs and continues on failure.
match crate::mission_workspace::ensure_checkout(pool, workspace_id, mission_id).await {
Ok(Some(path)) => eprintln!(
"mission_orchestrator: repo checked out at {} for mission {mission_id}",
path.display()
),
Ok(None) => eprintln!(
"mission_orchestrator: mission {mission_id} has no repo bound, skipping checkout"
),
Err(e) => eprintln!(
"mission_orchestrator: repo checkout for {mission_id} failed (continuing): {e}"
),
}
// Skip team materialization if already bound.
if mission.team_id.is_some() { if mission.team_id.is_some() {
eprintln!(
"mission_orchestrator::on_launch team_id already bound for mission_id={mission_id} — skipping team materialization"
);
return Ok(mission.team_id); return Ok(mission.team_id);
} }
@@ -139,24 +161,6 @@ 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}"
),
}
// Herdr second-runtime: if runtime_kind='local_herdr', spawn a // Herdr second-runtime: if runtime_kind='local_herdr', spawn a
// pane on target_node running the first available local CLI. // pane on target_node running the first available local CLI.
// Non-fatal on failure — the operator sees the error in server // Non-fatal on failure — the operator sees the error in server
+43 -7
View File
@@ -13,10 +13,11 @@
//! to bring it in sync //! to bring it in sync
//! - dir missing → `git clone --depth 1 <url> <path>` //! - dir missing → `git clone --depth 1 <url> <path>`
//! //!
//! Auth: relies on the ambient git credential setup (SSH agent, //! Auth: for `git.redclaw.dev` clones we inject the ambient
//! .netrc, or git-credential helper) in the process environment. We //! `GITEA_TOKEN` (already provisioned in the server container's env)
//! deliberately don't embed tokens in URLs — footgun risk outweighs //! into the clone URL as basic-auth. For any other host we fall back
//! the ergonomics, and prod already runs with a helper configured. //! to the ambient credential setup (SSH agent, .netrc, git helper) —
//! prod hosts run with those configured. Tokens are never logged.
use std::path::PathBuf; use std::path::PathBuf;
use tokio::process::Command; use tokio::process::Command;
@@ -64,14 +65,32 @@ pub async fn ensure_checkout(
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?; .map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
} }
let auth_url = with_ambient_auth(clone_url);
if path.join(".git").exists() { if path.join(".git").exists() {
fetch_and_reset(&path, default_branch).await?; fetch_and_reset(&path, default_branch).await?;
} else { } else {
clone(&path, clone_url).await?; clone(&path, &auth_url).await?;
} }
Ok(Some(path)) Ok(Some(path))
} }
/// If the URL points at git.redclaw.dev AND GITEA_TOKEN is set in the
/// environment, rewrite it to include the token as basic-auth. Returns
/// the URL unchanged otherwise. The token is never logged (we only
/// pass the rewritten URL into `git clone` via argv).
fn with_ambient_auth(url: &str) -> String {
let Ok(token) = std::env::var("GITEA_TOKEN") else {
return url.to_string();
};
if token.is_empty() {
return url.to_string();
}
if let Some(rest) = url.strip_prefix("https://git.redclaw.dev/") {
return format!("https://oauth2:{token}@git.redclaw.dev/{rest}");
}
url.to_string()
}
async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> { async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
let out = Command::new("git") let out = Command::new("git")
.args(["clone", "--depth", "1", url, &path.display().to_string()]) .args(["clone", "--depth", "1", url, &path.display().to_string()])
@@ -80,9 +99,9 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
.map_err(|e| format!("spawn git clone: {e}"))?; .map_err(|e| format!("spawn git clone: {e}"))?;
if !out.status.success() { if !out.status.success() {
return Err(format!( return Err(format!(
"git clone {url} → exit {}: {}", "git clone → exit {}: {}",
out.status, out.status,
String::from_utf8_lossy(&out.stderr) redact_token(&String::from_utf8_lossy(&out.stderr))
.chars() .chars()
.take(400) .take(400)
.collect::<String>() .collect::<String>()
@@ -91,6 +110,23 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
Ok(()) Ok(())
} }
fn redact_token(s: &str) -> String {
// Strip any "oauth2:<token>@" segment that git may echo back on
// failures. Belt-and-braces: also nuke any raw token env value.
let mut out = s.to_string();
if let Some(pos) = out.find("oauth2:") {
if let Some(at) = out[pos..].find('@') {
out.replace_range(pos..pos + at, "oauth2:***");
}
}
if let Ok(t) = std::env::var("GITEA_TOKEN") {
if !t.is_empty() {
out = out.replace(&t, "***");
}
}
out
}
async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), String> { async fn fetch_and_reset(path: &std::path::Path, branch: &str) -> Result<(), String> {
let fetch = Command::new("git") let fetch = Command::new("git")
.args([ .args([
@@ -427,11 +427,15 @@ export function MissionCanvas({
<button <button
type="button" type="button"
onClick={load} onClick={load}
disabled={loading}
title="Refresh" title="Refresh"
aria-label="Refresh" aria-label="Refresh"
style={iconBtn} style={{ ...iconBtn, opacity: loading ? 0.6 : 1 }}
> >
<RefreshCw size={13} /> <RefreshCw
size={13}
style={loading ? { animation: "cm-spin 1s linear infinite" } : undefined}
/>
</button> </button>
{mission.status === "draft" && (() => { {mission.status === "draft" && (() => {
// Launch is enabled when we have SOMETHING that can // Launch is enabled when we have SOMETHING that can
@@ -179,11 +179,15 @@ export function MissionsList({
<button <button
type="button" type="button"
onClick={load} onClick={load}
disabled={loading}
title="Refresh" title="Refresh"
aria-label="Refresh" aria-label="Refresh"
style={iconBtn} style={{ ...iconBtn, opacity: loading ? 0.6 : 1 }}
> >
<RotateCw size={13} /> <RotateCw
size={13}
style={loading ? { animation: "cm-spin 1s linear infinite" } : undefined}
/>
</button> </button>
<button <button
type="button" type="button"
+9
View File
@@ -317,3 +317,12 @@
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
@keyframes cm-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}