Expose /jobs, /dedup, and /events routes backed by a new EventBus that broadcasts job lifecycle and per-job progress over SSE. Serve a static dashboard from front/ that lists pending and in-progress jobs, streams agent activity, and shows completed reviews. Also add migrate-apply/migrate-generate scripts that load .env, update the Containerfile to ship the front assets and use the apply script, and refresh the roadmap.
This commit is contained in:
1 parent
8c763786c1
commit
7493de6952
32 files changed
+1681
-61
No files matched your search
+104
-13
@@ -1,31 +1,48 @@
|
||||
use axum::body::{Bytes, to_bytes};
|
||||
use axum::extract::{FromRef, FromRequest, State};
|
||||
use axum::extract::{FromRef, FromRequest, Path, State};
|
||||
use axum::http::Request;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use futures_util::stream::{self, Stream, StreamExt};
|
||||
use reqwest::StatusCode;
|
||||
use ring::hmac;
|
||||
use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError};
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{info, instrument, warn};
|
||||
|
||||
use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE};
|
||||
use crate::errors::AppError;
|
||||
use crate::event_bus::JobEvent;
|
||||
use crate::gitea::WebhookType;
|
||||
use crate::metrics;
|
||||
use crate::schemas::JobStatus;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::Result<()> {
|
||||
pub async fn start(app_state: AppState) -> anyhow::Result<()> {
|
||||
let http_port = app_state.config.http_port;
|
||||
let shutdown = app_state.shutdown.clone();
|
||||
|
||||
// Le dashboard est servi depuis un dossier statique ; les routes API
|
||||
// ci-dessous restent prioritaires (le dossier n'est que le fallback).
|
||||
let front_dir = std::env::var("FRONT_DIR").unwrap_or_else(|_| String::from("front"));
|
||||
if !std::path::Path::new(&front_dir).is_dir() {
|
||||
warn!(dir = %front_dir, "front directory not found, the dashboard will 404");
|
||||
}
|
||||
let dashboard = ServeDir::new(&front_dir);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/webhook", post(webhook))
|
||||
.route("/dedup", get(dedup_list))
|
||||
.route("/jobs", get(jobs_list))
|
||||
.route("/events", get(lifecycle_events))
|
||||
.route("/jobs/{id}/events", get(job_events))
|
||||
.fallback_service(dashboard)
|
||||
.layer(
|
||||
ServiceBuilder::new()
|
||||
.layer(NewSentryLayer::<Request<_>>::new_from_top())
|
||||
@@ -46,8 +63,73 @@ pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn root() -> &'static str {
|
||||
"Hi, I'm Steep :)"
|
||||
async fn dedup_list(State(app_state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
||||
let reviews = app_state.dedup.list_reviews().await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"reviews": reviews
|
||||
})))
|
||||
}
|
||||
|
||||
async fn jobs_list(State(app_state): State<AppState>) -> Result<impl IntoResponse, AppError> {
|
||||
let pending = app_state.queue.list(JobStatus::Pending).await?;
|
||||
let processing = app_state.queue.list(JobStatus::Processing).await?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"pending": pending,
|
||||
"processing": processing,
|
||||
})))
|
||||
}
|
||||
|
||||
/// Flux SSE des évènements de cycle de vie global (liste des jobs).
|
||||
///
|
||||
/// Le flux est clos dès que l'arrêt gracieux est lancé, sans quoi une connexion
|
||||
/// SSE vivante empêcherait le serveur de s'arrêter.
|
||||
async fn lifecycle_events(
|
||||
State(app_state): State<AppState>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, axum::Error>>> {
|
||||
let rx = app_state.event_bus.subscribe_lifecycle();
|
||||
let shutdown = app_state.shutdown.cancelled_owned();
|
||||
|
||||
let stream = BroadcastStream::new(rx).map(|result| match result {
|
||||
Ok(event) => sse_json("lifecycle", &event),
|
||||
Err(BroadcastStreamRecvError::Lagged(skipped)) => sse_gap(skipped),
|
||||
});
|
||||
|
||||
Sse::new(stream.take_until(shutdown)).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
async fn job_events(
|
||||
State(app_state): State<AppState>,
|
||||
Path(id): Path<u64>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, axum::Error>>>, AppError> {
|
||||
let subscription = app_state
|
||||
.event_bus
|
||||
.subscribe_job(id)
|
||||
.ok_or(AppError::JobNotFoundErr(id))?;
|
||||
let shutdown = app_state.shutdown.cancelled_owned();
|
||||
|
||||
let history = subscription
|
||||
.history
|
||||
.into_iter()
|
||||
.map(|event| sse_json("progress", &event));
|
||||
|
||||
let live = BroadcastStream::new(subscription.live).map(|result| match result {
|
||||
Ok(event) => sse_json("progress", &event),
|
||||
Err(BroadcastStreamRecvError::Lagged(skipped)) => sse_gap(skipped),
|
||||
});
|
||||
|
||||
let stream = stream::iter(history).chain(live).take_until(shutdown);
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
fn sse_json<T: serde::Serialize>(name: &'static str, payload: &T) -> Result<Event, axum::Error> {
|
||||
Event::default().event(name).json_data(payload)
|
||||
}
|
||||
|
||||
fn sse_gap(skipped: u64) -> Result<Event, axum::Error> {
|
||||
Ok(Event::default().event("gap").data(skipped.to_string()))
|
||||
}
|
||||
|
||||
#[instrument(skip(app_state), fields(webhook_type), err)]
|
||||
@@ -73,10 +155,19 @@ async fn webhook(
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = app_state.queue.push(wb).await {
|
||||
metrics::webhook_enqueue_failed(event_type);
|
||||
return Err(AppError::EnqueueErr(anyhow::Error::from(err)));
|
||||
}
|
||||
let job = match app_state.queue.push(wb).await {
|
||||
Ok(job) => job,
|
||||
Err(err) => {
|
||||
metrics::webhook_enqueue_failed(event_type);
|
||||
return Err(AppError::EnqueueErr(anyhow::Error::from(err)));
|
||||
}
|
||||
};
|
||||
|
||||
app_state.event_bus.publish(JobEvent::Queued {
|
||||
id: job.id,
|
||||
repo: job.repo,
|
||||
pr: job.pr,
|
||||
});
|
||||
|
||||
Ok((StatusCode::CREATED, "Task started"))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user