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",
|
"/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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 } : {}) }),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user