use axum::{
extract::{Query, State},
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
Json,
};
use futures::stream::Stream;
use wsb::prelude::*;
use serde::Deserialize;
use std::convert::Infallible;
use std::io::SeekFrom;
use std::path::{Path, PathBuf};
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use crate::web::state::WebState;
const POLL: std::time::Duration = std::time::Duration::from_millis(500);
const TAIL_BYTES: u64 = 64 * 1024;
#[derive(Debug, Deserialize)]
pub struct TailQuery {
pub path: String,
}
pub fn resolve_in_logs(workspace_root: &Path, requested: &str) -> Result<PathBuf, String> {
let logs_root = wsb::platform::canonicalize(wsb::logs::root(workspace_root))
.map_err(|error| format!("the logs root cannot be read: {error}"))?;
let candidate = if Path::new(requested).is_absolute() {
PathBuf::from(requested)
} else {
workspace_root.join(requested)
};
let (existing, rest) = deepest_existing(&candidate);
let existing = wsb::platform::canonicalize(&existing)
.map_err(|error| format!("{} cannot be resolved: {error}", existing.display()))?;
let resolved = existing.join(rest);
if !resolved.starts_with(&logs_root) {
return Err(format!(
"{requested} is not inside the logs root — only a run's own logs can be followed"
));
}
Ok(resolved)
}
fn deepest_existing(path: &Path) -> (PathBuf, PathBuf) {
let mut existing = path.to_path_buf();
let mut rest = PathBuf::new();
while !existing.exists() {
let Some(name) = existing.file_name().map(|n| n.to_os_string()) else {
break;
};
let Some(parent) = existing.parent().map(|p| p.to_path_buf()) else {
break;
};
rest = if rest.as_os_str().is_empty() {
PathBuf::from(&name)
} else {
Path::new(&name).join(&rest)
};
existing = parent;
}
(existing, rest)
}
pub async fn tail_stream(
State(state): State<WebState>,
Query(query): Query<TailQuery>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, Json<serde_json::Value>)>
{
let path = resolve_in_logs(&state.workspace_root, &query.path).map_err(|error| {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": error })),
)
})?;
let stream = async_stream::stream! {
let mut offset = match tokio::fs::metadata(&path).await {
Ok(meta) => meta.len().saturating_sub(TAIL_BYTES),
Err(_) => 0,
};
let mut pending = String::new();
let mut missing_reported = false;
loop {
match read_from(&path, offset).await {
Ok((text, next)) => {
offset = next;
if !text.is_empty() {
pending.push_str(&text);
let mut lines: Vec<String> = Vec::new();
while let Some(at) = pending.find('\n') {
let line: String = pending.drain(..=at).collect();
lines.push(line.trim_end_matches(['\n', '\r']).to_string());
}
if !lines.is_empty() {
let payload = serde_json::json!({
"event": "lines",
"lines": lines,
"offset": offset,
});
yield Ok(Event::default().event("tail").data(payload.to_string()));
}
}
}
Err(error) => {
if !missing_reported {
missing_reported = true;
let payload = serde_json::json!({
"event": "waiting",
"reason": error.to_string(),
});
yield Ok(Event::default().event("tail").data(payload.to_string()));
}
}
}
tokio::time::sleep(POLL).await;
}
};
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
async fn read_from(path: &Path, offset: u64) -> std::io::Result<(String, u64)> {
let mut file = tokio::fs::File::open(path).await?;
let len = file.metadata().await?.len();
let from = if len < offset { 0 } else { offset };
if len == from {
return Ok((String::new(), from));
}
file.seek(SeekFrom::Start(from)).await?;
let mut buffer = Vec::with_capacity((len - from) as usize);
file.read_to_end(&mut buffer).await?;
let read = buffer.len() as u64;
Ok((String::from_utf8_lossy(&buffer).into_owned(), from + read))
}
#[cfg(test)]
#[path = "log_tail_tests.rs"]
mod log_tail_tests;