missions: surface run output on terminal phase runs
Adds GET /api/topology-runs/{id}/output — trimmed view of the
runs checkpoint (totals + per-turn output previews, capped at
12 turns × 6kB each). The full checkpoint blob can be hundreds
of KB so it was never viable to send through mission polling.
Phase card run rows now expose a "show output" toggle for any
terminal run (completed/failed/cancelled), rendering turns,
tokens, records count, and per-turn agent text. Running rows
still get the live activity stream from the prior slice.
Diagnostic value: on a mission that "completed" without visible
work, this immediately shows whether the agents produced real
output (workspace missing / instructions vague / etc.) or
whether nothing ran at all.
This commit is contained in:
@@ -339,6 +339,91 @@ pub async fn run_events_sse(
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// A small, JSON-safe view of what a run actually produced. The full
|
||||
/// `checkpoint` blob can be hundreds of KB per run; this endpoint
|
||||
/// returns just the counters + trimmed output previews so mission
|
||||
/// phase cards can render "what did this run do" without dragging the
|
||||
/// whole checkpoint through the wire on every 3-second poll.
|
||||
#[derive(Serialize)]
|
||||
pub struct RunOutput {
|
||||
pub status: String,
|
||||
pub turns: u64,
|
||||
pub tokens: u64,
|
||||
pub records_count: usize,
|
||||
/// Each entry is a truncated slice of `checkpoint.outputs[i]`
|
||||
/// (typically the concatenated agent text output for one turn).
|
||||
pub outputs: Vec<RunOutputSlice>,
|
||||
/// Error text if the run failed; empty otherwise.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RunOutputSlice {
|
||||
pub preview: String,
|
||||
pub truncated: bool,
|
||||
pub full_len: usize,
|
||||
}
|
||||
|
||||
const OUTPUT_PREVIEW_MAX: usize = 6_000;
|
||||
const OUTPUT_LIST_MAX: usize = 12;
|
||||
|
||||
/// `GET /api/topology-runs/{id}/output` — trimmed summary of what the
|
||||
/// run produced (per-turn output previews + totals). Cheap enough for
|
||||
/// the mission page to fetch inline on-demand for any completed run.
|
||||
pub async fn get_run_output(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<RunOutput>, ApiError> {
|
||||
let run = cm_db::repo::topology_runs::status(&state.pool, id, user.workspace_id).await?;
|
||||
let cp = run.checkpoint.unwrap_or(serde_json::Value::Null);
|
||||
let totals = cp.get("totals").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let turns = totals
|
||||
.get("turns")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let tokens = totals
|
||||
.get("tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let records_count = cp
|
||||
.get("records")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.len())
|
||||
.unwrap_or(0);
|
||||
let outputs_raw = cp.get("outputs").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||
let outputs = outputs_raw
|
||||
.into_iter()
|
||||
.take(OUTPUT_LIST_MAX)
|
||||
.map(|v| {
|
||||
let s = match v {
|
||||
serde_json::Value::String(s) => s,
|
||||
other => other.to_string(),
|
||||
};
|
||||
let full_len = s.chars().count();
|
||||
let truncated = full_len > OUTPUT_PREVIEW_MAX;
|
||||
let preview = if truncated {
|
||||
s.chars().take(OUTPUT_PREVIEW_MAX).collect()
|
||||
} else {
|
||||
s
|
||||
};
|
||||
RunOutputSlice {
|
||||
preview,
|
||||
truncated,
|
||||
full_len,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(RunOutput {
|
||||
status: run.status,
|
||||
turns,
|
||||
tokens,
|
||||
records_count,
|
||||
outputs,
|
||||
error: run.error,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `POST /api/topology-runs/{id}/cancel` — request cancellation of a queued or
|
||||
/// running job; the worker stops at its next step boundary. 409 if the run is
|
||||
/// already terminal or unknown.
|
||||
|
||||
Reference in New Issue
Block a user