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:
@@ -529,6 +529,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/topology-runs/{id}/cancel",
|
"/api/topology-runs/{id}/cancel",
|
||||||
post(routes::topology::cancel_run),
|
post(routes::topology::cancel_run),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/topology-runs/{id}/output",
|
||||||
|
get(routes::topology::get_run_output),
|
||||||
|
)
|
||||||
// Repos tier — provider connections + cached repo list.
|
// Repos tier — provider connections + cached repo list.
|
||||||
.route(
|
.route(
|
||||||
"/api/repos/connections",
|
"/api/repos/connections",
|
||||||
|
|||||||
@@ -339,6 +339,91 @@ pub async fn run_events_sse(
|
|||||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
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
|
/// `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
|
/// running job; the worker stops at its next step boundary. 409 if the run is
|
||||||
/// already terminal or unknown.
|
/// already terminal or unknown.
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// budget. Owns the "show/hide activity" toggle for running runs and
|
// budget. Owns the "show/hide activity" toggle for running runs and
|
||||||
// mounts PhaseRunStream on demand.
|
// mounts PhaseRunStream on demand.
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { MissionRunSummary } from "@/lib/api/missions";
|
import { getRunOutput, type MissionRunSummary, type RunOutput } from "@/lib/api/missions";
|
||||||
import { PhaseRunStream } from "./PhaseRunStream";
|
import { PhaseRunStream } from "./PhaseRunStream";
|
||||||
|
|
||||||
const mono =
|
const mono =
|
||||||
@@ -70,7 +70,7 @@ export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
|||||||
{new Date(r.finished_at).toLocaleTimeString()}
|
{new Date(r.finished_at).toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{r.status === "running" && (
|
{(r.status === "running" || isTerminal) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -84,8 +84,8 @@ export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
|||||||
style={{
|
style={{
|
||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
border: "1px solid rgba(94,200,216,.35)",
|
border: `1px solid ${r.status === "running" ? "rgba(94,200,216,.35)" : "rgba(255,255,255,.18)"}`,
|
||||||
color: "#5ec8d8",
|
color: r.status === "running" ? "#5ec8d8" : "#c9c9d0",
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
padding: "2px 8px",
|
padding: "2px 8px",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
@@ -93,13 +93,18 @@ export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
|||||||
fontFamily: mono,
|
fontFamily: mono,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{expanded.has(r.id) ? "hide activity" : "show activity"}
|
{expanded.has(r.id)
|
||||||
|
? "hide output"
|
||||||
|
: r.status === "running"
|
||||||
|
? "show activity"
|
||||||
|
: "show output"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{r.status === "running" && expanded.has(r.id) && (
|
{r.status === "running" && expanded.has(r.id) && (
|
||||||
<PhaseRunStream runId={r.id} />
|
<PhaseRunStream runId={r.id} />
|
||||||
)}
|
)}
|
||||||
|
{isTerminal && expanded.has(r.id) && <RunOutputPanel runId={r.id} />}
|
||||||
{isFailed && r.error && (
|
{isFailed && r.error && (
|
||||||
<details style={{ marginTop: 4 }}>
|
<details style={{ marginTop: 4 }}>
|
||||||
<summary
|
<summary
|
||||||
@@ -135,3 +140,97 @@ export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RunOutputPanel({ runId }: { runId: string }) {
|
||||||
|
const [data, setData] = useState<RunOutput | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getRunOutput(runId)
|
||||||
|
.then((d) => {
|
||||||
|
if (alive) setData(d);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setErr(String(e));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [runId]);
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 11, color: "#ff8a7a" }}>{err}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 6, fontSize: 11, color: "#6a6a72" }}>
|
||||||
|
loading output…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 5,
|
||||||
|
background: "rgba(0,0,0,.35)",
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#cfcfd5",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", gap: 12, color: "#8a8a92", fontSize: 10 }}>
|
||||||
|
<span>turns: {data.turns}</span>
|
||||||
|
<span>tokens: {data.tokens.toLocaleString()}</span>
|
||||||
|
<span>records: {data.records_count}</span>
|
||||||
|
<span>outputs: {data.outputs.length}</span>
|
||||||
|
</div>
|
||||||
|
{data.outputs.length === 0 ? (
|
||||||
|
<span style={{ color: "#6a6a72" }}>
|
||||||
|
Run completed but produced no output. The agents may have been unable
|
||||||
|
to reach their working directory or found nothing to act on.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
data.outputs.map((o, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#5ec8d8",
|
||||||
|
fontSize: 9,
|
||||||
|
letterSpacing: ".08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginBottom: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
turn {i + 1}
|
||||||
|
{o.truncated
|
||||||
|
? ` · showing first ${o.preview.length.toLocaleString()} of ${o.full_len.toLocaleString()} chars`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: 6,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "rgba(0,0,0,.5)",
|
||||||
|
color: "#e0e0e5",
|
||||||
|
fontSize: 10.5,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
maxHeight: 300,
|
||||||
|
overflow: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{o.preview}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,6 +225,24 @@ export interface MissionRunSummary {
|
|||||||
export const listMissionRuns = (id: string) =>
|
export const listMissionRuns = (id: string) =>
|
||||||
api<{ runs: MissionRunSummary[] }>(`/api/missions/${id}/runs`);
|
api<{ runs: MissionRunSummary[] }>(`/api/missions/${id}/runs`);
|
||||||
|
|
||||||
|
export interface RunOutputSlice {
|
||||||
|
preview: string;
|
||||||
|
truncated: boolean;
|
||||||
|
full_len: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunOutput {
|
||||||
|
status: string;
|
||||||
|
turns: number;
|
||||||
|
tokens: number;
|
||||||
|
records_count: number;
|
||||||
|
outputs: RunOutputSlice[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRunOutput = (runId: string) =>
|
||||||
|
api<RunOutput>(`/api/topology-runs/${runId}/output`);
|
||||||
|
|
||||||
export const retryMissionPhase = (id: string, phaseId: string) =>
|
export const retryMissionPhase = (id: string, phaseId: string) =>
|
||||||
api<{ reset: boolean }>(
|
api<{ reset: boolean }>(
|
||||||
`/api/missions/${id}/phases/${phaseId}/retry`,
|
`/api/missions/${id}/phases/${phaseId}/retry`,
|
||||||
|
|||||||
Reference in New Issue
Block a user