Files
steep/crates/steep-server/src/job_queue.rs
T
qpismont 7493de6952
ci/woodpecker/push/tests Pipeline was successful
Add live dashboard with SSE job events
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.
2026-10-13 16:14:48 +00:00

196 lines
5.3 KiB
Rust

use std::{collections::VecDeque, time::Duration};
use futures_util::stream::{self, Stream};
use toasty::Db;
use tracing::error;
use crate::{
gitea::WebhookType,
schemas::{Job, JobStatus},
};
/// Délai entre deux interrogations de la table quand la file est vide.
const POLL_INTERVAL: Duration = Duration::from_secs(1);
/// File des reviews à faire.
#[derive(Clone)]
pub struct JobQueue {
db: Db,
}
impl JobQueue {
pub fn new(db: Db) -> Self {
Self { db }
}
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();
let job = toasty::create!(Job {
event_id,
repo,
pr,
payload: webhook,
status: JobStatus::Pending,
})
.exec(&mut db)
.await?;
Ok(job)
}
pub fn pending(&self) -> impl Stream<Item = Job> {
let db = self.db.clone();
stream::unfold(VecDeque::new(), move |mut buffer| {
let db = db.clone();
async move {
let mut db = db;
loop {
if let Some(job) = buffer.pop_front() {
return Some((job, buffer));
}
match Job::filter_by_status(JobStatus::Pending)
.exec(&mut db)
.await
{
Ok(jobs) if jobs.is_empty() => {
tokio::time::sleep(POLL_INTERVAL).await;
}
Ok(jobs) => buffer.extend(jobs),
Err(err) => {
error!(%err, "failed to poll pending jobs");
tokio::time::sleep(POLL_INTERVAL).await;
}
}
}
}
})
}
/// Marque un job comme en cours de traitement.
pub async fn mark_processing(&self, id: u64) -> toasty::Result<()> {
let mut db = self.db.clone();
Job::update_by_id(id)
.status(JobStatus::Processing)
.exec(&mut db)
.await?;
Ok(())
}
/// Retire un job de la file, une fois la review enregistrée.
pub async fn remove(&self, id: u64) -> toasty::Result<()> {
let mut db = self.db.clone();
Job::delete_by_id(&mut db, id).await?;
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)]
mod tests {
use std::time::Duration;
use futures_util::StreamExt;
use super::*;
use crate::gitea::{Comment, PullRequest, Repository, ReviewPayload};
fn webhook(event_id: u64) -> WebhookType {
WebhookType::Review(ReviewPayload {
action: "created".to_string(),
pull_request: PullRequest {
number: 1,
title: "Test".to_string(),
},
repository: Repository {
full_name: "owner/repo".to_string(),
clone_url: "https://example.com/owner/repo.git".to_string(),
},
comment: Comment {
id: event_id,
body: "@bot review".to_string(),
},
})
}
async fn next_job(queue: &JobQueue, event_id: u64) -> Job {
let mut pending = std::pin::pin!(queue.pending());
tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let Some(job) = pending.next().await
&& job.event_id == event_id
{
break job;
}
}
})
.await
.expect("timed out waiting for the job")
}
#[tokio::test]
#[ignore = "requires DATABASE_URL"]
async fn a_pushed_job_is_pending_then_removed() {
let db = crate::db::test_connect().await;
let queue = JobQueue::new(db.clone());
let event_id = 999_000_201;
Job::delete_by_event_id(&mut db.clone(), event_id)
.await
.ok();
queue.push(webhook(event_id)).await.unwrap();
let job = next_job(&queue, event_id).await;
queue.mark_processing(job.id).await.unwrap();
queue.remove(job.id).await.unwrap();
assert!(
Job::filter_by_event_id(event_id)
.first()
.exec(&mut db.clone())
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
#[ignore = "requires DATABASE_URL"]
async fn a_duplicate_event_id_is_rejected() {
let db = crate::db::test_connect().await;
let queue = JobQueue::new(db.clone());
let event_id = 999_000_202;
Job::delete_by_event_id(&mut db.clone(), event_id)
.await
.ok();
queue.push(webhook(event_id)).await.unwrap();
assert!(queue.push(webhook(event_id)).await.is_err());
Job::delete_by_event_id(&mut db.clone(), event_id)
.await
.unwrap();
}
}