use crate::serve::error::ServeError;
use crate::serve::logs::{LogEvent, log_events};
use crate::serve::state::ServerState;
use axum::extract::{Path, Query, State};
use axum::response::sse::{Event, KeepAlive};
use axum::response::{IntoResponse, Response, Sse};
use futures::StreamExt;
use serde::Deserialize;
use std::time::Duration;
use tokio::sync::broadcast;
const KEEP_ALIVE_SECS: u64 = 15;
const DEFAULT_LOG_LIMIT: usize = 1_000;
const MAX_LOG_LIMIT: usize = 10_000;
#[derive(Debug, Deserialize)]
pub struct LogQuery {
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub after: Option<u64>,
#[serde(default)]
pub limit: Option<usize>,
}
pub async fn stream_logs(
State(state): State<ServerState>,
Path(id): Path<String>,
Query(q): Query<LogQuery>,
) -> Result<Response, ServeError> {
match q.format.as_deref() {
None => stream_logs_sse(state, id)
.await
.map(IntoResponse::into_response),
Some("jsonl") | Some("text") => persisted_logs(state, id, q).await,
Some(other) => Err(ServeError::BadConfig(format!(
"unknown log format '{other}'; use 'jsonl' or 'text' (or omit for the SSE stream)"
))),
}
}
async fn persisted_logs(
state: ServerState,
id: String,
q: LogQuery,
) -> Result<Response, ServeError> {
let known = state
.history()
.get(&id)
.await
.map_err(|e| ServeError::Internal(e.to_string()))?
.is_some();
if !known {
return Err(ServeError::NotFound);
}
let limit = q.limit.unwrap_or(DEFAULT_LOG_LIMIT).clamp(1, MAX_LOG_LIMIT);
let page = state
.history()
.list_run_logs(&id, q.after, limit)
.await
.map_err(|e| ServeError::Internal(e.to_string()))?;
if q.format.as_deref() == Some("text") {
let mut body = String::new();
for l in &page.lines {
body.push_str(&l.line);
body.push('\n');
}
if page.truncated {
body.push_str("… (earlier lines truncated: per-run cap reached)\n");
}
return Ok((
[(
axum::http::header::CONTENT_TYPE,
"text/plain; charset=utf-8",
)],
body,
)
.into_response());
}
let mut body = String::new();
for l in &page.lines {
body.push_str(&serde_json::to_string(l).unwrap_or_default());
body.push('\n');
}
if page.truncated {
body.push_str(r#"{"truncated":true}"#);
body.push('\n');
}
Ok((
[(axum::http::header::CONTENT_TYPE, "application/x-ndjson")],
body,
)
.into_response())
}
async fn stream_logs_sse(
state: ServerState,
id: String,
) -> Result<Sse<impl futures::Stream<Item = Result<Event, std::convert::Infallible>>>, ServeError> {
let (snapshot, rx, ended) = match state.log_hub().reader(&id) {
Some(reader) => reader,
None => {
let known = state
.history()
.get(&id)
.await
.map_err(|e| ServeError::Internal(e.to_string()))?
.is_some();
if !known {
return Err(ServeError::NotFound);
}
(Vec::new(), broadcast::channel(1).1, true)
}
};
let stream = log_events(snapshot, rx, ended)
.map(|ev| Ok::<Event, std::convert::Infallible>(to_sse_event(ev)));
Ok(
Sse::new(stream)
.keep_alive(KeepAlive::new().interval(Duration::from_secs(KEEP_ALIVE_SECS))),
)
}
fn to_sse_event(ev: LogEvent) -> Event {
match ev {
LogEvent::Log(line) => Event::default().event("log").data(line),
LogEvent::Truncated(n) => Event::default().event("truncated").data(format!(
"{n} log line(s) dropped; rely on the persisted logs (?format=jsonl) or the centralized log sink"
)),
LogEvent::End => Event::default().event("end").data("done"),
}
}