research/probe: end-to-end smoke test endpoint (skip topics/loops/spawn)
New: POST /api/research/probe — one-shot pipeline probe against the
workspace's shared ZeroClaw gateway. Drives a trivial 'reply OK'
turn via ZeroClawDriveExecutor::drive and reports per-step timings.
Purpose: stop guessing what's broken in the wizard flow by exercising
JUST the executor→daemon→claude→response path. If probe succeeds
within a few seconds, we know:
- ZEROCLAW_TOKEN + gateway URL config is correct
- Daemon can reach and authenticate against claude
- The full ws round-trip works
…and every other failure we've been chasing (turn timed out, LLM
request failed, ws connect DNS error, etc.) is spawn-config or
prompt-size specific.
Request body (both optional):
{ "prompt": "Reply OK", "agent": "coordinator" }
Response:
{
"verdict": "ok" | "fail",
"total_duration_ms": …,
"prompt_len": …,
"response_preview": "OK",
"steps": [
{ name: "build_executor", duration_ms: …, status: "ok" },
{ name: "drive_turn", duration_ms: …, status: "ok", detail: "tokens=N, output_len=M" }
]
}
Wire-up:
- new routes/probe.rs
- pub mod probe in routes/mod.rs
- POST /api/research/probe registered in lib.rs router
- ZeroClawDriveExecutor::drive promoted from private to pub so the
probe handler can call it (behavior unchanged, other callers were
all inside the same struct).
Usage from anywhere (curl, browser dev tools, etc.):
curl -X POST https://clawmates.work/api/research/probe \
-H "Content-Type: application/json" \
-H "Cookie: <session cookie>" \
-d '{}'
Follow-up: a small frontend button (e.g. bottom of ResearchList) that
POSTs this and renders the response inline, so users don't need to
curl. Skipping in this commit to ship the useful part first.
This commit is contained in:
@@ -17,6 +17,7 @@ pub mod nodes;
|
||||
pub mod oauth;
|
||||
pub mod orgs;
|
||||
pub mod planner;
|
||||
pub mod probe;
|
||||
pub mod repos;
|
||||
pub mod research;
|
||||
pub mod research_pipeline;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! 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<String>,
|
||||
/// Which agent alias to drive. Defaults to the daemon's
|
||||
/// ZEROCLAW_DEFAULT_AGENT (currently `coordinator`).
|
||||
#[serde(default)]
|
||||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub steps: Vec<ProbeStep>,
|
||||
}
|
||||
|
||||
/// `POST /api/research/probe`.
|
||||
pub async fn probe(
|
||||
State(_state): State<AppState>,
|
||||
Authed(_user): Authed,
|
||||
Json(body): Json<ProbeRequest>,
|
||||
) -> Result<Json<ProbeResponse>, 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<ProbeStep> = 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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user