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),
|
patch(routes::missions::set_description),
|
||||||
)
|
)
|
||||||
.route("/api/missions/{id}/runs", get(routes::missions::list_runs))
|
.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(
|
.route(
|
||||||
"/api/missions/{id}/phases/{phase_id}/retry",
|
"/api/missions/{id}/phases/{phase_id}/retry",
|
||||||
post(routes::missions::retry_phase),
|
post(routes::missions::retry_phase),
|
||||||
|
|||||||
@@ -101,18 +101,51 @@ pub struct SecurityScanResponse {
|
|||||||
|
|
||||||
// ── Handlers ─────────────────────────────────────────────────────
|
// ── 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(
|
pub async fn list(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Authed(user): Authed,
|
Authed(user): Authed,
|
||||||
Query(q): Query<ListQuery>,
|
Query(q): Query<ListQuery>,
|
||||||
) -> Result<Json<Vec<Mission>>, ApiError> {
|
) -> Result<Json<Vec<MissionListItem>>, ApiError> {
|
||||||
let rows = cm_db::repo::missions::list_by_workspace(
|
let rows = cm_db::repo::missions::list_by_workspace(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
user.workspace_id.as_uuid(),
|
user.workspace_id.as_uuid(),
|
||||||
q.limit.clamp(1, 500),
|
q.limit.clamp(1, 500),
|
||||||
)
|
)
|
||||||
.await?;
|
.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(
|
pub async fn create(
|
||||||
@@ -697,3 +730,240 @@ pub async fn set_status(
|
|||||||
.ok_or(ApiError::NotFound)?;
|
.ok_or(ApiError::NotFound)?;
|
||||||
Ok(Json(mission))
|
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())
|
.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,
|
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";
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
type Block =
|
type Block =
|
||||||
| { kind: "h1" | "h2" | "h3"; text: string }
|
| { kind: "h1" | "h2" | "h3"; text: string; id?: string }
|
||||||
| { kind: "p"; text: string }
|
| { kind: "p"; text: string }
|
||||||
| { kind: "ul"; items: 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[] {
|
function parse(md: string): Block[] {
|
||||||
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
const lines = md.replace(/\r\n/g, "\n").split("\n");
|
||||||
@@ -28,13 +56,32 @@ function parse(md: string): Block[] {
|
|||||||
i++;
|
i++;
|
||||||
continue;
|
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
|
// Headings
|
||||||
const h = /^(#{1,3})\s+(.*)$/.exec(trimmed);
|
const h = /^(#{1,6})\s+(.*)$/.exec(trimmed);
|
||||||
if (h) {
|
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({
|
blocks.push({
|
||||||
kind: (`h${level}` as "h1" | "h2" | "h3"),
|
kind: (`h${level}` as "h1" | "h2" | "h3"),
|
||||||
text: h[2],
|
text,
|
||||||
|
id: headingId(text, blocks.length),
|
||||||
});
|
});
|
||||||
i++;
|
i++;
|
||||||
continue;
|
continue;
|
||||||
@@ -64,7 +111,8 @@ function parse(md: string): Block[] {
|
|||||||
while (
|
while (
|
||||||
i < lines.length &&
|
i < lines.length &&
|
||||||
lines[i].trim() &&
|
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()) &&
|
!/^[-*]\s+/.test(lines[i].trim()) &&
|
||||||
!/^\d+\.\s+/.test(lines[i].trim())
|
!/^\d+\.\s+/.test(lines[i].trim())
|
||||||
) {
|
) {
|
||||||
@@ -133,6 +181,7 @@ export function MarkdownBlock({ source }: { source: string }) {
|
|||||||
return (
|
return (
|
||||||
<h1
|
<h1
|
||||||
key={idx}
|
key={idx}
|
||||||
|
id={b.id}
|
||||||
style={{
|
style={{
|
||||||
margin: "8px 0 2px",
|
margin: "8px 0 2px",
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
@@ -148,6 +197,7 @@ export function MarkdownBlock({ source }: { source: string }) {
|
|||||||
return (
|
return (
|
||||||
<h2
|
<h2
|
||||||
key={idx}
|
key={idx}
|
||||||
|
id={b.id}
|
||||||
style={{
|
style={{
|
||||||
margin: "10px 0 -2px",
|
margin: "10px 0 -2px",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -165,6 +215,7 @@ export function MarkdownBlock({ source }: { source: string }) {
|
|||||||
return (
|
return (
|
||||||
<h3
|
<h3
|
||||||
key={idx}
|
key={idx}
|
||||||
|
id={b.id}
|
||||||
style={{
|
style={{
|
||||||
margin: "6px 0 -4px",
|
margin: "6px 0 -4px",
|
||||||
fontSize: 11.5,
|
fontSize: 11.5,
|
||||||
@@ -178,6 +229,43 @@ export function MarkdownBlock({ source }: { source: string }) {
|
|||||||
{renderInline(b.text)}
|
{renderInline(b.text)}
|
||||||
</h3>
|
</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")
|
if (b.kind === "p")
|
||||||
return (
|
return (
|
||||||
<p key={idx} style={{ margin: 0 }}>
|
<p key={idx} style={{ margin: 0 }}>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { EditMissionModal } from "./EditMissionModal";
|
|||||||
import { MarkdownBlock } from "./MarkdownBlock";
|
import { MarkdownBlock } from "./MarkdownBlock";
|
||||||
import { MissionLiveEvents } from "./MissionLiveEvents";
|
import { MissionLiveEvents } from "./MissionLiveEvents";
|
||||||
import { MissionLivePane } from "./MissionLivePane";
|
import { MissionLivePane } from "./MissionLivePane";
|
||||||
|
import { MissionOutputReader } from "./MissionOutputReader";
|
||||||
import { MissionTeamTab } from "./MissionTeamTab";
|
import { MissionTeamTab } from "./MissionTeamTab";
|
||||||
import { MissionWizard } from "./MissionWizard";
|
import { MissionWizard } from "./MissionWizard";
|
||||||
import { PhaseRunsList } from "./PhaseRunsList";
|
import { PhaseRunsList } from "./PhaseRunsList";
|
||||||
@@ -89,15 +90,19 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
|
|||||||
custom: "Custom",
|
custom: "Custom",
|
||||||
};
|
};
|
||||||
|
|
||||||
type Tab =
|
// Three primary tabs, each with a shallow segmented sub-view. The old
|
||||||
| "overview"
|
// shape was eight flat tabs (overview/phases/tasks/team/live/artifacts/
|
||||||
| "phases"
|
// benchmarks/pane) that mixed lifecycle, work items, people, telemetry,
|
||||||
| "tasks"
|
// outputs and infra at one level — so nothing told you where the actual
|
||||||
| "team"
|
// deliverable lived (it was buried under phases → run → turn).
|
||||||
| "live"
|
//
|
||||||
| "artifacts"
|
// RUN what is happening · phases · tasks · live
|
||||||
| "benchmarks"
|
// OUTPUT what came out of it · documents · artifacts · benchmarks
|
||||||
| "pane";
|
// 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({
|
export function MissionCanvas({
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -118,7 +123,10 @@ export function MissionCanvas({
|
|||||||
const [mission, setMission] = useState<MissionDetail | null>(null);
|
const [mission, setMission] = useState<MissionDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [launching, setLaunching] = useState(false);
|
||||||
const [refining, setRefining] = useState(false);
|
const [refining, setRefining] = useState(false);
|
||||||
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
|
const [refineDiff, setRefineDiff] = useState<RefineResult | null>(null);
|
||||||
@@ -540,12 +548,19 @@ export function MissionCanvas({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{mission.description && !headerCollapsed && (
|
{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
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
maxHeight: "38vh",
|
maxHeight: 92,
|
||||||
overflowY: "auto",
|
overflow: "hidden",
|
||||||
paddingRight: 8,
|
paddingRight: 8,
|
||||||
|
maskImage:
|
||||||
|
"linear-gradient(to bottom, #000 60%, transparent 100%)",
|
||||||
|
WebkitMaskImage:
|
||||||
|
"linear-gradient(to bottom, #000 60%, transparent 100%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MarkdownBlock source={mission.description} />
|
<MarkdownBlock source={mission.description} />
|
||||||
@@ -582,52 +597,97 @@ export function MissionCanvas({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Primary tabs — three, not eight. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
marginTop: 4,
|
marginTop: 6,
|
||||||
overflowX: "auto",
|
overflowX: "auto",
|
||||||
paddingBottom: 2,
|
paddingBottom: 2,
|
||||||
scrollbarWidth: "thin",
|
scrollbarWidth: "thin",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(
|
{(["run", "output", "setup"] as Tab[]).map((t) => {
|
||||||
[
|
|
||||||
"overview",
|
|
||||||
"phases",
|
|
||||||
"tasks",
|
|
||||||
"team",
|
|
||||||
"live",
|
|
||||||
"artifacts",
|
|
||||||
"benchmarks",
|
|
||||||
...(mission.runtime_kind === "local_herdr" ? (["pane"] as const) : []),
|
|
||||||
] as Tab[]
|
|
||||||
).map((t) => {
|
|
||||||
const active = tab === 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
style={{
|
style={{
|
||||||
padding: "5px 12px",
|
padding: "6px 16px",
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: `1px solid ${active ? "rgba(255,138,122,.5)" : "rgba(255,255,255,.08)"}`,
|
border: `1px solid ${active ? "rgba(255,138,122,.5)" : "rgba(255,255,255,.08)"}`,
|
||||||
background: active ? "rgba(255,138,122,.08)" : "transparent",
|
background: active ? "rgba(255,138,122,.08)" : "transparent",
|
||||||
color: active ? "#ff8a7a" : "#a0a0a8",
|
color: active ? "#ff8a7a" : "#a0a0a8",
|
||||||
fontFamily: mono,
|
fontFamily: mono,
|
||||||
fontSize: 11,
|
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",
|
letterSpacing: ".08em",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -638,16 +698,9 @@ export function MissionCanvas({
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t}
|
{sub}
|
||||||
{badge !== null && (
|
{badge !== null && (
|
||||||
<span
|
<span style={{ fontSize: 9.5, color: "#6a6a72" }}>{badge}</span>
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
color: active ? "#ff8a7a" : "#8a8a92",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -655,9 +708,44 @@ export function MissionCanvas({
|
|||||||
</div>
|
</div>
|
||||||
</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 }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 22 }}>
|
||||||
{tab === "overview" && (
|
{tab === "setup" && setupSub === "overview" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
<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="Template" v={TEMPLATE_LABEL[mission.template_kind] ?? mission.template_kind} />
|
||||||
<FieldRow k="Status" v={mission.status} />
|
<FieldRow k="Status" v={mission.status} />
|
||||||
<FieldRow k="Team" v={mission.team_id ?? "(auto-provision on launch)"} />
|
<FieldRow k="Team" v={mission.team_id ?? "(auto-provision on launch)"} />
|
||||||
@@ -679,7 +767,7 @@ export function MissionCanvas({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "phases" && (
|
{tab === "run" && runSub === "phases" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{orderedPhases.length === 0 ? (
|
{orderedPhases.length === 0 ? (
|
||||||
<Empty label="no phases" />
|
<Empty label="no phases" />
|
||||||
@@ -841,7 +929,7 @@ export function MissionCanvas({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "tasks" && (
|
{tab === "run" && runSub === "tasks" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{mission.tasks.length === 0 ? (
|
{mission.tasks.length === 0 ? (
|
||||||
<Empty label="no tasks yet — task-card parser lands in Slice 5" />
|
<Empty label="no tasks yet — task-card parser lands in Slice 5" />
|
||||||
@@ -910,7 +998,7 @@ export function MissionCanvas({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "team" && (
|
{tab === "setup" && setupSub === "team" && (
|
||||||
<MissionTeamTab
|
<MissionTeamTab
|
||||||
missionId={mission.id}
|
missionId={mission.id}
|
||||||
teamId={mission.team_id}
|
teamId={mission.team_id}
|
||||||
@@ -918,11 +1006,11 @@ export function MissionCanvas({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "live" && (
|
{tab === "run" && runSub === "live" && (
|
||||||
<MissionLiveEvents missionId={mission.id} visible={tab === "live"} />
|
<MissionLiveEvents missionId={mission.id} visible={tab === "run" && runSub === "live"} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "artifacts" && (
|
{tab === "output" && outputSub === "artifacts" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{mission.artifacts.length === 0 ? (
|
{mission.artifacts.length === 0 ? (
|
||||||
<Empty label="no artifacts yet — phases produce them as they run" />
|
<Empty label="no artifacts yet — phases produce them as they run" />
|
||||||
@@ -1018,7 +1106,7 @@ export function MissionCanvas({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "benchmarks" && (
|
{tab === "output" && outputSub === "benchmarks" && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{mission.benchmarks.length === 0 ? (
|
{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" }" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "pane" && mission.runtime_kind === "local_herdr" && (
|
{tab === "setup" && setupSub === "pane" && mission.runtime_kind === "local_herdr" && (
|
||||||
<MissionLivePane
|
<MissionLivePane
|
||||||
nodeId={mission.target_node_id}
|
nodeId={mission.target_node_id}
|
||||||
visible={tab === "pane"}
|
visible={tab === "setup" && setupSub === "pane"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</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 {
|
import {
|
||||||
deleteMission,
|
deleteMission,
|
||||||
listMissions,
|
listMissions,
|
||||||
type Mission,
|
type MissionListItem,
|
||||||
type MissionStatus,
|
type MissionStatus,
|
||||||
|
type PhaseKind,
|
||||||
type TemplateKind,
|
type TemplateKind,
|
||||||
} from "@/lib/api/missions";
|
} from "@/lib/api/missions";
|
||||||
import { MissionWizard } from "./MissionWizard";
|
import { MissionWizard } from "./MissionWizard";
|
||||||
@@ -52,7 +53,7 @@ export function MissionsList({
|
|||||||
* currently-open mission was among the deleted rows. */
|
* currently-open mission was among the deleted rows. */
|
||||||
onDeleted?: (deletedIds: string[]) => void;
|
onDeleted?: (deletedIds: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [missions, setMissions] = useState<Mission[]>([]);
|
const [missions, setMissions] = useState<MissionListItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -351,6 +352,49 @@ export function MissionsList({
|
|||||||
>
|
>
|
||||||
{m.title}
|
{m.title}
|
||||||
</div>
|
</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>
|
</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 = {
|
const iconBtn: React.CSSProperties = {
|
||||||
width: 26,
|
width: 26,
|
||||||
height: 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 }) {
|
function RunOutputPanel({ runId }: { runId: string }) {
|
||||||
const [data, setData] = useState<RunOutput | null>(null);
|
const [data, setData] = useState<RunOutput | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -238,6 +246,10 @@ function RunOutputPanel({ runId }: { runId: string }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</summary>
|
</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
|
<pre
|
||||||
style={{
|
style={{
|
||||||
margin: "4px 0 0",
|
margin: "4px 0 0",
|
||||||
@@ -248,12 +260,24 @@ function RunOutputPanel({ runId }: { runId: string }) {
|
|||||||
fontSize: 10.5,
|
fontSize: 10.5,
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
maxHeight: 300,
|
|
||||||
overflow: "auto",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{o.preview}
|
{excerpt(o.preview)}
|
||||||
</pre>
|
</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>
|
</details>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -196,8 +196,16 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
return (await r.json()) as 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) =>
|
export const listMissions = (limit = 50) =>
|
||||||
api<Mission[]>(`/api/missions?limit=${limit}`);
|
api<MissionListItem[]>(`/api/missions?limit=${limit}`);
|
||||||
|
|
||||||
export const getMission = (id: string) =>
|
export const getMission = (id: string) =>
|
||||||
api<MissionDetail>(`/api/missions/${id}`);
|
api<MissionDetail>(`/api/missions/${id}`);
|
||||||
@@ -250,6 +258,46 @@ export interface RunOutput {
|
|||||||
export const getRunOutput = (runId: string) =>
|
export const getRunOutput = (runId: string) =>
|
||||||
api<RunOutput>(`/api/topology-runs/${runId}/output`);
|
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 {
|
export interface PhaseSummarySource {
|
||||||
title?: string;
|
title?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
|||||||
+39
-20
@@ -79,25 +79,43 @@ ssh "$BUILD_HOST" 'set -e; cd ~/clawmates
|
|||||||
done'
|
done'
|
||||||
|
|
||||||
if [ -z "${IMAGES_ONLY:-}" ]; then
|
if [ -z "${IMAGES_ONLY:-}" ]; then
|
||||||
echo "→ pull + recreate server + frontend on $GW ($GW_DIR)"
|
echo "→ repoint registry :latest → main-$SHA"
|
||||||
# Snapshot the currently-deployed images as :rollback (a repoint, cheap) so a
|
# gw-04 does NOT deploy from this script's push alone. A systemd timer
|
||||||
# bad deploy can be reverted without a rebuild, then pull the freshly-pushed
|
# (clawmates-deploy.timer, every 60s, /usr/local/bin/clawmates-deploy.sh)
|
||||||
# images and recreate.
|
# pulls `$REGISTRY/clawmates/<svc>:latest` and rolls the stack onto it
|
||||||
# Pull the IMMUTABLE main-<sha> tag and retag it to :latest locally, then
|
# whenever the running image differs. So ANY local `docker tag`/recreate on
|
||||||
# recreate WITHOUT a compose pull. Pulling `:latest` here is not reliable —
|
# the gateway is reverted within a minute — the registry's `:latest` is the
|
||||||
# the registry has served a stale manifest for that mutable tag (a deploy
|
# single source of truth for what prod runs.
|
||||||
# pushed main-9bc5f6a fine, but `pull :latest` reported "up to date" and
|
#
|
||||||
# left the OLD image running). Immutable tags always resolve correctly, so
|
# And `docker push …:latest` does NOT reliably move that tag here: when the
|
||||||
# the sha tag is the source of truth and `:latest` is just a local alias
|
# manifest already exists in the registry under another tag (which it does,
|
||||||
# for the compose file's image reference.
|
# 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
|
ssh "$GW" "set -e
|
||||||
for svc in server frontend; do
|
for svc in server frontend; do
|
||||||
docker tag $REGISTRY/clawmates/\$svc:$TAG $REGISTRY/clawmates/\$svc:rollback 2>/dev/null || true
|
docker tag $REGISTRY/clawmates/\$svc:$TAG $REGISTRY/clawmates/\$svc:rollback 2>/dev/null || true
|
||||||
docker pull $REGISTRY/clawmates/\$svc:main-$SHA
|
docker pull -q $REGISTRY/clawmates/\$svc:$TAG >/dev/null
|
||||||
docker tag $REGISTRY/clawmates/\$svc:main-$SHA $REGISTRY/clawmates/\$svc:$TAG
|
|
||||||
done
|
done
|
||||||
cd $GW_DIR
|
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
|
fi
|
||||||
|
|
||||||
echo "→ load agent runtime images onto $GW + every fleet node"
|
echo "→ load agent runtime images onto $GW + every fleet node"
|
||||||
@@ -110,16 +128,17 @@ done
|
|||||||
|
|
||||||
if [ -z "${IMAGES_ONLY:-}" ]; then
|
if [ -z "${IMAGES_ONLY:-}" ]; then
|
||||||
echo "→ verify"
|
echo "→ verify"
|
||||||
# Verify the RUNNING image matches what we just pushed — not just that the
|
# 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
|
# edge is up. A green edge on the OLD image is the silent-revert failure
|
||||||
# this check exists to catch.
|
# mode this check exists to catch. Compare against the resolved :latest,
|
||||||
want=$(ssh "$GW" "docker image inspect -f '{{.Id}}' $REGISTRY/clawmates/server:main-$SHA 2>/dev/null" || true)
|
# 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)
|
got=$(ssh "$GW" "docker inspect -f '{{.Image}}' clawmates_server_1 2>/dev/null" || true)
|
||||||
if [ -n "$want" ] && [ "$want" = "$got" ]; then
|
if [ -n "$want" ] && [ "$want" = "$got" ]; then
|
||||||
echo " server running expected image ($SHA): ${got:7:12}"
|
echo " server running expected image ($SHA): ${got:7:12}"
|
||||||
else
|
else
|
||||||
echo " ✗ server image MISMATCH — running ${got:7:12}, expected main-$SHA (${want:7:12})"
|
echo " ✗ server image MISMATCH — running ${got:7:12}, expected ${want:7:12}"
|
||||||
echo " the deploy did NOT take effect; check the registry pull on $GW"
|
echo " check that :latest was repointed and the roll succeeded on $GW"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
ssh "$GW" 'curl -s -o /dev/null -w " edge HTTP %{http_code}\n" -m 10 https://clawmates.work/ || true'
|
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