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
@@ -7,6 +7,7 @@ edition = "2024"
|
||||
reqwest = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
tokio-stream = { workspace = true, features = ["sync"] }
|
||||
futures-util = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
+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"))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use tracing::{error, info, instrument};
|
||||
use crate::{
|
||||
bot_actions::review::ReviewOutcome,
|
||||
dedup::{Dedup, ReviewRecord},
|
||||
event_bus::{EventBus, Reporter},
|
||||
gitea::{GiteaAPI, WebhookType},
|
||||
job_queue::JobQueue,
|
||||
metrics,
|
||||
@@ -134,9 +135,11 @@ pub struct Bot {
|
||||
sandbox: SandboxConfig,
|
||||
dedup: Dedup,
|
||||
queue: JobQueue,
|
||||
event_bus: EventBus,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
gitea_api: GiteaAPI,
|
||||
open_router_client: OpenRouterClient,
|
||||
@@ -145,6 +148,7 @@ impl Bot {
|
||||
sandbox: SandboxConfig,
|
||||
dedup: Dedup,
|
||||
queue: JobQueue,
|
||||
event_bus: EventBus,
|
||||
) -> Self {
|
||||
Self {
|
||||
gitea_api,
|
||||
@@ -154,6 +158,7 @@ impl Bot {
|
||||
sandbox,
|
||||
dedup,
|
||||
queue,
|
||||
event_bus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +219,7 @@ impl Bot {
|
||||
Ok(true) => {
|
||||
metrics::task_skipped(event_type);
|
||||
info!("Review already done, skipping");
|
||||
self.event_bus.skip(job.id, &job.repo, job.pr);
|
||||
|
||||
if let Err(err) = self.queue.remove(job.id).await {
|
||||
error!(%err, "failed to remove job");
|
||||
@@ -224,9 +230,11 @@ impl Bot {
|
||||
Err(err) => error!(%err, "Dedup lookup failed, reviewing anyway"),
|
||||
}
|
||||
|
||||
let reporter = self.event_bus.start(job.id, job.repo.clone(), job.pr);
|
||||
|
||||
let payload = job.payload.0.clone();
|
||||
let started = std::time::Instant::now();
|
||||
let result = self.exec(payload).await;
|
||||
let result = self.exec(payload, &reporter).await;
|
||||
let duration_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
let (ok, outcome) = match result {
|
||||
@@ -243,6 +251,8 @@ impl Bot {
|
||||
}
|
||||
};
|
||||
|
||||
let comments = outcome.comments as u64;
|
||||
|
||||
let record = ReviewRecord {
|
||||
event_id: job.event_id,
|
||||
repo: job.repo.clone(),
|
||||
@@ -251,7 +261,7 @@ impl Bot {
|
||||
model: self.open_router_model.clone(),
|
||||
markdown: outcome.markdown,
|
||||
cost: outcome.cost,
|
||||
comments: outcome.comments as u64,
|
||||
comments,
|
||||
duration_ms,
|
||||
};
|
||||
|
||||
@@ -259,13 +269,20 @@ impl Bot {
|
||||
error!(%err, "failed to record review");
|
||||
}
|
||||
|
||||
self.event_bus
|
||||
.finish(job.id, &job.repo, job.pr, ok, comments, duration_ms);
|
||||
|
||||
if let Err(err) = self.queue.remove(job.id).await {
|
||||
error!(%err, "failed to remove job");
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self, webhook), fields(repo, pr))]
|
||||
pub async fn exec(&self, webhook: WebhookType) -> anyhow::Result<ReviewOutcome> {
|
||||
#[instrument(skip(self, webhook, reporter), fields(repo, pr))]
|
||||
pub async fn exec(
|
||||
&self,
|
||||
webhook: WebhookType,
|
||||
reporter: &Reporter,
|
||||
) -> anyhow::Result<ReviewOutcome> {
|
||||
match &webhook {
|
||||
WebhookType::Review(p) => {
|
||||
tracing::Span::current().record("repo", &p.repository.full_name);
|
||||
@@ -284,6 +301,7 @@ impl Bot {
|
||||
&self.sandbox,
|
||||
tools,
|
||||
review_payload,
|
||||
reporter,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use tracing::{info, instrument, warn};
|
||||
use crate::{
|
||||
bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide},
|
||||
consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
|
||||
event_bus::Reporter,
|
||||
gitea::{GiteaAPI, PullRequestFile, ReviewPayload},
|
||||
metrics,
|
||||
open_router::{OpenRouterClient, Tool},
|
||||
@@ -28,7 +29,14 @@ impl ReviewOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(gitea_api, open_router_client, sandbox_config, tools, review_payload))]
|
||||
#[instrument(skip(
|
||||
gitea_api,
|
||||
open_router_client,
|
||||
sandbox_config,
|
||||
tools,
|
||||
review_payload,
|
||||
reporter
|
||||
))]
|
||||
pub async fn exec_review(
|
||||
gitea_api: &GiteaAPI,
|
||||
open_router_client: &OpenRouterClient,
|
||||
@@ -36,6 +44,7 @@ pub async fn exec_review(
|
||||
sandbox_config: &SandboxConfig,
|
||||
tools: Vec<Tool>,
|
||||
review_payload: ReviewPayload,
|
||||
reporter: &Reporter,
|
||||
) -> anyhow::Result<ReviewOutcome> {
|
||||
tracing::info!(
|
||||
repo = %review_payload.repository.full_name,
|
||||
@@ -86,6 +95,7 @@ pub async fn exec_review(
|
||||
tools,
|
||||
&review_payload,
|
||||
&bot_request,
|
||||
reporter,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -144,12 +154,14 @@ async fn run_sandboxed_review(
|
||||
tools: Vec<Tool>,
|
||||
review_payload: &ReviewPayload,
|
||||
bot_request: &str,
|
||||
reporter: &Reporter,
|
||||
) -> anyhow::Result<(ReviewResult, Option<f64>)> {
|
||||
let sandbox = Sandbox::create(
|
||||
&sandbox_config.runtime,
|
||||
&review_payload.repository.clone_url,
|
||||
gitea_api.token(),
|
||||
review_payload.pull_request.number,
|
||||
reporter,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -160,6 +172,7 @@ async fn run_sandboxed_review(
|
||||
SANDBOX_SYSTEM_PROMPT,
|
||||
bot_request,
|
||||
sandbox_config.max_iterations,
|
||||
reporter,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -70,6 +70,13 @@ impl Dedup {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_reviews(&self) -> toasty::Result<Vec<Review>> {
|
||||
let mut db = self.db.clone();
|
||||
let reviews = Review::all().exec(&mut db).await?;
|
||||
|
||||
Ok(reviews)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use axum::response::IntoResponse;
|
||||
use reqwest::StatusCode;
|
||||
use sentry::capture_error;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum AppError {
|
||||
@@ -27,9 +28,15 @@ pub enum AppError {
|
||||
#[error("Already processed")]
|
||||
AlreadyProcessedErr,
|
||||
|
||||
#[error("Job {0} is not running")]
|
||||
JobNotFoundErr(u64),
|
||||
|
||||
#[error(transparent)]
|
||||
BadJsonStructErr(#[from] serde_json::Error),
|
||||
|
||||
#[error("Other toasty error")]
|
||||
OtherToastyErr(#[from] toasty::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
@@ -61,6 +68,9 @@ impl IntoResponse for AppError {
|
||||
"WebHook sig header is invalid".to_string(),
|
||||
),
|
||||
AppError::AlreadyProcessedErr => (StatusCode::OK, "Already processed".to_string()),
|
||||
AppError::JobNotFoundErr(id) => {
|
||||
(StatusCode::NOT_FOUND, format!("job {id} is not running"))
|
||||
}
|
||||
AppError::EnqueueErr(err) => {
|
||||
sentry_anyhow::capture_anyhow(&err);
|
||||
(
|
||||
@@ -75,6 +85,13 @@ impl IntoResponse for AppError {
|
||||
"Internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
AppError::OtherToastyErr(err) => {
|
||||
capture_error(&err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::text::excerpt;
|
||||
|
||||
const LIFECYCLE_CAPACITY: usize = 256;
|
||||
const PROGRESS_CAPACITY: usize = 256;
|
||||
const HISTORY_CAPACITY: usize = 256;
|
||||
const MAX_EVENT_ARGUMENTS: usize = 200;
|
||||
|
||||
/// Évènement de cycle de vie d'un job, diffusé globalement.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum JobEvent {
|
||||
Queued {
|
||||
id: u64,
|
||||
repo: String,
|
||||
pr: u64,
|
||||
},
|
||||
Started {
|
||||
id: u64,
|
||||
repo: String,
|
||||
pr: u64,
|
||||
},
|
||||
ReviewDone {
|
||||
id: u64,
|
||||
repo: String,
|
||||
pr: u64,
|
||||
ok: bool,
|
||||
comments: u64,
|
||||
duration_ms: u64,
|
||||
},
|
||||
Failed {
|
||||
id: u64,
|
||||
repo: String,
|
||||
pr: u64,
|
||||
},
|
||||
Skipped {
|
||||
id: u64,
|
||||
repo: String,
|
||||
pr: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Progress {
|
||||
ToolCall {
|
||||
iteration: usize,
|
||||
name: String,
|
||||
arguments: String,
|
||||
},
|
||||
ToolResult {
|
||||
iteration: usize,
|
||||
name: String,
|
||||
truncated: bool,
|
||||
output: String,
|
||||
},
|
||||
Finished {
|
||||
ok: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct JobChannel {
|
||||
tx: broadcast::Sender<Progress>,
|
||||
history: Mutex<VecDeque<Progress>>,
|
||||
}
|
||||
|
||||
impl JobChannel {
|
||||
fn subscribe(&self) -> (Vec<Progress>, broadcast::Receiver<Progress>) {
|
||||
let history = self.history.lock().expect("job history lock poisoned");
|
||||
let rx = self.tx.subscribe();
|
||||
|
||||
(history.iter().cloned().collect(), rx)
|
||||
}
|
||||
|
||||
fn emit(&self, event: Progress) {
|
||||
let mut history = self.history.lock().expect("job history lock poisoned");
|
||||
|
||||
if history.len() >= HISTORY_CAPACITY {
|
||||
history.pop_front();
|
||||
}
|
||||
|
||||
let _ = self.tx.send(event.clone());
|
||||
history.push_back(event);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JobSubscription {
|
||||
pub history: Vec<Progress>,
|
||||
pub live: broadcast::Receiver<Progress>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EventBus {
|
||||
lifecycle: broadcast::Sender<JobEvent>,
|
||||
jobs: Arc<RwLock<HashMap<u64, Arc<JobChannel>>>>,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
pub fn new() -> Self {
|
||||
let (lifecycle, _rx) = broadcast::channel(LIFECYCLE_CAPACITY);
|
||||
|
||||
Self {
|
||||
lifecycle,
|
||||
jobs: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publish(&self, event: JobEvent) {
|
||||
let _ = self.lifecycle.send(event);
|
||||
}
|
||||
|
||||
pub fn subscribe_lifecycle(&self) -> broadcast::Receiver<JobEvent> {
|
||||
self.lifecycle.subscribe()
|
||||
}
|
||||
|
||||
pub fn start(&self, id: u64, repo: impl Into<String>, pr: u64) -> Reporter {
|
||||
let (tx, _rx) = broadcast::channel(PROGRESS_CAPACITY);
|
||||
let channel = Arc::new(JobChannel {
|
||||
tx,
|
||||
history: Mutex::new(VecDeque::new()),
|
||||
});
|
||||
|
||||
self.jobs
|
||||
.write()
|
||||
.expect("job registry lock poisoned")
|
||||
.insert(id, channel.clone());
|
||||
|
||||
self.publish(JobEvent::Started {
|
||||
id,
|
||||
repo: repo.into(),
|
||||
pr,
|
||||
});
|
||||
|
||||
Reporter { channel }
|
||||
}
|
||||
|
||||
pub fn finish(&self, id: u64, repo: &str, pr: u64, ok: bool, comments: u64, duration_ms: u64) {
|
||||
let event = if ok {
|
||||
JobEvent::ReviewDone {
|
||||
id,
|
||||
repo: repo.to_string(),
|
||||
pr,
|
||||
ok,
|
||||
comments,
|
||||
duration_ms,
|
||||
}
|
||||
} else {
|
||||
JobEvent::Failed {
|
||||
id,
|
||||
repo: repo.to_string(),
|
||||
pr,
|
||||
}
|
||||
};
|
||||
|
||||
self.publish(event);
|
||||
|
||||
if let Some(channel) = self
|
||||
.jobs
|
||||
.write()
|
||||
.expect("job registry lock poisoned")
|
||||
.remove(&id)
|
||||
{
|
||||
let _ = channel.tx.send(Progress::Finished { ok });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn skip(&self, id: u64, repo: &str, pr: u64) {
|
||||
self.publish(JobEvent::Skipped {
|
||||
id,
|
||||
repo: repo.to_string(),
|
||||
pr,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn subscribe_job(&self, id: u64) -> Option<JobSubscription> {
|
||||
let channel = self
|
||||
.jobs
|
||||
.read()
|
||||
.expect("job registry lock poisoned")
|
||||
.get(&id)
|
||||
.cloned()?;
|
||||
|
||||
let (history, live) = channel.subscribe();
|
||||
|
||||
Some(JobSubscription { history, live })
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EventBus {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Reporter {
|
||||
channel: Arc<JobChannel>,
|
||||
}
|
||||
|
||||
impl Reporter {
|
||||
pub fn tool_call(&self, iteration: usize, name: &str, arguments: &str) {
|
||||
self.channel.emit(Progress::ToolCall {
|
||||
iteration,
|
||||
name: name.to_string(),
|
||||
arguments: excerpt(arguments, MAX_EVENT_ARGUMENTS),
|
||||
});
|
||||
}
|
||||
|
||||
/// Signale la fin d'un appel d'outil.
|
||||
pub fn tool_result(&self, iteration: usize, name: &str, truncated: bool, output: &str) {
|
||||
self.channel.emit(Progress::ToolResult {
|
||||
iteration,
|
||||
name: name.to_string(),
|
||||
truncated,
|
||||
output: output.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_started_job_is_announced_and_subscribable() {
|
||||
let bus = EventBus::new();
|
||||
let mut lifecycle = bus.subscribe_lifecycle();
|
||||
|
||||
let _reporter = bus.start(1, "owner/repo", 7);
|
||||
|
||||
assert!(matches!(
|
||||
lifecycle.try_recv().unwrap(),
|
||||
JobEvent::Started { id: 1, pr: 7, .. }
|
||||
));
|
||||
assert!(bus.subscribe_job(1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_emitted_before_subscribing_is_replayed() {
|
||||
let bus = EventBus::new();
|
||||
let reporter = bus.start(1, "owner/repo", 7);
|
||||
|
||||
reporter.tool_call(1, "ls", "{}");
|
||||
reporter.tool_result(1, "ls", false, "src\nmain.rs");
|
||||
|
||||
let subscription = bus.subscribe_job(1).unwrap();
|
||||
|
||||
assert_eq!(subscription.history.len(), 2);
|
||||
assert!(matches!(
|
||||
subscription.history[0],
|
||||
Progress::ToolCall { iteration: 1, .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
&subscription.history[1],
|
||||
Progress::ToolResult { name, .. } if name == "ls"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_emitted_after_subscribing_arrives_live() {
|
||||
let bus = EventBus::new();
|
||||
let reporter = bus.start(1, "owner/repo", 7);
|
||||
let mut subscription = bus.subscribe_job(1).unwrap();
|
||||
|
||||
reporter.tool_call(2, "grep", r#"{"pattern":"fn main"}"#);
|
||||
|
||||
assert!(matches!(
|
||||
subscription.live.try_recv().unwrap(),
|
||||
Progress::ToolCall { iteration: 2, name, .. } if name == "grep"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_closes_the_channel_and_announces_the_review() {
|
||||
let bus = EventBus::new();
|
||||
let mut lifecycle = bus.subscribe_lifecycle();
|
||||
let _reporter = bus.start(1, "owner/repo", 7);
|
||||
let mut subscription = bus.subscribe_job(1).unwrap();
|
||||
|
||||
bus.finish(1, "owner/repo", 7, true, 3, 120);
|
||||
|
||||
assert!(bus.subscribe_job(1).is_none());
|
||||
assert!(matches!(
|
||||
subscription.live.try_recv().unwrap(),
|
||||
Progress::Finished { ok: true }
|
||||
));
|
||||
|
||||
let _started = lifecycle.try_recv().unwrap();
|
||||
assert!(matches!(
|
||||
lifecycle.try_recv().unwrap(),
|
||||
JobEvent::ReviewDone {
|
||||
id: 1,
|
||||
ok: true,
|
||||
comments: 3,
|
||||
duration_ms: 120,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_job_is_announced_as_failed() {
|
||||
let bus = EventBus::new();
|
||||
let mut lifecycle = bus.subscribe_lifecycle();
|
||||
let _reporter = bus.start(1, "owner/repo", 7);
|
||||
|
||||
bus.finish(1, "owner/repo", 7, false, 0, 42);
|
||||
|
||||
let _started = lifecycle.try_recv().unwrap();
|
||||
assert!(matches!(
|
||||
lifecycle.try_recv().unwrap(),
|
||||
JobEvent::Failed { id: 1, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_skipped_job_is_announced_without_a_channel() {
|
||||
let bus = EventBus::new();
|
||||
let mut lifecycle = bus.subscribe_lifecycle();
|
||||
|
||||
bus.skip(1, "owner/repo", 7);
|
||||
|
||||
assert!(matches!(
|
||||
lifecycle.try_recv().unwrap(),
|
||||
JobEvent::Skipped { id: 1, pr: 7, .. }
|
||||
));
|
||||
assert!(bus.subscribe_job(1).is_none());
|
||||
}
|
||||
}
|
||||
@@ -23,17 +23,14 @@ impl JobQueue {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Enfile une nouvelle review.
|
||||
///
|
||||
/// L'unicité de `event_id` fait échouer l'insertion d'un doublon.
|
||||
pub async fn push(&self, webhook: WebhookType) -> toasty::Result<()> {
|
||||
pub async fn push(&self, webhook: WebhookType) -> toasty::Result<Job> {
|
||||
let event_id = webhook.event_id();
|
||||
let repo = webhook.repository_full_name().to_string();
|
||||
let pr = webhook.pull_request_number();
|
||||
|
||||
let mut db = self.db.clone();
|
||||
|
||||
toasty::create!(Job {
|
||||
let job = toasty::create!(Job {
|
||||
event_id,
|
||||
repo,
|
||||
pr,
|
||||
@@ -43,14 +40,9 @@ impl JobQueue {
|
||||
.exec(&mut db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Flux des jobs en attente.
|
||||
///
|
||||
/// C'est un `Stream` (et non un `Iterator`) : l'accès à la base est
|
||||
/// asynchrone. Le flux interroge la table en boucle et met en tampon les
|
||||
/// jobs trouvés.
|
||||
pub fn pending(&self) -> impl Stream<Item = Job> {
|
||||
let db = self.db.clone();
|
||||
|
||||
@@ -103,6 +95,13 @@ impl JobQueue {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list(&self, status: JobStatus) -> toasty::Result<Vec<Job>> {
|
||||
let mut db = self.db.clone();
|
||||
let jobs = Job::filter_by_status(status).exec(&mut db).await?;
|
||||
|
||||
Ok(jobs)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
bot::Bot, dedup::Dedup, gitea::GiteaAPI, job_queue::JobQueue, open_router::OpenRouterClient,
|
||||
sandbox::SandboxConfig, state::AppState,
|
||||
bot::Bot, dedup::Dedup, event_bus::EventBus, gitea::GiteaAPI, job_queue::JobQueue,
|
||||
open_router::OpenRouterClient, sandbox::SandboxConfig, state::AppState,
|
||||
};
|
||||
|
||||
use dotenvy::dotenv;
|
||||
@@ -19,6 +19,7 @@ mod db;
|
||||
mod dedup;
|
||||
mod env;
|
||||
mod errors;
|
||||
mod event_bus;
|
||||
mod gitea;
|
||||
mod job_queue;
|
||||
mod metrics;
|
||||
@@ -126,6 +127,7 @@ async fn run() -> anyhow::Result<()> {
|
||||
let db = db::connect(&config.database_url).await?;
|
||||
let dedup = Dedup::new(db.clone());
|
||||
let queue = JobQueue::new(db);
|
||||
let event_bus = EventBus::new();
|
||||
|
||||
let bot = Bot::new(
|
||||
gitea_api,
|
||||
@@ -135,13 +137,16 @@ async fn run() -> anyhow::Result<()> {
|
||||
sandbox,
|
||||
dedup.clone(),
|
||||
queue.clone(),
|
||||
event_bus.clone(),
|
||||
);
|
||||
|
||||
let app_state = AppState {
|
||||
queue,
|
||||
dedup,
|
||||
event_bus,
|
||||
bot_name: gitea_user.login.clone(),
|
||||
config,
|
||||
shutdown: shutdown.clone(),
|
||||
};
|
||||
|
||||
let signal = async {
|
||||
@@ -166,7 +171,7 @@ async fn run() -> anyhow::Result<()> {
|
||||
result = async {
|
||||
tokio::try_join!(
|
||||
bot.start(shutdown.clone()),
|
||||
api::start(app_state, shutdown.clone()),
|
||||
api::start(app_state),
|
||||
signal
|
||||
)
|
||||
} => {
|
||||
|
||||
@@ -13,6 +13,7 @@ use serde_json::Value;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
event_bus::Reporter,
|
||||
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice},
|
||||
sandbox::{Sandbox, tools},
|
||||
text::{excerpt, truncate_bytes},
|
||||
@@ -61,17 +62,6 @@ pub struct AgentResult<A> {
|
||||
pub iterations: usize,
|
||||
}
|
||||
|
||||
/// Runs the tool-calling loop until the model answers or `max_iterations` is
|
||||
/// reached.
|
||||
///
|
||||
/// `tool_definitions` is the set of tools the model may call; it is selected by
|
||||
/// the caller based on the webhook action (see [`tools::for_webhook`]).
|
||||
///
|
||||
/// The answer of the model is parsed into `A`, which is how the caller states the
|
||||
/// format it expects: while [`FromStr`] rejects the answer, the error is sent back
|
||||
/// to the model so that it can correct itself, which costs an iteration. The
|
||||
/// parsing happens here because that is where the conversation and the remaining
|
||||
/// budget are at hand.
|
||||
pub async fn run<A>(
|
||||
open_router: &OpenRouterClient,
|
||||
sandbox: &Sandbox,
|
||||
@@ -79,6 +69,7 @@ pub async fn run<A>(
|
||||
system_prompt: &str,
|
||||
user_prompt: &str,
|
||||
max_iterations: usize,
|
||||
reporter: &Reporter,
|
||||
) -> anyhow::Result<AgentResult<A>>
|
||||
where
|
||||
A: FromStr,
|
||||
@@ -177,9 +168,13 @@ where
|
||||
));
|
||||
|
||||
for call in &response.tool_calls {
|
||||
reporter.tool_call(iteration, call.name(), call.arguments_json());
|
||||
|
||||
let started = Instant::now();
|
||||
let (content, truncated) = execute(sandbox, call).await;
|
||||
|
||||
reporter.tool_result(iteration, call.name(), truncated, &content);
|
||||
|
||||
info!(
|
||||
iteration,
|
||||
tool = call.name(),
|
||||
|
||||
@@ -11,6 +11,8 @@ use devcontainer_rs::{Container, ContainerRuntime, ExecOutput};
|
||||
use tempfile::TempDir;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::event_bus::Reporter;
|
||||
|
||||
/// Devcontainer locations recognized within a repository, in priority order.
|
||||
const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
|
||||
|
||||
@@ -43,6 +45,7 @@ impl Sandbox {
|
||||
clone_url: &str,
|
||||
token: &str,
|
||||
pull_request_number: u64,
|
||||
reporter: &Reporter,
|
||||
) -> anyhow::Result<Self> {
|
||||
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
|
||||
let repo_dir = workspace.path().join("repo");
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
use jiff::Timestamp;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::gitea::WebhookType;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, toasty::Embed, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum JobStatus {
|
||||
Pending,
|
||||
Processing,
|
||||
}
|
||||
|
||||
#[derive(Debug, toasty::Model)]
|
||||
#[derive(Debug, toasty::Model, Clone, Serialize)]
|
||||
pub struct Job {
|
||||
#[key]
|
||||
#[auto]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use jiff::Timestamp;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, toasty::Model)]
|
||||
#[derive(Debug, toasty::Model, Serialize, Clone)]
|
||||
pub struct Review {
|
||||
#[key]
|
||||
#[auto]
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use crate::{dedup::Dedup, env::EnvConfig, job_queue::JobQueue};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{dedup::Dedup, env::EnvConfig, event_bus::EventBus, job_queue::JobQueue};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub queue: JobQueue,
|
||||
pub dedup: Dedup,
|
||||
pub event_bus: EventBus,
|
||||
pub bot_name: String,
|
||||
pub config: EnvConfig,
|
||||
/// Jeton annulé au démarrage de l'arrêt gracieux : il sert à clore les flux SSE.
|
||||
pub shutdown: CancellationToken,
|
||||
}
|
||||
Reference in new issue
Block a user