missions: fix repo checkout for retries + tokenize git.redclaw.dev clones
- 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:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user