//! Build-log endpoints for the dashboard. //! //! Header lists (all-agents + per-agent), the full row by id, a `text/plain` //! download, and an SSE stream that delivers incremental stdout/stderr while //! a build runs (closing once it finishes or the row is vacuum-reaped). use std::convert::Infallible; use axum::{ extract::{Path as AxumPath, State}, http::StatusCode, response::{ IntoResponse, Response, sse::{Event, KeepAlive, Sse}, }, }; use serde::{Deserialize, Serialize}; use tokio_stream::Stream; use tokio_stream::wrappers::ReceiverStream; use super::{AppState, error_response, validate_agent_name}; #[derive(Deserialize)] pub(super) struct BuildLogsAllQuery { /// Max rows to return. Capped at 100. Default 30. #[serde(default)] limit: Option, } /// `GET /api/build-logs?limit=N` — most-recent build log headers across /// all agents, newest first. Same JSON shape as the per-agent endpoint. pub(super) async fn get_build_logs_all( State(state): State, axum::extract::Query(q): axum::extract::Query, ) -> Response { let limit = q.limit.unwrap_or(30); match state.coord.build_logs.list_recent_all(limit) { Ok(rows) => axum::Json(rows).into_response(), Err(e) => error_response(&format!("build-logs all: {e:#}")), } } #[derive(Deserialize)] pub(super) struct BuildLogsQuery { /// Maximum number of rows to return. Capped server-side at 50 /// (see `build_logs::list_recent_for_agent`). Default 10. #[serde(default)] limit: Option, } /// `GET /api/build-logs/{agent}?limit=N` — most-recent build log /// headers for one agent, newest first. Returns /// `Vec` (JSON). Limit defaults to 10, server-side /// cap at 50. Backs the per-agent log chip in the agent card and /// the side-panel header list. pub(super) async fn get_build_logs_agent( State(state): State, AxumPath(name): AxumPath, axum::extract::Query(q): axum::extract::Query, ) -> Response { if let Some(reason) = validate_agent_name(&name) { return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response(); } let limit = q.limit.unwrap_or(10); match state.coord.build_logs.list_recent_for_agent(&name, limit) { Ok(rows) => axum::Json(rows).into_response(), Err(e) => error_response(&format!("build-logs {name}: {e:#}")), } } /// `GET /api/build-logs/id/{id}` — full build log row (stdout + /// stderr concatenated) by id. Returns `BuildLogFull` (JSON), or /// HTTP 404 when the id doesn't exist (vacuum-reaped, or the /// operator passed a stale id from a refresh race). pub(super) async fn get_build_log_full( State(state): State, AxumPath(id): AxumPath, ) -> Response { match state.coord.build_logs.get_full(id) { Ok(Some(log)) => axum::Json(log).into_response(), Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(), Err(e) => error_response(&format!("build-log {id}: {e:#}")), } } /// JSON frame sent on the `/api/build-logs/id/{id}/stream` SSE channel. /// `stdout_append` / `stderr_append` carry only the new bytes since the /// last frame; `done = true` means the build finished and the stream /// will close after this frame. #[derive(Serialize)] struct BuildLogFrame { stdout_append: String, stderr_append: String, #[serde(skip_serializing_if = "Option::is_none")] status: Option, done: bool, } /// `GET /api/build-logs/id/{id}/stream` — SSE stream that delivers /// incremental stdout/stderr as a build runs. The client connects when /// it opens a running-build panel; the stream closes automatically once /// the build finishes (or the row disappears due to a vacuum). /// /// Each frame is a JSON-serialised `BuildLogFrame`. The first frame /// always carries the full accumulated log so far (cursors start at 0); /// subsequent frames carry only new bytes. `done: true` on the final /// frame signals the browser to close the `EventSource`. pub(super) async fn get_build_log_stream( State(state): State, AxumPath(id): AxumPath, ) -> Sse>> { let (tx, rx) = tokio::sync::mpsc::channel::>(32); let logs = state.coord.build_logs.clone(); tokio::spawn(async move { let mut notify_rx = logs.subscribe_notifications(); let mut stdout_cursor = 0usize; let mut stderr_cursor = 0usize; // ── initial snapshot ────────────────────────────────────────── match logs.get_progress(id, stdout_cursor, stderr_cursor) { Ok(Some(prog)) => { stdout_cursor += prog.stdout_append.len(); stderr_cursor += prog.stderr_append.len(); let done = prog.finished_at.is_some(); if let Ok(json) = serde_json::to_string(&BuildLogFrame { stdout_append: prog.stdout_append, stderr_append: prog.stderr_append, status: prog.status, done, }) { let _ = tx.send(Ok(Event::default().data(json))).await; } if done { return; } } Ok(None) => { // Row missing — send a single error event and exit. let _ = tx .send(Ok(Event::default() .event("error") .data(format!("build log #{id} not found")))) .await; return; } Err(e) => { let _ = tx .send(Ok(Event::default() .event("error") .data(format!("build log #{id}: {e:#}")))) .await; return; } } // ── live delta loop ─────────────────────────────────────────── loop { match notify_rx.recv().await { // Notification for a different build — ignore and wait // for the next one. Ok(notif_id) if notif_id != id => {} Ok(_) => { match logs.get_progress(id, stdout_cursor, stderr_cursor) { Ok(Some(prog)) => { stdout_cursor += prog.stdout_append.len(); stderr_cursor += prog.stderr_append.len(); let done = prog.finished_at.is_some(); if let Ok(json) = serde_json::to_string(&BuildLogFrame { stdout_append: prog.stdout_append, stderr_append: prog.stderr_append, status: prog.status, done, }) && tx.send(Ok(Event::default().data(json))).await.is_err() { return; // browser disconnected } if done { return; } } Ok(None) | Err(_) => return, // vacuum reaped row / channel closed } } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} Err(tokio::sync::broadcast::error::RecvError::Closed) => return, } } }); Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default()) } /// `GET /api/build-logs/id/{id}/raw` — full log as `text/plain` for /// download. Stdout and stderr are concatenated with a `--- stderr ---` /// separator (same layout the JS side-panel renders). The /// `Content-Disposition` header triggers a browser download with a /// descriptive filename so the operator can save and share the log. pub(super) async fn get_build_log_raw( State(state): State, AxumPath(id): AxumPath, ) -> Response { match state.coord.build_logs.get_full(id) { Ok(Some(log)) => { let mut text = log.stdout; if !log.stderr.is_empty() { text.push_str("\n--- stderr ---\n"); text.push_str(&log.stderr); } ( StatusCode::OK, [ ("content-type", "text/plain; charset=utf-8".to_string()), ( "content-disposition", format!( "attachment; filename=\"build-log-{}-{}.txt\"", log.header.agent, id ), ), ], text, ) .into_response() } Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(), Err(e) => error_response(&format!("build-log {id}: {e:#}")), } }