research/probe: end-to-end smoke test endpoint (skip topics/loops/spawn)
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 48s
ci / rust (push) Failing after 2m34s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

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:
Omar Sobh
2026-07-11 10:21:23 -07:00
parent 3373f57da0
commit 212e68f6b1
4 changed files with 150 additions and 1 deletions
+1
View File
@@ -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;
+147
View File
@@ -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,
}))
}
}
}