Add live dashboard with SSE job events
ci/woodpecker/push/tests Pipeline was successful

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:
qpismont committed 2026-10-13 16:14:48 +00:00
1 parent 8c763786c1
commit 7493de6952
32 files changed
+1681 -61

No files matched your search

+1
View File
@@ -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
View File
@@ -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"))
}
+22 -4
View File
@@ -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
}
+14 -1
View File
@@ -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;
+7
View File
@@ -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)]
+17
View File
@@ -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()
}
+335
View File
@@ -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());
}
}
+10 -11
View File
@@ -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)]
+8 -3
View File
@@ -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
)
} => {
+6 -11
View File
@@ -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");
+4 -2
View File
@@ -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]
+2 -1
View File
@@ -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]
+6 -1
View File
@@ -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,
}