wizard: materialize picked repo across clawstor fleet at step-2-next #5
@@ -438,6 +438,14 @@ pub fn router(state: AppState) -> Router {
|
||||
"/api/research/wizard/refine",
|
||||
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(
|
||||
"/api/research/{id}/artifact",
|
||||
get(routes::research::get_artifact),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
createTopic,
|
||||
wizardRefine,
|
||||
wizardRepoEnsure,
|
||||
wizardRepoRelease,
|
||||
type OutcomeKind,
|
||||
type TopologyKind,
|
||||
type WizardRepoReply,
|
||||
} from "@/lib/api/research";
|
||||
import { RepoPicker, type PickedRepo } from "./RepoPicker";
|
||||
import { NoAgentsGate } from "./NoAgentsGate";
|
||||
@@ -131,6 +134,52 @@ export function ResearchWizard({
|
||||
const [selected, setSelected] = useState<AgentSelection[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
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) {
|
||||
setRefineError(null);
|
||||
@@ -168,6 +217,7 @@ export function ResearchWizard({
|
||||
schedule: { mode: scheduleMode },
|
||||
create_paired_coding_loop: pairedCodingLoop,
|
||||
});
|
||||
setCommitted(true);
|
||||
onCreated(id);
|
||||
} catch (e) {
|
||||
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 =
|
||||
(step === 1 && prompt.trim().length > 0) ||
|
||||
step === 2 ||
|
||||
@@ -196,7 +254,7 @@ export function ResearchWizard({
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
onClick={onClose}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -230,7 +288,7 @@ export function ResearchWizard({
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
width: 30,
|
||||
@@ -252,7 +310,7 @@ export function ResearchWizard({
|
||||
{/* Body */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
|
||||
{agents.length === 0 ? (
|
||||
<NoAgentsGate what="research topic" onDismiss={onClose} />
|
||||
<NoAgentsGate what="research topic" onDismiss={handleClose} />
|
||||
) : null}
|
||||
{agents.length > 0 && step === 1 && (
|
||||
<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.
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -630,11 +710,11 @@ export function ResearchWizard({
|
||||
{step < 6 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5 | 6))}
|
||||
disabled={!canNext || refining}
|
||||
style={{ ...primaryBtn, opacity: !canNext || refining ? 0.4 : 1 }}
|
||||
onClick={handleNext}
|
||||
disabled={!canNext || refining || ensuring}
|
||||
style={{ ...primaryBtn, opacity: !canNext || refining || ensuring ? 0.4 : 1 }}
|
||||
>
|
||||
Next
|
||||
{ensuring ? "Materializing repo…" : "Next"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -197,3 +197,33 @@ export const wizardRefine = (body: {
|
||||
"/api/research/wizard/refine",
|
||||
{ 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 } : {}) }),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user