Compare commits
2
Commits
d676a9e089
...
a78f308eea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a78f308eea | ||
|
|
0785ac9c79 |
@@ -463,6 +463,14 @@ pub fn router(state: AppState) -> Router {
|
||||
patch(routes::missions::set_description),
|
||||
)
|
||||
.route("/api/missions/{id}/runs", get(routes::missions::list_runs))
|
||||
.route(
|
||||
"/api/missions/{id}/documents",
|
||||
get(routes::missions::list_documents),
|
||||
)
|
||||
.route(
|
||||
"/api/missions/{id}/documents/{run_id}/{index}",
|
||||
get(routes::missions::get_document),
|
||||
)
|
||||
.route(
|
||||
"/api/missions/{id}/phases/{phase_id}/retry",
|
||||
post(routes::missions::retry_phase),
|
||||
|
||||
@@ -101,18 +101,51 @@ pub struct SecurityScanResponse {
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────
|
||||
|
||||
/// A mission plus the phase progress the list card needs. `mission` is
|
||||
/// flattened, so the JSON is a strict SUPERSET of `Mission` — existing
|
||||
/// consumers keep working and simply gain fields.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MissionListItem {
|
||||
#[serde(flatten)]
|
||||
pub mission: Mission,
|
||||
pub phases_total: i64,
|
||||
pub phases_done: i64,
|
||||
/// Kind of the phase currently running, if any.
|
||||
pub current_phase: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<Vec<Mission>>, ApiError> {
|
||||
) -> Result<Json<Vec<MissionListItem>>, ApiError> {
|
||||
let rows = cm_db::repo::missions::list_by_workspace(
|
||||
&state.pool,
|
||||
user.workspace_id.as_uuid(),
|
||||
q.limit.clamp(1, 500),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(rows))
|
||||
// One extra grouped query for the whole page, not one per mission.
|
||||
let ids: Vec<Uuid> = rows.iter().map(|m| m.id).collect();
|
||||
let progress = cm_db::repo::missions::phase_progress(&state.pool, &ids).await?;
|
||||
let by_id: std::collections::HashMap<Uuid, (i64, i64, Option<String>)> = progress
|
||||
.into_iter()
|
||||
.map(|(id, total, done, running)| (id, (total, done, running)))
|
||||
.collect();
|
||||
Ok(Json(
|
||||
rows.into_iter()
|
||||
.map(|m| {
|
||||
let (phases_total, phases_done, current_phase) =
|
||||
by_id.get(&m.id).cloned().unwrap_or((0, 0, None));
|
||||
MissionListItem {
|
||||
mission: m,
|
||||
phases_total,
|
||||
phases_done,
|
||||
current_phase,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
@@ -697,3 +730,240 @@ pub async fn set_status(
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
Ok(Json(mission))
|
||||
}
|
||||
|
||||
// ── Output reader ────────────────────────────────────────────────
|
||||
//
|
||||
// The mission Output tab is a document reader, not a log tail. The
|
||||
// phase-card preview endpoint (`routes::topology::get_run_output`) caps
|
||||
// every turn at 6,000 chars, which shows only ~11% of a typical research
|
||||
// brief (they run 40–55kB) with no way to read the rest. These two routes
|
||||
// are the reader's data source: one lists every document in the mission
|
||||
// for the outline rail, the other returns one document in full.
|
||||
|
||||
/// One agent turn's output, as a readable document.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MissionDocument {
|
||||
pub run_id: Uuid,
|
||||
pub phase_id: Option<Uuid>,
|
||||
/// Index into the run's `checkpoint.outputs` array.
|
||||
pub index: usize,
|
||||
/// Topology node id (`n0`) — stable within the run's graph.
|
||||
pub node_id: String,
|
||||
/// The node's role (`code_archeologist`), i.e. what this agent was.
|
||||
pub role: String,
|
||||
/// Human title: the document's first markdown heading when it has
|
||||
/// one, else its first non-empty line.
|
||||
pub title: String,
|
||||
pub chars: usize,
|
||||
pub run_status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MissionDocumentsResponse {
|
||||
pub documents: Vec<MissionDocument>,
|
||||
}
|
||||
|
||||
/// Derive a display title from a document's own text: prefer the first
|
||||
/// markdown ATX heading, else the first non-empty line. Both are trimmed
|
||||
/// to keep the rail readable.
|
||||
fn document_title(body: &str, fallback: &str) -> String {
|
||||
const MAX: usize = 90;
|
||||
let heading = body
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| l.starts_with('#'))
|
||||
.map(|l| l.trim_start_matches('#').trim());
|
||||
let line = heading.or_else(|| body.lines().map(str::trim).find(|l| !l.is_empty()));
|
||||
match line {
|
||||
Some(l) if !l.is_empty() => {
|
||||
if l.chars().count() > MAX {
|
||||
format!("{}…", l.chars().take(MAX).collect::<String>())
|
||||
} else {
|
||||
l.to_string()
|
||||
}
|
||||
}
|
||||
_ => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a run's graph node index → (node_id, role). The reader labels each
|
||||
/// document by the agent that produced it; `checkpoint.outputs[i]`
|
||||
/// corresponds to `graph.nodes[i]` (the worker appends one output per
|
||||
/// step, in node order).
|
||||
fn nodes_of(graph: Option<&Value>) -> Vec<(String, String)> {
|
||||
graph
|
||||
.and_then(|g| g.get("nodes"))
|
||||
.and_then(|n| n.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|n| {
|
||||
(
|
||||
n.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
n.get("role")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("agent")
|
||||
.to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn outputs_of(checkpoint: Option<&Value>) -> Vec<String> {
|
||||
checkpoint
|
||||
.and_then(|c| c.get("outputs"))
|
||||
.and_then(|o| o.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.map(|v| match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `GET /api/missions/{id}/documents` — every agent output in the mission,
|
||||
/// oldest run first, as a flat list the reader groups by phase. Bodies are
|
||||
/// NOT included; the rail only needs titles and sizes.
|
||||
pub async fn list_documents(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<MissionDocumentsResponse>, ApiError> {
|
||||
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let source =
|
||||
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
|
||||
|
||||
let mut documents = Vec::new();
|
||||
for (run_id, phase_id, run_status, graph, checkpoint) in source {
|
||||
let nodes = nodes_of(graph.as_ref());
|
||||
for (index, body) in outputs_of(checkpoint.as_ref()).into_iter().enumerate() {
|
||||
let (node_id, role) = nodes
|
||||
.get(index)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (format!("n{index}"), "agent".to_string()));
|
||||
let fallback = format!("Turn {}", index + 1);
|
||||
documents.push(MissionDocument {
|
||||
run_id,
|
||||
phase_id,
|
||||
index,
|
||||
node_id,
|
||||
title: document_title(&body, &fallback),
|
||||
role,
|
||||
chars: body.chars().count(),
|
||||
run_status: run_status.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Json(MissionDocumentsResponse { documents }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MissionDocumentBody {
|
||||
pub run_id: Uuid,
|
||||
pub index: usize,
|
||||
pub role: String,
|
||||
pub title: String,
|
||||
/// The complete output text — untruncated, which is the whole point.
|
||||
pub body: String,
|
||||
pub chars: usize,
|
||||
}
|
||||
|
||||
/// `GET /api/missions/{id}/documents/{run_id}/{index}` — one document in
|
||||
/// full. Separate from the list so opening the Output tab doesn't pull
|
||||
/// every brief in the mission over the wire at once.
|
||||
pub async fn get_document(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
Path((id, run_id, index)): Path<(Uuid, Uuid, usize)>,
|
||||
) -> Result<Json<MissionDocumentBody>, ApiError> {
|
||||
let _ = cm_db::repo::missions::get(&state.pool, id, user.workspace_id.as_uuid())
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
// Scope the run to the mission as well, so a valid run id from another
|
||||
// mission (or workspace) can't be read through this path.
|
||||
let source =
|
||||
cm_db::repo::topology_runs::documents_source_for_mission(&state.pool, id).await?;
|
||||
let (_, _, _, graph, checkpoint) = source
|
||||
.into_iter()
|
||||
.find(|(rid, _, _, _, _)| *rid == run_id)
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
|
||||
let body = outputs_of(checkpoint.as_ref())
|
||||
.into_iter()
|
||||
.nth(index)
|
||||
.ok_or(ApiError::NotFound)?;
|
||||
let role = nodes_of(graph.as_ref())
|
||||
.get(index)
|
||||
.map(|(_, r)| r.clone())
|
||||
.unwrap_or_else(|| "agent".to_string());
|
||||
let fallback = format!("Turn {}", index + 1);
|
||||
Ok(Json(MissionDocumentBody {
|
||||
run_id,
|
||||
index,
|
||||
title: document_title(&body, &fallback),
|
||||
role,
|
||||
chars: body.chars().count(),
|
||||
body,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn title_prefers_first_markdown_heading() {
|
||||
let body = "I'll start by exploring.\n\n# ClawHDF5 Research Report\n\ntext";
|
||||
assert_eq!(document_title(body, "Turn 1"), "ClawHDF5 Research Report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_falls_back_to_first_nonempty_line() {
|
||||
let body = "\n\n Architecture notes for the io crate\nmore\n";
|
||||
assert_eq!(
|
||||
document_title(body, "Turn 1"),
|
||||
"Architecture notes for the io crate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_falls_back_to_label_when_empty() {
|
||||
assert_eq!(document_title(" \n\n", "Turn 3"), "Turn 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_is_truncated() {
|
||||
let body = format!("# {}", "x".repeat(200));
|
||||
let t = document_title(&body, "Turn 1");
|
||||
assert!(t.ends_with('…'));
|
||||
assert_eq!(t.chars().count(), 91);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_and_outputs_are_positionally_aligned() {
|
||||
let graph = serde_json::json!({
|
||||
"nodes": [
|
||||
{"id": "n0", "role": "code_archeologist"},
|
||||
{"id": "n1", "role": "architecture_mapper"}
|
||||
]
|
||||
});
|
||||
let cp = serde_json::json!({ "outputs": ["first brief", "second brief"] });
|
||||
let nodes = nodes_of(Some(&graph));
|
||||
let outs = outputs_of(Some(&cp));
|
||||
assert_eq!(nodes[1], ("n1".into(), "architecture_mapper".into()));
|
||||
assert_eq!(outs[1], "second brief");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_graph_or_checkpoint_yields_no_documents() {
|
||||
assert!(nodes_of(None).is_empty());
|
||||
assert!(outputs_of(None).is_empty());
|
||||
assert!(outputs_of(Some(&serde_json::json!({}))).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -743,3 +743,42 @@ pub async fn benchmark_snapshots_for(
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Phase progress for a set of missions, for the missions list cards.
|
||||
/// Returns `(mission_id, total, done, running_phase_kind)`.
|
||||
///
|
||||
/// A status dot alone doesn't tell you where a mission actually is; this
|
||||
/// is what lets a card say "Coding · 1/2" instead of just "running".
|
||||
pub async fn phase_progress(
|
||||
pool: &PgPool,
|
||||
mission_ids: &[Uuid],
|
||||
) -> Result<Vec<(Uuid, i64, i64, Option<String>)>, DbError> {
|
||||
use sqlx::Row;
|
||||
if mission_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let rows = sqlx::query(
|
||||
"SELECT mission_id,
|
||||
count(*) AS total,
|
||||
count(*) FILTER (WHERE status IN ('completed','skipped')) AS done,
|
||||
(array_agg(kind ORDER BY order_idx)
|
||||
FILTER (WHERE status = 'running'))[1] AS running_kind
|
||||
FROM mission_phases
|
||||
WHERE mission_id = ANY($1)
|
||||
GROUP BY mission_id",
|
||||
)
|
||||
.bind(mission_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.get("mission_id"),
|
||||
r.get("total"),
|
||||
r.get("done"),
|
||||
r.get("running_kind"),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -476,3 +476,38 @@ pub async fn status(
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Graph + checkpoint for every run of a mission, oldest first — the raw
|
||||
/// material the mission Output reader turns into a document list.
|
||||
///
|
||||
/// Deliberately returns the FULL checkpoint: the reader exists precisely
|
||||
/// because the 6kB preview in `routes::topology::get_run_output` throws
|
||||
/// away ~90% of a research brief. This is fetched on demand when the
|
||||
/// operator opens the Output tab, never on a poll loop.
|
||||
pub async fn documents_source_for_mission(
|
||||
pool: &PgPool,
|
||||
mission_id: Uuid,
|
||||
) -> Result<Vec<(Uuid, Option<Uuid>, String, Option<Value>, Option<Value>)>, DbError> {
|
||||
use sqlx::Row;
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, mission_phase_id, status, graph, checkpoint
|
||||
FROM topology_runs
|
||||
WHERE mission_id = $1
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(mission_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.get("id"),
|
||||
r.get("mission_phase_id"),
|
||||
r.get("status"),
|
||||
r.get("graph"),
|
||||
r.get("checkpoint"),
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -12,10 +12,38 @@ const mono =
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||
|
||||
type Block =
|
||||
| { kind: "h1" | "h2" | "h3"; text: string }
|
||||
| { kind: "h1" | "h2" | "h3"; text: string; id?: string }
|
||||
| { kind: "p"; text: string }
|
||||
| { kind: "ul"; items: string[] }
|
||||
| { kind: "ol"; items: string[] };
|
||||
| { kind: "ol"; items: string[] }
|
||||
| { kind: "code"; lang: string; text: string };
|
||||
|
||||
/** Stable slug for a heading, so the reader's outline can scroll to it. */
|
||||
export function headingId(text: string, ordinal: number): string {
|
||||
const slug = text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
return `h-${ordinal}-${slug || "section"}`;
|
||||
}
|
||||
|
||||
/** The headings of a document, for an outline rail. */
|
||||
export function outlineOf(
|
||||
md: string,
|
||||
): Array<{ id: string; text: string; level: 1 | 2 | 3 }> {
|
||||
return parse(md).flatMap((b, idx) =>
|
||||
b.kind === "h1" || b.kind === "h2" || b.kind === "h3"
|
||||
? [
|
||||
{
|
||||
id: b.id ?? headingId(b.text, idx),
|
||||
text: b.text,
|
||||
level: Number(b.kind.slice(1)) as 1 | 2 | 3,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function parse(md: string): Block[] {
|
||||
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
||||
@@ -28,13 +56,32 @@ function parse(md: string): Block[] {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Fenced code block. Agent output is full of ```rust / ```toml
|
||||
// blocks; without this they render as mangled paragraphs.
|
||||
const fence = /^```([A-Za-z0-9_+-]*)\s*$/.exec(trimmed);
|
||||
if (fence) {
|
||||
const lang = fence[1] ?? "";
|
||||
const body: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
|
||||
body.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
i++; // consume the closing fence (or run off the end on an unclosed block)
|
||||
blocks.push({ kind: "code", lang, text: body.join("\n") });
|
||||
continue;
|
||||
}
|
||||
// Headings
|
||||
const h = /^(#{1,3})\s+(.*)$/.exec(trimmed);
|
||||
const h = /^(#{1,6})\s+(.*)$/.exec(trimmed);
|
||||
if (h) {
|
||||
const level = h[1].length as 1 | 2 | 3;
|
||||
// h4-h6 are rare in agent output; render them as h3 rather than
|
||||
// dropping the text into a paragraph.
|
||||
const level = Math.min(h[1].length, 3) as 1 | 2 | 3;
|
||||
const text = h[2];
|
||||
blocks.push({
|
||||
kind: (`h${level}` as "h1" | "h2" | "h3"),
|
||||
text: h[2],
|
||||
text,
|
||||
id: headingId(text, blocks.length),
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
@@ -64,7 +111,8 @@ function parse(md: string): Block[] {
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() &&
|
||||
!/^(#{1,3})\s+/.test(lines[i].trim()) &&
|
||||
!/^(#{1,6})\s+/.test(lines[i].trim()) &&
|
||||
!/^```/.test(lines[i].trim()) &&
|
||||
!/^[-*]\s+/.test(lines[i].trim()) &&
|
||||
!/^\d+\.\s+/.test(lines[i].trim())
|
||||
) {
|
||||
@@ -133,6 +181,7 @@ export function MarkdownBlock({ source }: { source: string }) {
|
||||
return (
|
||||
<h1
|
||||
key={idx}
|
||||
id={b.id}
|
||||
style={{
|
||||
margin: "8px 0 2px",
|
||||
fontSize: 17,
|
||||
@@ -148,6 +197,7 @@ export function MarkdownBlock({ source }: { source: string }) {
|
||||
return (
|
||||
<h2
|
||||
key={idx}
|
||||
id={b.id}
|
||||
style={{
|
||||
margin: "10px 0 -2px",
|
||||
fontSize: 12,
|
||||
@@ -165,6 +215,7 @@ export function MarkdownBlock({ source }: { source: string }) {
|
||||
return (
|
||||
<h3
|
||||
key={idx}
|
||||
id={b.id}
|
||||
style={{
|
||||
margin: "6px 0 -4px",
|
||||
fontSize: 11.5,
|
||||
@@ -178,6 +229,43 @@ export function MarkdownBlock({ source }: { source: string }) {
|
||||
{renderInline(b.text)}
|
||||
</h3>
|
||||
);
|
||||
if (b.kind === "code")
|
||||
return (
|
||||
<pre
|
||||
key={idx}
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid rgba(255,255,255,.07)",
|
||||
background: "rgba(0,0,0,.45)",
|
||||
color: "#e0e0e5",
|
||||
fontFamily: mono,
|
||||
fontSize: 11.5,
|
||||
lineHeight: 1.5,
|
||||
// Code is the one thing that may scroll sideways; the
|
||||
// page itself must never scroll horizontally.
|
||||
overflowX: "auto",
|
||||
whiteSpace: "pre",
|
||||
}}
|
||||
>
|
||||
{b.lang && (
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: 9.5,
|
||||
letterSpacing: ".12em",
|
||||
textTransform: "uppercase",
|
||||
color: "#6a6a72",
|
||||
}}
|
||||
>
|
||||
{b.lang}
|
||||
</span>
|
||||
)}
|
||||
<code>{b.text}</code>
|
||||
</pre>
|
||||
);
|
||||
if (b.kind === "p")
|
||||
return (
|
||||
<p key={idx} style={{ margin: 0 }}>
|
||||
|
||||
@@ -44,6 +44,7 @@ import { EditMissionModal } from "./EditMissionModal";
|
||||
import { MarkdownBlock } from "./MarkdownBlock";
|
||||
import { MissionLiveEvents } from "./MissionLiveEvents";
|
||||
import { MissionLivePane } from "./MissionLivePane";
|
||||
import { MissionOutputReader } from "./MissionOutputReader";
|
||||
import { MissionTeamTab } from "./MissionTeamTab";
|
||||
import { MissionWizard } from "./MissionWizard";
|
||||
import { PhaseRunsList } from "./PhaseRunsList";
|
||||
@@ -89,15 +90,19 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
|
||||
custom: "Custom",
|
||||
};
|
||||
|
||||
type Tab =
|
||||
| "overview"
|
||||
| "phases"
|
||||
| "tasks"
|
||||
| "team"
|
||||
| "live"
|
||||
| "artifacts"
|
||||
| "benchmarks"
|
||||
| "pane";
|
||||
// Three primary tabs, each with a shallow segmented sub-view. The old
|
||||
// shape was eight flat tabs (overview/phases/tasks/team/live/artifacts/
|
||||
// benchmarks/pane) that mixed lifecycle, work items, people, telemetry,
|
||||
// outputs and infra at one level — so nothing told you where the actual
|
||||
// deliverable lived (it was buried under phases → run → turn).
|
||||
//
|
||||
// RUN what is happening · phases · tasks · live
|
||||
// OUTPUT what came out of it · documents · artifacts · benchmarks
|
||||
// SETUP how it is configured · overview · team · pane
|
||||
type Tab = "run" | "output" | "setup";
|
||||
type RunSub = "phases" | "tasks" | "live";
|
||||
type OutputSub = "documents" | "artifacts" | "benchmarks";
|
||||
type SetupSub = "overview" | "team" | "pane";
|
||||
|
||||
export function MissionCanvas({
|
||||
selectedId,
|
||||
@@ -118,7 +123,10 @@ export function MissionCanvas({
|
||||
const [mission, setMission] = useState<MissionDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<Tab>("overview");
|
||||
const [tab, setTab] = useState<Tab>("run");
|
||||
const [runSub, setRunSub] = useState<RunSub>("phases");
|
||||
const [outputSub, setOutputSub] = useState<OutputSub>("documents");
|
||||
const [setupSub, setSetupSub] = useState<SetupSub>("overview");
|
||||
const [launching, setLaunching] = useState(false);
|
||||
const [refining, setRefining] = useState(false);
|
||||
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
|
||||
@@ -540,12 +548,19 @@ export function MissionCanvas({
|
||||
)}
|
||||
</div>
|
||||
{mission.description && !headerCollapsed && (
|
||||
// Clipped, NOT scrollable — a scroll container here was a fourth
|
||||
// nested scrollbar above the content area. The full text lives in
|
||||
// Setup → Overview.
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
maxHeight: "38vh",
|
||||
overflowY: "auto",
|
||||
maxHeight: 92,
|
||||
overflow: "hidden",
|
||||
paddingRight: 8,
|
||||
maskImage:
|
||||
"linear-gradient(to bottom, #000 60%, transparent 100%)",
|
||||
WebkitMaskImage:
|
||||
"linear-gradient(to bottom, #000 60%, transparent 100%)",
|
||||
}}
|
||||
>
|
||||
<MarkdownBlock source={mission.description} />
|
||||
@@ -582,52 +597,97 @@ export function MissionCanvas({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Primary tabs — three, not eight. */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
marginTop: 4,
|
||||
marginTop: 6,
|
||||
overflowX: "auto",
|
||||
paddingBottom: 2,
|
||||
scrollbarWidth: "thin",
|
||||
}}
|
||||
>
|
||||
{(
|
||||
[
|
||||
"overview",
|
||||
"phases",
|
||||
"tasks",
|
||||
"team",
|
||||
"live",
|
||||
"artifacts",
|
||||
"benchmarks",
|
||||
...(mission.runtime_kind === "local_herdr" ? (["pane"] as const) : []),
|
||||
] as Tab[]
|
||||
).map((t) => {
|
||||
{(["run", "output", "setup"] as Tab[]).map((t) => {
|
||||
const active = tab === t;
|
||||
const badge =
|
||||
t === "tasks"
|
||||
? mission.tasks.length
|
||||
: t === "artifacts"
|
||||
? mission.artifacts.length
|
||||
: t === "phases"
|
||||
? mission.phases.length
|
||||
: t === "benchmarks"
|
||||
? mission.benchmarks.length
|
||||
: null;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
padding: "5px 12px",
|
||||
padding: "6px 16px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${active ? "rgba(255,138,122,.5)" : "rgba(255,255,255,.08)"}`,
|
||||
background: active ? "rgba(255,138,122,.08)" : "transparent",
|
||||
color: active ? "#ff8a7a" : "#a0a0a8",
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
letterSpacing: ".10em",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: active ? 600 : 400,
|
||||
cursor: "pointer",
|
||||
flex: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sub-view for the active tab. */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 3,
|
||||
marginTop: 6,
|
||||
overflowX: "auto",
|
||||
paddingBottom: 2,
|
||||
scrollbarWidth: "thin",
|
||||
}}
|
||||
>
|
||||
{(tab === "run"
|
||||
? ([
|
||||
["phases", mission.phases.length],
|
||||
["tasks", mission.tasks.length],
|
||||
["live", null],
|
||||
] as Array<[string, number | null]>)
|
||||
: tab === "output"
|
||||
? ([
|
||||
["documents", null],
|
||||
["artifacts", mission.artifacts.length],
|
||||
["benchmarks", mission.benchmarks.length],
|
||||
] as Array<[string, number | null]>)
|
||||
: ([
|
||||
["overview", null],
|
||||
["team", null],
|
||||
...(mission.runtime_kind === "local_herdr"
|
||||
? ([["pane", null]] as Array<[string, number | null]>)
|
||||
: []),
|
||||
] as Array<[string, number | null]>)
|
||||
).map(([sub, badge]) => {
|
||||
const current =
|
||||
tab === "run" ? runSub : tab === "output" ? outputSub : setupSub;
|
||||
const active = current === sub;
|
||||
return (
|
||||
<button
|
||||
key={sub}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (tab === "run") setRunSub(sub as RunSub);
|
||||
else if (tab === "output") setOutputSub(sub as OutputSub);
|
||||
else setSetupSub(sub as SetupSub);
|
||||
}}
|
||||
style={{
|
||||
padding: "3px 10px",
|
||||
borderRadius: 999,
|
||||
border: "1px solid transparent",
|
||||
background: active ? "rgba(255,255,255,.07)" : "transparent",
|
||||
color: active ? "#e0e0e5" : "#8a8a92",
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
letterSpacing: ".08em",
|
||||
textTransform: "uppercase",
|
||||
cursor: "pointer",
|
||||
@@ -638,16 +698,9 @@ export function MissionCanvas({
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
{sub}
|
||||
{badge !== null && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: active ? "#ff8a7a" : "#8a8a92",
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
<span style={{ fontSize: 9.5, color: "#6a6a72" }}>{badge}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
@@ -655,9 +708,44 @@ export function MissionCanvas({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The documents reader manages its own columns + scrolling, so it
|
||||
renders full-bleed. Everything else lives in one padded scroller
|
||||
— never a scroll container inside a scroll container. */}
|
||||
{tab === "output" && outputSub === "documents" ? (
|
||||
<MissionOutputReader
|
||||
missionId={mission.id}
|
||||
phases={mission.phases}
|
||||
visible
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
|
||||
{tab === "overview" && (
|
||||
{tab === "setup" && setupSub === "overview" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{mission.description && (
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(255,255,255,.07)",
|
||||
background: "#101014",
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 9.5,
|
||||
letterSpacing: ".14em",
|
||||
textTransform: "uppercase",
|
||||
color: "#6a6a72",
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Brief
|
||||
</div>
|
||||
<MarkdownBlock source={mission.description} />
|
||||
</div>
|
||||
)}
|
||||
<FieldRow k="Template" v={TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind} />
|
||||
<FieldRow k="Status" v={mission.status} />
|
||||
<FieldRow k="Team" v={mission.team_id ?? "(auto-provision on launch)"} />
|
||||
@@ -679,7 +767,7 @@ export function MissionCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "phases" && (
|
||||
{tab === "run" && runSub === "phases" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{orderedPhases.length === 0 ? (
|
||||
<Empty label="no phases" />
|
||||
@@ -841,7 +929,7 @@ export function MissionCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "tasks" && (
|
||||
{tab === "run" && runSub === "tasks" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{mission.tasks.length === 0 ? (
|
||||
<Empty label="no tasks yet — task-card parser lands in Slice 5" />
|
||||
@@ -910,7 +998,7 @@ export function MissionCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "team" && (
|
||||
{tab === "setup" && setupSub === "team" && (
|
||||
<MissionTeamTab
|
||||
missionId={mission.id}
|
||||
teamId={mission.team_id}
|
||||
@@ -918,11 +1006,11 @@ export function MissionCanvas({
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "live" && (
|
||||
<MissionLiveEvents missionId={mission.id} visible={tab === "live"} />
|
||||
{tab === "run" && runSub === "live" && (
|
||||
<MissionLiveEvents missionId={mission.id} visible={tab === "run" && runSub === "live"} />
|
||||
)}
|
||||
|
||||
{tab === "artifacts" && (
|
||||
{tab === "output" && outputSub === "artifacts" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{mission.artifacts.length === 0 ? (
|
||||
<Empty label="no artifacts yet — phases produce them as they run" />
|
||||
@@ -1018,7 +1106,7 @@ export function MissionCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "benchmarks" && (
|
||||
{tab === "output" && outputSub === "benchmarks" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{mission.benchmarks.length === 0 ? (
|
||||
<Empty label="no benchmark snapshots yet — trigger a baseline via /api/missions/{id}/benchmark or a workflow with benchmark = { mode = "before_after" }" />
|
||||
@@ -1151,13 +1239,14 @@ export function MissionCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "pane" && mission.runtime_kind === "local_herdr" && (
|
||||
{tab === "setup" && setupSub === "pane" && mission.runtime_kind === "local_herdr" && (
|
||||
<MissionLivePane
|
||||
nodeId={mission.target_node_id}
|
||||
visible={tab === "pane"}
|
||||
visible={tab === "setup" && setupSub === "pane"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"use client";
|
||||
|
||||
// MissionOutputReader — the mission's reading surface.
|
||||
//
|
||||
// Agent phases produce 40–55kB markdown briefs per turn. Before this,
|
||||
// the only way to see them was a 300px-tall <pre> nested inside a 260px
|
||||
// run box inside the page scroller, showing the first 6,000 chars with
|
||||
// no way to reach the rest. This replaces that with a document reader:
|
||||
//
|
||||
// left rail every document in the mission, grouped by phase
|
||||
// right pane the selected document IN FULL, rendered as markdown
|
||||
// outline that document's headings, click to jump
|
||||
//
|
||||
// Exactly ONE scroll container per column — no nesting. The rail and the
|
||||
// document scroll independently; the page body never scrolls.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Copy, Download, FileText, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
getMissionDocument,
|
||||
listMissionDocuments,
|
||||
type MissionDocument,
|
||||
type MissionPhase,
|
||||
type PhaseKind,
|
||||
} from "@/lib/api/missions";
|
||||
import { MarkdownBlock, outlineOf } from "./MarkdownBlock";
|
||||
|
||||
const mono =
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||
|
||||
const PHASE_LABEL: Record<PhaseKind, string> = {
|
||||
research: "Research",
|
||||
coding: "Coding",
|
||||
benchmark: "Benchmark",
|
||||
security_scan: "Security scan",
|
||||
};
|
||||
|
||||
/** `code_archeologist` → `Code Archeologist`. */
|
||||
function humanRole(role: string): string {
|
||||
return role
|
||||
.split(/[_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((w) => w[0].toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function sizeLabel(chars: number): string {
|
||||
return chars >= 1000 ? `${Math.round(chars / 1000)}k` : `${chars}`;
|
||||
}
|
||||
|
||||
type DocKey = string;
|
||||
const keyOf = (d: Pick<MissionDocument, "run_id" | "index">): DocKey =>
|
||||
`${d.run_id}:${d.index}`;
|
||||
|
||||
export function MissionOutputReader({
|
||||
missionId,
|
||||
phases,
|
||||
visible,
|
||||
}: {
|
||||
missionId: string;
|
||||
phases: MissionPhase[];
|
||||
visible: boolean;
|
||||
}) {
|
||||
const [docs, setDocs] = useState<MissionDocument[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<DocKey | null>(null);
|
||||
const [body, setBody] = useState<string>("");
|
||||
const [bodyLoading, setBodyLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const docScroll = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await listMissionDocuments(missionId);
|
||||
setDocs(res.documents);
|
||||
// Default to the newest document so the pane is never empty.
|
||||
setSelected((prev) => {
|
||||
if (prev && res.documents.some((d) => keyOf(d) === prev)) return prev;
|
||||
const last = res.documents[res.documents.length - 1];
|
||||
return last ? keyOf(last) : null;
|
||||
});
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "failed to load documents");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [missionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
void loadList();
|
||||
}, [visible, loadList]);
|
||||
|
||||
const selectedDoc = useMemo(
|
||||
() => docs.find((d) => keyOf(d) === selected) ?? null,
|
||||
[docs, selected],
|
||||
);
|
||||
|
||||
// Fetch the selected document's full text.
|
||||
useEffect(() => {
|
||||
if (!visible || !selectedDoc) {
|
||||
setBody("");
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setBodyLoading(true);
|
||||
getMissionDocument(missionId, selectedDoc.run_id, selectedDoc.index)
|
||||
.then((d) => {
|
||||
if (!alive) return;
|
||||
setBody(d.body);
|
||||
setError(null);
|
||||
// A new document starts at the top, not wherever the last one sat.
|
||||
docScroll.current?.scrollTo({ top: 0 });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
setError(e instanceof Error ? e.message : "failed to load document");
|
||||
setBody("");
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setBodyLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [missionId, selectedDoc, visible]);
|
||||
|
||||
const outline = useMemo(() => (body ? outlineOf(body) : []), [body]);
|
||||
|
||||
// Group documents under their phase, in phase order. Documents whose
|
||||
// phase is unknown (ad-hoc runs) collect under a trailing bucket.
|
||||
const groups = useMemo(() => {
|
||||
const byPhase = new Map<string, MissionDocument[]>();
|
||||
for (const d of docs) {
|
||||
const k = d.phase_id ?? "__unphased";
|
||||
const list = byPhase.get(k);
|
||||
if (list) list.push(d);
|
||||
else byPhase.set(k, [d]);
|
||||
}
|
||||
const ordered = [...phases]
|
||||
.sort((a, b) => a.order_idx - b.order_idx)
|
||||
.filter((p) => byPhase.has(p.id))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
label: `${PHASE_LABEL[p.kind] ?? p.kind}`,
|
||||
docs: byPhase.get(p.id) ?? [],
|
||||
}));
|
||||
const loose = byPhase.get("__unphased");
|
||||
if (loose?.length) {
|
||||
ordered.push({
|
||||
id: "__unphased",
|
||||
label: "Other runs",
|
||||
docs: loose,
|
||||
});
|
||||
}
|
||||
return ordered;
|
||||
}, [docs, phases]);
|
||||
|
||||
const jumpTo = useCallback((id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, []);
|
||||
|
||||
const copyBody = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(body);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard can be blocked; the download button is the fallback.
|
||||
}
|
||||
}, [body]);
|
||||
|
||||
const downloadBody = useCallback(() => {
|
||||
if (!selectedDoc) return;
|
||||
const blob = new Blob([body], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${selectedDoc.role}-${selectedDoc.index + 1}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [body, selectedDoc]);
|
||||
|
||||
if (loading && docs.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 22, fontFamily: mono, fontSize: 11, color: "#5ec8d8" }}>
|
||||
Loading documents…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loading && docs.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 22, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: "#cfcfd5" }}>
|
||||
No agent output yet.
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#8a8a92", lineHeight: 1.5 }}>
|
||||
Documents appear here as each phase's agents finish their turns.
|
||||
</span>
|
||||
{error && (
|
||||
<span style={{ fontSize: 12, color: "#ff8a7a" }}>{error}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "grid",
|
||||
// rail · document · outline. The outline collapses away on
|
||||
// narrow viewports so the document keeps its reading width.
|
||||
gridTemplateColumns: "230px minmax(0, 1fr) 200px",
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
{/* ── rail: every document, grouped by phase ── */}
|
||||
<div
|
||||
style={{
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
borderRight: "1px solid rgba(255,255,255,.07)",
|
||||
padding: "12px 8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{groups.map((g) => (
|
||||
<div key={g.id} style={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 9.5,
|
||||
letterSpacing: ".14em",
|
||||
textTransform: "uppercase",
|
||||
color: "#6a6a72",
|
||||
padding: "0 6px 2px",
|
||||
}}
|
||||
>
|
||||
{g.label} · {g.docs.length}
|
||||
</div>
|
||||
{g.docs.map((d) => {
|
||||
const k = keyOf(d);
|
||||
const active = k === selected;
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setSelected(k)}
|
||||
title={d.title}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "6px 8px",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${active ? "rgba(255,138,122,.45)" : "transparent"}`,
|
||||
background: active ? "rgba(255,138,122,.08)" : "transparent",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11.5,
|
||||
color: active ? "#ff8a7a" : "#cfcfd5",
|
||||
fontWeight: active ? 600 : 400,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{humanRole(d.role)}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 9.5,
|
||||
color: "#6a6a72",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{d.node_id} · {sizeLabel(d.chars)} chars
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── document: the only place long-form content is read ── */}
|
||||
<div
|
||||
ref={docScroll}
|
||||
style={{
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "18px 26px 60px",
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div style={{ marginBottom: 12, fontSize: 12, color: "#ff8a7a" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{selectedDoc && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
marginBottom: 14,
|
||||
paddingBottom: 12,
|
||||
borderBottom: "1px solid rgba(255,255,255,.07)",
|
||||
}}
|
||||
>
|
||||
<FileText size={15} style={{ color: "#7cd6e0", flex: "none", marginTop: 3 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, color: "#f3f3f5", fontWeight: 600 }}>
|
||||
{selectedDoc.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
color: "#8a8a92",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{humanRole(selectedDoc.role)} · {selectedDoc.node_id} ·{" "}
|
||||
{selectedDoc.chars.toLocaleString()} chars
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyBody}
|
||||
disabled={!body}
|
||||
title="Copy the full document"
|
||||
style={readerBtn}
|
||||
>
|
||||
<Copy size={12} /> {copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadBody}
|
||||
disabled={!body}
|
||||
title="Download as .md"
|
||||
style={readerBtn}
|
||||
>
|
||||
<Download size={12} /> .md
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{bodyLoading ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
color: "#5ec8d8",
|
||||
}}
|
||||
>
|
||||
<Loader2 size={13} className="animate-spin" /> Loading document…
|
||||
</div>
|
||||
) : body ? (
|
||||
<MarkdownBlock source={body} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── outline: headings of the open document ── */}
|
||||
<div
|
||||
style={{
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
borderLeft: "1px solid rgba(255,255,255,.07)",
|
||||
padding: "16px 10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 9.5,
|
||||
letterSpacing: ".14em",
|
||||
textTransform: "uppercase",
|
||||
color: "#6a6a72",
|
||||
padding: "0 6px 6px",
|
||||
}}
|
||||
>
|
||||
Outline
|
||||
</div>
|
||||
{outline.length === 0 ? (
|
||||
<span style={{ padding: "0 6px", fontSize: 11, color: "#6a6a72" }}>
|
||||
No headings
|
||||
</span>
|
||||
) : (
|
||||
outline.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
onClick={() => jumpTo(h.id)}
|
||||
title={h.text}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "3px 6px",
|
||||
paddingLeft: 6 + (h.level - 1) * 10,
|
||||
borderRadius: 6,
|
||||
border: "1px solid transparent",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
color: h.level === 1 ? "#cfcfd5" : "#8a8a92",
|
||||
fontSize: h.level === 1 ? 11.5 : 11,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{h.text}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const readerBtn: React.CSSProperties = {
|
||||
flex: "none",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
padding: "4px 9px",
|
||||
borderRadius: 7,
|
||||
border: "1px solid rgba(255,255,255,.10)",
|
||||
background: "transparent",
|
||||
color: "#a0a0a8",
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
cursor: "pointer",
|
||||
};
|
||||
@@ -11,8 +11,9 @@ import { Plus, RotateCw, Trash2, Wrench } from "lucide-react";
|
||||
import {
|
||||
deleteMission,
|
||||
listMissions,
|
||||
type Mission,
|
||||
type MissionListItem,
|
||||
type MissionStatus,
|
||||
type PhaseKind,
|
||||
type TemplateKind,
|
||||
} from "@/lib/api/missions";
|
||||
import { MissionWizard } from "./MissionWizard";
|
||||
@@ -52,7 +53,7 @@ export function MissionsList({
|
||||
* currently-open mission was among the deleted rows. */
|
||||
onDeleted?: (deletedIds: string[]) => void;
|
||||
}) {
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
const [missions, setMissions] = useState<MissionListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -351,6 +352,49 @@ export function MissionsList({
|
||||
>
|
||||
{m.title}
|
||||
</div>
|
||||
{/* Where the mission actually IS. A status dot alone
|
||||
doesn't distinguish "just launched" from "nearly done". */}
|
||||
{m.phases_total > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
background: "rgba(255,255,255,.08)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.round((m.phases_done / m.phases_total) * 100)}%`,
|
||||
height: "100%",
|
||||
background: STATUS_COLOR[m.status],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 9,
|
||||
color: "#6a6a72",
|
||||
flex: "none",
|
||||
}}
|
||||
>
|
||||
{m.current_phase
|
||||
? `${PHASE_LABEL[m.current_phase] ?? m.current_phase} · `
|
||||
: ""}
|
||||
{m.phases_done}/{m.phases_total}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
@@ -371,6 +415,13 @@ export function MissionsList({
|
||||
);
|
||||
}
|
||||
|
||||
const PHASE_LABEL: Record<PhaseKind, string> = {
|
||||
research: "Research",
|
||||
coding: "Coding",
|
||||
benchmark: "Benchmark",
|
||||
security_scan: "Security",
|
||||
};
|
||||
|
||||
const iconBtn: React.CSSProperties = {
|
||||
width: 26,
|
||||
height: 26,
|
||||
|
||||
@@ -141,6 +141,14 @@ export function PhaseRunsList({ runs }: { runs: MissionRunSummary[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** First few lines of a turn — enough to recognize it in the timeline,
|
||||
* short enough not to need its own scrollbar. */
|
||||
function excerpt(text: string, lines = 12): string {
|
||||
const parts = text.split("\n");
|
||||
if (parts.length <= lines) return text;
|
||||
return `${parts.slice(0, lines).join("\n")}\n…`;
|
||||
}
|
||||
|
||||
function RunOutputPanel({ runId }: { runId: string }) {
|
||||
const [data, setData] = useState<RunOutput | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
@@ -238,6 +246,10 @@ function RunOutputPanel({ runId }: { runId: string }) {
|
||||
</span>
|
||||
)}
|
||||
</summary>
|
||||
{/* A SHORT excerpt only — no inner scrollbar. This used to be
|
||||
a 300px scroll box nested inside the 260px run box inside
|
||||
the page scroller, which made long output unreadable. The
|
||||
full document lives in the Output tab's reader. */}
|
||||
<pre
|
||||
style={{
|
||||
margin: "4px 0 0",
|
||||
@@ -248,12 +260,24 @@ function RunOutputPanel({ runId }: { runId: string }) {
|
||||
fontSize: 10.5,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
maxHeight: 300,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{o.preview}
|
||||
{excerpt(o.preview)}
|
||||
</pre>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: 4,
|
||||
fontSize: 10,
|
||||
color: "#6a6a72",
|
||||
}}
|
||||
>
|
||||
{o.full_len.toLocaleString()} chars · open the{" "}
|
||||
<strong style={{ color: "#7cd6e0", fontWeight: 600 }}>
|
||||
Output
|
||||
</strong>{" "}
|
||||
tab to read this in full
|
||||
</span>
|
||||
</details>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -196,8 +196,16 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
/** A list row: `Mission` plus phase progress for the card. */
|
||||
export interface MissionListItem extends Mission {
|
||||
phases_total: number;
|
||||
phases_done: number;
|
||||
/** Kind of the phase currently running, if any. */
|
||||
current_phase: PhaseKind | null;
|
||||
}
|
||||
|
||||
export const listMissions = (limit = 50) =>
|
||||
api<Mission[]>(`/api/missions?limit=${limit}`);
|
||||
api<MissionListItem[]>(`/api/missions?limit=${limit}`);
|
||||
|
||||
export const getMission = (id: string) =>
|
||||
api<MissionDetail>(`/api/missions/${id}`);
|
||||
@@ -250,6 +258,46 @@ export interface RunOutput {
|
||||
export const getRunOutput = (runId: string) =>
|
||||
api<RunOutput>(`/api/topology-runs/${runId}/output`);
|
||||
|
||||
// ── Output reader ────────────────────────────────────────────────
|
||||
// `getRunOutput` above caps each turn at 6,000 chars server-side, which
|
||||
// is only ~11% of a typical research brief. These two power the Output
|
||||
// tab's reader, which shows documents in full.
|
||||
|
||||
export interface MissionDocument {
|
||||
run_id: string;
|
||||
phase_id: string | null;
|
||||
index: number;
|
||||
node_id: string;
|
||||
role: string;
|
||||
title: string;
|
||||
chars: number;
|
||||
run_status: string;
|
||||
}
|
||||
|
||||
export interface MissionDocumentBody {
|
||||
run_id: string;
|
||||
index: number;
|
||||
role: string;
|
||||
title: string;
|
||||
/** Complete, untruncated output text. */
|
||||
body: string;
|
||||
chars: number;
|
||||
}
|
||||
|
||||
/** Every agent output in the mission — titles + sizes only, no bodies. */
|
||||
export const listMissionDocuments = (id: string) =>
|
||||
api<{ documents: MissionDocument[] }>(`/api/missions/${id}/documents`);
|
||||
|
||||
/** One document, in full. Fetched on selection, not with the list. */
|
||||
export const getMissionDocument = (
|
||||
id: string,
|
||||
runId: string,
|
||||
index: number,
|
||||
) =>
|
||||
api<MissionDocumentBody>(
|
||||
`/api/missions/${id}/documents/${runId}/${index}`,
|
||||
);
|
||||
|
||||
export interface PhaseSummarySource {
|
||||
title?: string;
|
||||
note?: string;
|
||||
|
||||
+39
-20
@@ -79,25 +79,43 @@ ssh "$BUILD_HOST" 'set -e; cd ~/clawmates
|
||||
done'
|
||||
|
||||
if [ -z "${IMAGES_ONLY:-}" ]; then
|
||||
echo "→ pull + recreate server + frontend on $GW ($GW_DIR)"
|
||||
# Snapshot the currently-deployed images as :rollback (a repoint, cheap) so a
|
||||
# bad deploy can be reverted without a rebuild, then pull the freshly-pushed
|
||||
# images and recreate.
|
||||
# Pull the IMMUTABLE main-<sha> tag and retag it to :latest locally, then
|
||||
# recreate WITHOUT a compose pull. Pulling `:latest` here is not reliable —
|
||||
# the registry has served a stale manifest for that mutable tag (a deploy
|
||||
# pushed main-9bc5f6a fine, but `pull :latest` reported "up to date" and
|
||||
# left the OLD image running). Immutable tags always resolve correctly, so
|
||||
# the sha tag is the source of truth and `:latest` is just a local alias
|
||||
# for the compose file's image reference.
|
||||
echo "→ repoint registry :latest → main-$SHA"
|
||||
# gw-04 does NOT deploy from this script's push alone. A systemd timer
|
||||
# (clawmates-deploy.timer, every 60s, /usr/local/bin/clawmates-deploy.sh)
|
||||
# pulls `$REGISTRY/clawmates/<svc>:latest` and rolls the stack onto it
|
||||
# whenever the running image differs. So ANY local `docker tag`/recreate on
|
||||
# the gateway is reverted within a minute — the registry's `:latest` is the
|
||||
# single source of truth for what prod runs.
|
||||
#
|
||||
# And `docker push …:latest` does NOT reliably move that tag here: when the
|
||||
# manifest already exists in the registry under another tag (which it does,
|
||||
# we just pushed main-$SHA), the push reports a digest but `:latest` keeps
|
||||
# resolving to the old image. Writing the manifest to the tag directly over
|
||||
# the HTTP API is what actually moves it. Verified: PUT → 201, and the
|
||||
# timer then rolls prod on its own.
|
||||
ssh "$BUILD_HOST" "set -e
|
||||
for svc in server frontend; do
|
||||
ct=\$(curl -s -o /tmp/cm-manifest.json -D- \
|
||||
-H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json' \
|
||||
http://$REGISTRY/v2/clawmates/\$svc/manifests/main-$SHA \
|
||||
| awk -F': ' '/^[Cc]ontent-[Tt]ype/{print \$2}' | tr -d '\r')
|
||||
code=\$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
|
||||
-H \"Content-Type: \$ct\" --data-binary @/tmp/cm-manifest.json \
|
||||
http://$REGISTRY/v2/clawmates/\$svc/manifests/latest)
|
||||
echo \" \$svc :latest → main-$SHA (HTTP \$code)\"
|
||||
case \"\$code\" in 20*) ;; *) echo \" ✗ tag write failed\"; exit 1 ;; esac
|
||||
done"
|
||||
|
||||
echo "→ roll $GW onto main-$SHA"
|
||||
# Roll immediately rather than waiting up to 60s for the timer. Snapshot the
|
||||
# outgoing image as :rollback first so a revert is a repoint, not a rebuild.
|
||||
ssh "$GW" "set -e
|
||||
for svc in server frontend; do
|
||||
docker tag $REGISTRY/clawmates/\$svc:$TAG $REGISTRY/clawmates/\$svc:rollback 2>/dev/null || true
|
||||
docker pull $REGISTRY/clawmates/\$svc:main-$SHA
|
||||
docker tag $REGISTRY/clawmates/\$svc:main-$SHA $REGISTRY/clawmates/\$svc:$TAG
|
||||
docker pull -q $REGISTRY/clawmates/\$svc:$TAG >/dev/null
|
||||
done
|
||||
cd $GW_DIR
|
||||
docker-compose -p clawmates up -d --force-recreate --no-deps server frontend"
|
||||
docker-compose -p clawmates up -d --no-deps server frontend"
|
||||
fi
|
||||
|
||||
echo "→ load agent runtime images onto $GW + every fleet node"
|
||||
@@ -110,16 +128,17 @@ done
|
||||
|
||||
if [ -z "${IMAGES_ONLY:-}" ]; then
|
||||
echo "→ verify"
|
||||
# Verify the RUNNING image matches what we just pushed — not just that the
|
||||
# edge is up. A green edge on the OLD image is the silent-revert failure mode
|
||||
# this check exists to catch.
|
||||
want=$(ssh "$GW" "docker image inspect -f '{{.Id}}' $REGISTRY/clawmates/server:main-$SHA 2>/dev/null" || true)
|
||||
# Verify the RUNNING image is the one we just published — not just that the
|
||||
# edge is up. A green edge on the OLD image is the silent-revert failure
|
||||
# mode this check exists to catch. Compare against the resolved :latest,
|
||||
# which is what both compose and the rolling timer deploy from.
|
||||
want=$(ssh "$GW" "docker image inspect -f '{{.Id}}' $REGISTRY/clawmates/server:$TAG 2>/dev/null" || true)
|
||||
got=$(ssh "$GW" "docker inspect -f '{{.Image}}' clawmates_server_1 2>/dev/null" || true)
|
||||
if [ -n "$want" ] && [ "$want" = "$got" ]; then
|
||||
echo " server running expected image ($SHA): ${got:7:12}"
|
||||
else
|
||||
echo " ✗ server image MISMATCH — running ${got:7:12}, expected main-$SHA (${want:7:12})"
|
||||
echo " the deploy did NOT take effect; check the registry pull on $GW"
|
||||
echo " ✗ server image MISMATCH — running ${got:7:12}, expected ${want:7:12}"
|
||||
echo " check that :latest was repointed and the roll succeeded on $GW"
|
||||
exit 1
|
||||
fi
|
||||
ssh "$GW" 'curl -s -o /dev/null -w " edge HTTP %{http_code}\n" -m 10 https://clawmates.work/ || true'
|
||||
|
||||
Reference in New Issue
Block a user