//! One-shot end-to-end pipeline probe. //! //! `POST /api/research/probe` bypasses the wizard / topics / loops / //! per-team spawn machinery and drives a single trivial turn against //! the workspace's shared ZeroClaw gateway with a minimal prompt. //! Purpose: distinguish "pipeline is broken" from "the coordinator //! prompt is too big for the current daemon timeouts". If this //! succeeds, every failure we've been chasing is spawn-config or //! prompt-size specific. //! //! Body: `{ "prompt": "…", "agent": "…" }` — both optional; defaults are //! a two-letter reply prompt and the daemon's default agent alias. //! Returns per-step timings + verdict. use axum::extract::State; use axum::Json; use serde::{Deserialize, Serialize}; use std::time::Instant; use crate::topology_exec::ZeroClawDriveExecutor; use crate::{ApiError, AppState, Authed}; #[derive(Deserialize, Default)] pub struct ProbeRequest { /// The prompt to send. Defaults to a two-letter reply prompt so /// the daemon returns fast and we can measure baseline latency. #[serde(default)] pub prompt: Option, /// Which agent alias to drive. Defaults to the daemon's /// ZEROCLAW_DEFAULT_AGENT (currently `coordinator`). #[serde(default)] pub agent: Option, } #[derive(Serialize)] pub struct ProbeStep { pub name: &'static str, pub duration_ms: u128, pub status: &'static str, #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option, } #[derive(Serialize)] pub struct ProbeResponse { pub verdict: &'static str, pub total_duration_ms: u128, pub prompt_len: usize, #[serde(skip_serializing_if = "Option::is_none")] pub response_preview: Option, pub steps: Vec, } /// `POST /api/research/probe`. pub async fn probe( State(_state): State, Authed(_user): Authed, Json(body): Json, ) -> Result, ApiError> { let prompt = body.prompt.unwrap_or_else(|| { "Respond with only these two letters (nothing else, no explanation): OK".to_string() }); let agent_override = body.agent; let started = Instant::now(); let mut steps: Vec = Vec::new(); // ── Step 1: build the executor from env (parses ZEROCLAW_TOKEN, // ZEROCLAW_GATEWAY_URL, ZEROCLAW_AGENT_MAP). Anything wrong with // the workspace config surfaces here. let s1 = Instant::now(); let executor = match ZeroClawDriveExecutor::from_env() { Ok(e) => e, Err(e) => { steps.push(ProbeStep { name: "build_executor", duration_ms: s1.elapsed().as_millis(), status: "fail", detail: Some(e.clone()), }); return Ok(Json(ProbeResponse { verdict: "fail", total_duration_ms: started.elapsed().as_millis(), prompt_len: prompt.len(), response_preview: None, steps, })); } }; steps.push(ProbeStep { name: "build_executor", duration_ms: s1.elapsed().as_millis(), status: "ok", detail: None, }); // ── Step 2: drive one turn end-to-end (opens ws, sends message, // drains events until terminal). All of "handshake / auth / // daemon spawn claude / claude call / response stream" collapse // into this single measurement because ZeroClawDriveExecutor // doesn't expose finer-grained hooks. But: if this succeeds // within a few seconds, EVERY layer works and the coordinator // failures we've been chasing are prompt-size specific. let agent = agent_override.unwrap_or_else(|| "coordinator".to_string()); let s2 = Instant::now(); match executor.drive(&agent, &prompt).await { Ok(outcome) => { let out_ms = s2.elapsed().as_millis(); steps.push(ProbeStep { name: "drive_turn", duration_ms: out_ms, status: "ok", detail: Some(format!( "tokens={}, output_len={}", outcome.tokens, outcome.output.len() )), }); let preview = if outcome.output.len() > 200 { format!("{}…", &outcome.output[..200]) } else { outcome.output.clone() }; Ok(Json(ProbeResponse { verdict: "ok", total_duration_ms: started.elapsed().as_millis(), prompt_len: prompt.len(), response_preview: Some(preview), steps, })) } Err(e) => { steps.push(ProbeStep { name: "drive_turn", duration_ms: s2.elapsed().as_millis(), status: "fail", detail: Some(format!("{e}")), }); Ok(Json(ProbeResponse { verdict: "fail", total_duration_ms: started.elapsed().as_millis(), prompt_len: prompt.len(), response_preview: None, steps, })) } } }