//! Pipeline diagnostics for a research topic. //! //! Walks the pipeline stages (staffing, repo, container, runs, outcomes, //! approval) and returns a per-stage report. Read-only — every stage is //! evaluated in isolation and any lookup failure downgrades to warn/skip //! rather than failing the endpoint. Purpose: give users end-to-end //! visibility so silent failures (a run that dies before writing an //! outcome) are surfaced instead of buried in an empty artifact //! download. use axum::extract::{Path, State}; use axum::Json; use serde::Serialize; use sqlx::Row; use uuid::Uuid; use crate::{ApiError, AppState, Authed}; #[derive(Serialize)] pub struct PipelineStage { /// Machine-readable stage id: staffing / repo / container / runs / /// outcomes / approval. Frontend uses this to key the checklist. pub key: String, /// User-facing one-line summary. pub label: String, /// ok | warn | fail | skip — drives the pill color in the UI. pub status: &'static str, /// Optional error text (last-known failure reason from the underlying /// row) so the user can see WHY a stage failed instead of a green tick /// with no artifact behind it. #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option, } #[derive(Serialize)] pub struct PipelineState { pub topic_id: Uuid, pub status: String, pub stages: Vec, } /// `GET /api/research/:id/pipeline-state`. pub async fn pipeline_state( State(state): State, Authed(user): Authed, Path(id): Path, ) -> Result, ApiError> { let topic = cm_db::repo::research_topics::get(&state.pool, id, user.workspace_id.as_uuid()) .await? .ok_or(ApiError::NotFound)?; let mut stages = Vec::new(); // 1. staffing. let agents = cm_db::repo::research_topics::agents(&state.pool, id) .await .unwrap_or_default(); stages.push(PipelineStage { key: "staffing".into(), label: format!("{} agent(s) assigned", agents.len()), status: if agents.is_empty() { "fail" } else { "ok" }, detail: None, }); // 2. repo — optional. When bound, we check the clone actually landed. if topic.repo_id.is_some() { let cloned = topic .repo_workspace_path .as_ref() .is_some_and(|p| !p.is_empty()); stages.push(PipelineStage { key: "repo".into(), label: if cloned { format!( "Repo cloned at {}", topic.repo_workspace_path.as_deref().unwrap_or("") ) } else { "Repo bound but never cloned".into() }, status: if cloned { "ok" } else { "fail" }, detail: None, }); } else { stages.push(PipelineStage { key: "repo".into(), label: "No repo bound (optional)".into(), status: "skip", detail: None, }); } // 3. container — per-topic team runtime. let container_ok = topic.zeroclaw_container_name.is_some() && topic.zeroclaw_gateway_url.is_some(); stages.push(PipelineStage { key: "container".into(), label: if container_ok { format!( "Container: {}", topic.zeroclaw_container_name.as_deref().unwrap_or("") ) } else { "Container not spawned (falling back to shared gateway)".into() }, status: if container_ok { "ok" } else { "warn" }, detail: None, }); // 4. runs — catches the failure with the actual error text. let run_rows = sqlx::query( "SELECT id, status, error, created_at FROM topology_runs WHERE research_topic_id = $1 ORDER BY created_at DESC", ) .bind(id) .fetch_all(&state.pool) .await .unwrap_or_default(); let n_runs = run_rows.len(); let n_failed = run_rows .iter() .filter(|r| r.try_get::("status").ok().as_deref() == Some("failed")) .count(); let latest_error = run_rows .iter() .find_map(|r| r.try_get::, _>("error").ok().flatten()) .filter(|s| !s.is_empty()); let run_status = if n_runs == 0 { "warn" } else if n_failed == n_runs { "fail" } else if n_failed > 0 { "warn" } else { "ok" }; stages.push(PipelineStage { key: "runs".into(), label: format!("{n_runs} run(s), {n_failed} failed"), status: run_status, detail: latest_error, }); // 5. outcomes — the artifact rows get_artifact reads. let outcome_count: i64 = sqlx::query_scalar("SELECT count(*) FROM research_outcomes WHERE topic_id = $1") .bind(id) .fetch_one(&state.pool) .await .unwrap_or(0); stages.push(PipelineStage { key: "outcomes".into(), label: format!("{outcome_count} outcome(s) written"), status: if outcome_count > 0 { "ok" } else { "fail" }, detail: if outcome_count == 0 { Some("No outcome produced yet — check the runs stage for the failure reason.".into()) } else { None }, }); // 6. approval — pending publish-approval, if any. let pending = cm_db::repo::research_publish_approvals::pending_for_topic(&state.pool, id) .await .ok() .flatten(); if let Some(a) = pending { stages.push(PipelineStage { key: "approval".into(), label: format!( "Approval pending (requested {})", a.created_at .format(&time::format_description::well_known::Rfc3339) .unwrap_or_default() ), status: "warn", detail: None, }); } Ok(Json(PipelineState { topic_id: id, status: topic.status, stages, })) }