wizard: materialize picked repo across clawstor fleet at step-2-next (#5)
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 11s
ci / frontend (push) Successful in 29s
ci / e2e (push) Skipped
ci / publish (push) Skipped

This commit was merged in pull request #5.
This commit is contained in:
2026-07-15 11:33:19 +00:00
parent 1cb643142c
commit 149ad992bb
5 changed files with 250 additions and 7 deletions
+1
View File
@@ -22,6 +22,7 @@ pub mod repos;
pub mod research;
pub mod research_pipeline;
pub mod research_setup;
pub mod wizard_repo;
pub mod routines;
pub mod sessions;
pub mod skills;
+124
View File
@@ -0,0 +1,124 @@
//! Wizard-driven clawstor repo materialization.
//!
//! Bridges the research wizard (frontend) to clawstor's fleet-wide
//! `POST /api/v2/repos/{ensure,release}` primitives so a picked repo
//! is checked out on every clawstor peer at step-2-next, and released
//! if the user backs out.
//!
//! The clawstor bearer token is server-side only; the frontend never
//! sees it. Endpoints require the standard `Authed` extractor and
//! resolve the picked `repo_id` against the caller's workspace so a
//! user cannot ensure a repo they can't see.
//!
//! Configured via env:
//! CLAWSTOR_URL — aggregator base, e.g. https://quantum.taila4f562.ts.net/clawstor
//! CLAWSTOR_TOKEN — bearer token whose namespace scopes the writes
//!
//! Both missing = disabled (500). Not-configured is a deploy-time
//! decision; runtime callers get a plain error.
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{ApiError, AppState, Authed};
#[derive(Deserialize)]
pub struct RepoBody {
pub repo_id: Uuid,
/// Git ref (branch, tag, or SHA the remote will accept via
/// `git clone --branch`). When omitted, falls back to the repo's
/// recorded `default_branch`.
#[serde(default)]
pub git_ref: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct PeerResult {
pub peer: String,
pub ok: bool,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub head_sha: Option<String>,
#[serde(default)]
pub cached: Option<bool>,
#[serde(default)]
pub removed: Option<bool>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct FanoutReply {
pub url: String,
pub git_ref: String,
pub workspace: String,
pub peers: Vec<PeerResult>,
pub all_ok: bool,
}
/// `POST /api/research/wizard/repo/ensure` — materialize the picked
/// repo across the clawstor fleet. Returns the aggregator's per-peer
/// reply so the wizard can render which nodes succeeded.
pub async fn ensure_repo(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<RepoBody>,
) -> Result<Json<FanoutReply>, ApiError> {
proxy(&state, &user, body, "ensure").await
}
/// `POST /api/research/wizard/repo/release` — inverse of ensure.
/// Called by the wizard on cancel (modal close before submit).
pub async fn release_repo(
State(state): State<AppState>,
Authed(user): Authed,
Json(body): Json<RepoBody>,
) -> Result<Json<FanoutReply>, ApiError> {
proxy(&state, &user, body, "release").await
}
async fn proxy(
state: &AppState,
user: &cm_auth::AuthedUser,
body: RepoBody,
action: &str,
) -> Result<Json<FanoutReply>, ApiError> {
// Workspace-scoped lookup — a caller can't touch repos outside
// their own workspace even if they know the id.
let repo = cm_db::repo::repos::get(&state.pool, body.repo_id, user.workspace_id).await?;
let url = repo.clone_url.ok_or(ApiError::BadRequest)?;
let git_ref = body
.git_ref
.as_deref()
.map(str::to_string)
.or(repo.default_branch)
.ok_or(ApiError::BadRequest)?;
if url.trim().is_empty() || git_ref.trim().is_empty() {
return Err(ApiError::BadRequest);
}
let base = std::env::var("CLAWSTOR_URL").map_err(|_| ApiError::Internal)?;
let token = std::env::var("CLAWSTOR_TOKEN").map_err(|_| ApiError::Internal)?;
let endpoint = format!("{}/api/v2/repos/{}", base.trim_end_matches('/'), action);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(360))
.build()
.map_err(|_| ApiError::Internal)?;
let resp = client
.post(&endpoint)
.bearer_auth(token)
.json(&serde_json::json!({
"url": url,
"git_ref": git_ref,
}))
.send()
.await
.map_err(|_| ApiError::Internal)?;
if !resp.status().is_success() {
return Err(ApiError::Internal);
}
let reply: FanoutReply = resp.json().await.map_err(|_| ApiError::Internal)?;
Ok(Json(reply))
}