wizard: materialize picked repo across clawstor fleet at step-2-next #5

Merged
osobh merged 1 commits from wizard-clawstor-repo-ensure into main 2026-07-15 11:33:20 +00:00
5 changed files with 250 additions and 7 deletions
+8
View File
@@ -438,6 +438,14 @@ pub fn router(state: AppState) -> Router {
"/api/research/wizard/refine", "/api/research/wizard/refine",
post(routes::research::refine_wizard), post(routes::research::refine_wizard),
) )
.route(
"/api/research/wizard/repo/ensure",
post(routes::wizard_repo::ensure_repo),
)
.route(
"/api/research/wizard/repo/release",
post(routes::wizard_repo::release_repo),
)
.route( .route(
"/api/research/{id}/artifact", "/api/research/{id}/artifact",
get(routes::research::get_artifact), get(routes::research::get_artifact),
+1
View File
@@ -22,6 +22,7 @@ pub mod repos;
pub mod research; pub mod research;
pub mod research_pipeline; pub mod research_pipeline;
pub mod research_setup; pub mod research_setup;
pub mod wizard_repo;
pub mod routines; pub mod routines;
pub mod sessions; pub mod sessions;
pub mod skills; 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))
}
@@ -13,8 +13,11 @@ import type { Agent } from "@/lib/api/schemas";
import { import {
createTopic, createTopic,
wizardRefine, wizardRefine,
wizardRepoEnsure,
wizardRepoRelease,
type OutcomeKind, type OutcomeKind,
type TopologyKind, type TopologyKind,
type WizardRepoReply,
} from "@/lib/api/research"; } from "@/lib/api/research";
import { RepoPicker, type PickedRepo } from "./RepoPicker"; import { RepoPicker, type PickedRepo } from "./RepoPicker";
import { NoAgentsGate } from "./NoAgentsGate"; import { NoAgentsGate } from "./NoAgentsGate";
@@ -131,6 +134,52 @@ export function ResearchWizard({
const [selected, setSelected] = useState<AgentSelection[]>([]); const [selected, setSelected] = useState<AgentSelection[]>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
// Track the repo we've asked clawstor to materialize across the fleet
// (set on step-2 → step-3 transition). Used to release on cancel and
// to render per-peer status.
const [ensuredRepoId, setEnsuredRepoId] = useState<string | null>(null);
const [ensuring, setEnsuring] = useState(false);
const [ensureError, setEnsureError] = useState<string | null>(null);
const [ensureReply, setEnsureReply] = useState<WizardRepoReply | null>(null);
const [committed, setCommitted] = useState(false);
async function maybeEnsureRepo(): Promise<boolean> {
if (!repo) return true;
if (ensuredRepoId === repo.repo_id) return true;
setEnsureError(null);
setEnsuring(true);
try {
const reply = await wizardRepoEnsure(
repo.repo_id,
repo.default_branch ?? undefined,
);
setEnsureReply(reply);
setEnsuredRepoId(repo.repo_id);
return true;
} catch (e) {
setEnsureError(e instanceof Error ? e.message : "ensure failed");
return false;
} finally {
setEnsuring(false);
}
}
async function handleClose() {
// Best-effort release on cancel. If we already committed via
// submit(), the repo stays live for the topic and we skip.
if (ensuredRepoId && !committed) {
try {
await wizardRepoRelease(
ensuredRepoId,
repo?.default_branch ?? undefined,
);
} catch {
// Swallow — cancel path is best-effort; TTL sweeper follow-up
// in a future clawstor phase will cover the true safety net.
}
}
onClose();
}
async function runRefine(prior?: string) { async function runRefine(prior?: string) {
setRefineError(null); setRefineError(null);
@@ -168,6 +217,7 @@ export function ResearchWizard({
schedule: { mode: scheduleMode }, schedule: { mode: scheduleMode },
create_paired_coding_loop: pairedCodingLoop, create_paired_coding_loop: pairedCodingLoop,
}); });
setCommitted(true);
onCreated(id); onCreated(id);
} catch (e) { } catch (e) {
setSubmitError(e instanceof Error ? e.message : "create failed"); setSubmitError(e instanceof Error ? e.message : "create failed");
@@ -176,6 +226,14 @@ export function ResearchWizard({
} }
} }
async function handleNext() {
if (step === 2) {
const ok = await maybeEnsureRepo();
if (!ok) return;
}
setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5 | 6));
}
const canNext = const canNext =
(step === 1 && prompt.trim().length > 0) || (step === 1 && prompt.trim().length > 0) ||
step === 2 || step === 2 ||
@@ -196,7 +254,7 @@ export function ResearchWizard({
justifyContent: "center", justifyContent: "center",
padding: 24, padding: 24,
}} }}
onClick={onClose} onClick={handleClose}
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -230,7 +288,7 @@ export function ResearchWizard({
</span> </span>
<button <button
type="button" type="button"
onClick={onClose} onClick={handleClose}
aria-label="Close" aria-label="Close"
style={{ style={{
width: 30, width: 30,
@@ -252,7 +310,7 @@ export function ResearchWizard({
{/* Body */} {/* Body */}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}> <div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{agents.length === 0 ? ( {agents.length === 0 ? (
<NoAgentsGate what="research topic" onDismiss={onClose} /> <NoAgentsGate what="research topic" onDismiss={handleClose} />
) : null} ) : null}
{agents.length > 0 && step === 1 && ( {agents.length > 0 && step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
@@ -282,6 +340,28 @@ export function ResearchWizard({
use it as context, and downstream outcomes can cite it. use it as context, and downstream outcomes can cite it.
</p> </p>
<RepoPicker value={repo} onChange={setRepo} optional /> <RepoPicker value={repo} onChange={setRepo} optional />
{ensureError && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
Fleet materialization failed: {ensureError}
</p>
)}
{ensureReply && ensuredRepoId === repo?.repo_id && (
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
<div style={{ marginBottom: 4 }}>
{ensureReply.all_ok ? "✓" : "!"} materialized on{" "}
{ensureReply.peers.filter((p) => p.ok).length}/
{ensureReply.peers.length} peers · workspace{" "}
{ensureReply.workspace}
</div>
{ensureReply.peers.map((p) => (
<div key={p.peer}>
{p.ok ? " ✓" : " ✗"} {p.peer}
{p.cached ? " (cached)" : ""}
{p.error ? `${p.error}` : ""}
</div>
))}
</div>
)}
</div> </div>
)} )}
@@ -630,11 +710,11 @@ export function ResearchWizard({
{step < 6 ? ( {step < 6 ? (
<button <button
type="button" type="button"
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5 | 6))} onClick={handleNext}
disabled={!canNext || refining} disabled={!canNext || refining || ensuring}
style={{ ...primaryBtn, opacity: !canNext || refining ? 0.4 : 1 }} style={{ ...primaryBtn, opacity: !canNext || refining || ensuring ? 0.4 : 1 }}
> >
Next {ensuring ? "Materializing repo…" : "Next"}
</button> </button>
) : ( ) : (
<button <button
+30
View File
@@ -197,3 +197,33 @@ export const wizardRefine = (body: {
"/api/research/wizard/refine", "/api/research/wizard/refine",
{ method: "POST", body: JSON.stringify(body) }, { method: "POST", body: JSON.stringify(body) },
); );
export interface WizardRepoPeer {
peer: string;
ok: boolean;
path?: string;
head_sha?: string;
cached?: boolean;
removed?: boolean;
error?: string;
}
export interface WizardRepoReply {
url: string;
git_ref: string;
workspace: string;
peers: WizardRepoPeer[];
all_ok: boolean;
}
export const wizardRepoEnsure = (repo_id: string, git_ref?: string) =>
api<WizardRepoReply>("/api/research/wizard/repo/ensure", {
method: "POST",
body: JSON.stringify({ repo_id, ...(git_ref ? { git_ref } : {}) }),
});
export const wizardRepoRelease = (repo_id: string, git_ref?: string) =>
api<WizardRepoReply>("/api/research/wizard/repo/release", {
method: "POST",
body: JSON.stringify({ repo_id, ...(git_ref ? { git_ref } : {}) }),
});