Postgres persistance + job queue + api with sse #9

Merged
qpismont merged 8 commits from 1.3 into main 2026-10-26 21:19:32 +01:00
32 changed files with 1681 additions and 61 deletions
Showing only changes of commit 7493de6952 - Show all commits

No files matched your search

+3
View File
@@ -19,6 +19,9 @@
}, },
"workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/steep,type=bind", "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/steep,type=bind",
"workspaceFolder": "/workspaces/steep", "workspaceFolder": "/workspaces/steep",
"mounts": [
"source=/run/user/1000/podman/podman.sock,target=/var/run/docker.sock,type=bind"
Review

[maintainability] Le socket podman est monté avec le chemin en dur /run/user/1000/podman/podman.sock. Le devcontainer ne démarre pas pour un utilisateur dont l'UID n'est pas 1000, alors que le build utilise ${localEnv:UID}. De plus, si le socket n'existe pas, le montage fait échouer l'ouverture du conteneur. Il faut utiliser ${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock.

**[maintainability]** Le socket podman est monté avec le chemin en dur `/run/user/1000/podman/podman.sock`. Le devcontainer ne démarre pas pour un utilisateur dont l'UID n'est pas 1000, alors que le build utilise `${localEnv:UID}`. De plus, si le socket n'existe pas, le montage fait échouer l'ouverture du conteneur. Il faut utiliser `${localEnv:XDG_RUNTIME_DIR}/podman/podman.sock`.
],
"runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"],
"appPort": [3000] "appPort": [3000]
} }
Generated
+33
View File
@@ -1747,6 +1747,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -2536,6 +2542,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "minimal-lexical" name = "minimal-lexical"
version = "0.2.1" version = "0.2.1"
@@ -4274,6 +4290,7 @@ dependencies = [
"toasty", "toasty",
"toasty-cli", "toasty-cli",
"tokio", "tokio",
"tokio-stream",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http 0.7.1", "tower-http 0.7.1",
@@ -4773,6 +4790,7 @@ dependencies = [
"futures-core", "futures-core",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
"tokio-util",
] ]
[[package]] [[package]]
@@ -4885,10 +4903,19 @@ checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"bytes", "bytes",
"futures-core",
"futures-util",
"http 1.5.0", "http 1.5.0",
"http-body", "http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -5211,6 +5238,12 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "unicase"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
+2 -2
View File
@@ -17,7 +17,7 @@ sentry = { version = "0.49", features = ["tower-axum-matched-path"] }
sentry-anyhow = { version = "0.49", features = ["backtrace"] } sentry-anyhow = { version = "0.49", features = ["backtrace"] }
dotenvy = "0.15" dotenvy = "0.15"
tower = "0.5" tower = "0.5"
tower-http = { version = "0.7", features = ["trace"] } tower-http = { version = "0.7", features = ["trace", "fs"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
axum = "0.8" axum = "0.8"
@@ -30,7 +30,7 @@ metrics = "0.24"
metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] }
toasty = { version = "0.11", features = ["postgresql", "serde", "jiff"] } toasty = { version = "0.11", features = ["postgresql", "serde", "jiff"] }
toasty-cli = "0.11" toasty-cli = "0.11"
jiff = "0.2" jiff = { version = "0.2", features = ["serde"] }
[profile.release] [profile.release]
debug = 1 debug = 1
+6 -1
View File
@@ -23,7 +23,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
COPY --from=builder /app/target/release/steep-server ./steep-server COPY --from=builder /app/target/release/steep-server ./steep-server
COPY front ./front
COPY crates/steep-server/toasty ./toasty COPY crates/steep-server/toasty ./toasty
ENV MIGRATIONS_DIR=/app/toasty ENV MIGRATIONS_DIR=/app/toasty
CMD ["sh", "-c", "./steep-server migration apply && exec ./steep-server"] COPY scripts/migrate-apply.sh ./scripts/migrate-apply.sh
RUN chmod +x ./scripts/migrate-apply.sh
CMD ["sh", "-c", "./scripts/migrate-apply.sh && exec ./steep-server"]
+5 -3
View File
@@ -109,16 +109,18 @@ The database schema is versioned with [Toasty](https://github.com/tokio-rs/toast
After changing a model in `crates/steep-server/src/schemas/`, generate a migration: After changing a model in `crates/steep-server/src/schemas/`, generate a migration:
```sh ```sh
cargo run -p steep-server -- migration generate --name describe_change ./scripts/migrate-generate.sh describe_change
``` ```
Apply the pending migrations **before starting the service** — the server does not migrate at boot: Apply the pending migrations **before starting the service** — the server does not migrate at boot:
```sh ```sh
cargo run -p steep-server -- migration apply ./scripts/migrate-apply.sh
``` ```
The `Containerfile` does this at startup: its entrypoint runs `steep-server migration apply` before exec'ing the server. The CLI reads the migration files from `MIGRATIONS_DIR` (defaults to the crate's `toasty/` directory; the image sets it to `/app/toasty`). Both scripts load the `.env` file at the project root (so `DATABASE_URL` is picked up). Generation runs through `cargo run -p steep-server --`; applying uses the compiled `steep-server` binary when present (as in the image), falling back to `cargo run` otherwise.
The `Containerfile` does this at startup: its entrypoint runs `scripts/migrate-apply.sh` before exec'ing the server. The CLI reads the migration files from `MIGRATIONS_DIR` (defaults to the crate's `toasty/` directory; the image sets it to `/app/toasty`).
Other subcommands: `migration snapshot`, `migration drop`, `migration reset`. They read `DATABASE_URL`. Other subcommands: `migration snapshot`, `migration drop`, `migration reset`. They read `DATABASE_URL`.
+18 -6
View File
@@ -11,15 +11,22 @@
- If a repository has no Dev Container configuration, run its review in a default container based on `debian:stable-slim`. - If a repository has no Dev Container configuration, run its review in a default container based on `debian:stable-slim`.
- Use `/workspace` as the workspace path for the default container and as the fallback when `workspaceFolder` is not specified. - Use `/workspace` as the workspace path for the default container and as the fallback when `workspaceFolder` is not specified.
## 1.3 — Typed action routing — planned ## 1.3 — Durable jobs & resumption — planned
- Persist review jobs and their progress in PostgreSQL so in-flight work survives a restart and can be resumed instead of being lost.
- Expose an API to retrieve the list of jobs by state: running, pending, and review done.
- Stream job events over Server-Sent Events (SSE) so clients see status changes in real time.
## 1.4 — YAML configuration — planned
- Add a YAML configuration file as the primary way to configure Steep, replacing part of the current environment variables.
- Move non-secret settings such as the model, timeouts, concurrency limit, and sandbox iterations into the file, and keep secrets (API keys, tokens, webhook secret, database URL) in environment variables.
## 2.0 — Typed action routing and implementation requests — planned
- Introduce a type-safe model output for the action Steep should take, such as `Review` or `Implementation`. - Introduce a type-safe model output for the action Steep should take, such as `Review` or `Implementation`.
- Route review requests (for example, “@steep, review this PR”) to the existing review flow. - Route review requests (for example, “@steep, review this PR”) to the existing review flow.
- Recognize implementation requests (for example, “@steep, rename method `XXX` to `YYY`”) and establish their dispatch path. Execution of implementation requests is planned for 2.0. - Recognize implementation requests (for example, “@steep, rename method `XXX` to `YYY`”) and route them to the implementation workflow.
## 2.0 — Implementation requests — planned
- Use the typed action from 1.3 to route explicit implementation requests to an implementation workflow.
- For a request on an issue, clone the repository from its default branch and implement the requested features on a new branch. - For a request on an issue, clone the repository from its default branch and implement the requested features on a new branch.
- For a request on an existing pull request, clone its branch and implement the requested modifications there. - For a request on an existing pull request, clone its branch and implement the requested modifications there.
- When the agent's iteration loop ends, the orchestration layer automatically pushes the resulting changes: it opens a pull request for issue-based work, or updates the existing pull request branch. The agent does not receive or invoke a push tool. - When the agent's iteration loop ends, the orchestration layer automatically pushes the resulting changes: it opens a pull request for issue-based work, or updates the existing pull request branch. The agent does not receive or invoke a push tool.
@@ -31,6 +38,11 @@
- Allow configuring a dedicated model for pull request reviews and another for implementation requests. - Allow configuring a dedicated model for pull request reviews and another for implementation requests.
- Route each action type to its configured model, with a clear fallback when only one model is configured. - Route each action type to its configured model, with a clear fallback when only one model is configured.
## 2.2 — Sub-agents — planned
- Let the agent delegate well-scoped parts of a task to sub-agents that run their own iteration loop.
- Run independent sub-agents in parallel and have the main agent consolidate their results.
## 3.0 — Autonomous Sentry fixes — planned ## 3.0 — Autonomous Sentry fixes — planned
- Automatically discover project errors reported by Sentry, without waiting for a user comment. - Automatically discover project errors reported by Sentry, without waiting for a user comment.
+1
View File
@@ -7,6 +7,7 @@ edition = "2024"
reqwest = { workspace = true } reqwest = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-util = { workspace = true } tokio-util = { workspace = true }
tokio-stream = { workspace = true, features = ["sync"] }
futures-util = { workspace = true } futures-util = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
+104 -13
View File
@@ -1,31 +1,48 @@
use axum::body::{Bytes, to_bytes}; 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::http::Request;
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::{get, post}; use axum::routing::{get, post};
use axum::{Json, Router}; use axum::{Json, Router};
use futures_util::stream::{self, Stream, StreamExt};
use reqwest::StatusCode; use reqwest::StatusCode;
use ring::hmac; use ring::hmac;
use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer}; 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::ServiceBuilder;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing::{info, instrument}; use tracing::{info, instrument, warn};
use tokio_util::sync::CancellationToken;
use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE}; use crate::consts::{GITEA_EVENT_TYPE_HEADER_NAME, GITEA_SIG_HEADER_NAME, MAX_WEBHOOK_BODY_SIZE};
use crate::errors::AppError; use crate::errors::AppError;
use crate::event_bus::JobEvent;
use crate::gitea::WebhookType; use crate::gitea::WebhookType;
use crate::metrics; use crate::metrics;
use crate::schemas::JobStatus;
use crate::state::AppState; 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 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() let app = Router::new()
.route("/", get(root))
.route("/webhook", post(webhook)) .route("/webhook", post(webhook))
.route("/dedup", get(dedup_list))
Review

[security] Les routes /reviews, /jobs, /events et /jobs/{id}/events sont publiques, sans authentification. Elles exposent les noms de dépôts privés, les numéros de PR, le payload complet des webhooks (corps des commentaires, clone_url), le markdown des reviews et le flux des appels d'outils de l'agent. Ce flux contient des extraits du code des dépôts, ce qui est sensible. Le service tourne sur 0.0.0.0 et le dashboard est servi par le même processus. Il faut protéger ces routes (token ou basic auth, ou reverse proxy authentifié), ou au moins les placer sur un port interne distinct du /webhook. Il n'y a aussi aucune limite sur le nombre de connexions SSE ouvertes.

**[security]** Les routes `/reviews`, `/jobs`, `/events` et `/jobs/{id}/events` sont publiques, sans authentification. Elles exposent les noms de dépôts privés, les numéros de PR, le payload complet des webhooks (corps des commentaires, clone_url), le markdown des reviews et le flux des appels d'outils de l'agent. Ce flux contient des extraits du code des dépôts, ce qui est sensible. Le service tourne sur `0.0.0.0` et le dashboard est servi par le même processus. Il faut protéger ces routes (token ou basic auth, ou reverse proxy authentifié), ou au moins les placer sur un port interne distinct du `/webhook`. Il n'y a aussi aucune limite sur le nombre de connexions SSE ouvertes.
.route("/jobs", get(jobs_list))
qpismont marked this conversation as resolved
Review

[security] Ces routes de consultation et les flux SSE sont accessibles sans authentification. Elles exposent notamment les noms de dépôts, les reviews et les sorties des outils exécutés sur le code, qui peuvent contenir des données de dépôts privés. Protégez ces endpoints ou limitez explicitement leur accès.

**[security]** Ces routes de consultation et les flux SSE sont accessibles sans authentification. Elles exposent notamment les noms de dépôts, les reviews et les sorties des outils exécutés sur le code, qui peuvent contenir des données de dépôts privés. Protégez ces endpoints ou limitez explicitement leur accès.
.route("/events", get(lifecycle_events))
.route("/jobs/{id}/events", get(job_events))
.fallback_service(dashboard)
.layer( .layer(
ServiceBuilder::new() ServiceBuilder::new()
.layer(NewSentryLayer::<Request<_>>::new_from_top()) .layer(NewSentryLayer::<Request<_>>::new_from_top())
@@ -46,8 +63,73 @@ pub async fn start(app_state: AppState, shutdown: CancellationToken) -> anyhow::
Ok(()) Ok(())
} }
async fn root() -> &'static str { async fn dedup_list(State(app_state): State<AppState>) -> Result<impl IntoResponse, AppError> {
"Hi, I'm Steep :)" 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)] #[instrument(skip(app_state), fields(webhook_type), err)]
1
@@ -73,10 +155,19 @@ async fn webhook(
} }
} }
if let Err(err) = app_state.queue.push(wb).await { let job = match app_state.queue.push(wb).await {
Review

[bug] Le contrôle is_known puis queue.push n'est pas atomique. Deux livraisons concurrentes du même événement (retry Gitea) passent toutes les deux le contrôle. La seconde échoue sur la contrainte UNIQUE(event_id) et renvoie 503 Could not enqueue task, avec un faux événement Sentry et le compteur enqueue_failed. Gitea risque alors de re-livrer en boucle. Il faut traiter la violation d'unicité comme AlreadyProcessedErr (200), ou faire un insert avec ON CONFLICT DO NOTHING.

**[bug]** Le contrôle `is_known` puis `queue.push` n'est pas atomique. Deux livraisons concurrentes du même événement (retry Gitea) passent toutes les deux le contrôle. La seconde échoue sur la contrainte `UNIQUE(event_id)` et renvoie 503 `Could not enqueue task`, avec un faux événement Sentry et le compteur `enqueue_failed`. Gitea risque alors de re-livrer en boucle. Il faut traiter la violation d'unicité comme `AlreadyProcessedErr` (200), ou faire un insert avec `ON CONFLICT DO NOTHING`.
metrics::webhook_enqueue_failed(event_type); Ok(job) => job,
return Err(AppError::EnqueueErr(anyhow::Error::from(err))); 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")) Ok((StatusCode::CREATED, "Task started"))
} }
+22 -4
View File
@@ -9,6 +9,7 @@ use tracing::{error, info, instrument};
use crate::{ use crate::{
bot_actions::review::ReviewOutcome, bot_actions::review::ReviewOutcome,
dedup::{Dedup, ReviewRecord}, dedup::{Dedup, ReviewRecord},
event_bus::{EventBus, Reporter},
gitea::{GiteaAPI, WebhookType}, gitea::{GiteaAPI, WebhookType},
job_queue::JobQueue, job_queue::JobQueue,
metrics, metrics,
@@ -134,9 +135,11 @@ pub struct Bot {
sandbox: SandboxConfig, sandbox: SandboxConfig,
dedup: Dedup, dedup: Dedup,
queue: JobQueue, queue: JobQueue,
event_bus: EventBus,
} }
impl Bot { impl Bot {
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
gitea_api: GiteaAPI, gitea_api: GiteaAPI,
open_router_client: OpenRouterClient, open_router_client: OpenRouterClient,
@@ -145,6 +148,7 @@ impl Bot {
sandbox: SandboxConfig, sandbox: SandboxConfig,
dedup: Dedup, dedup: Dedup,
queue: JobQueue, queue: JobQueue,
event_bus: EventBus,
) -> Self { ) -> Self {
Self { Self {
gitea_api, gitea_api,
@@ -154,6 +158,7 @@ impl Bot {
sandbox, sandbox,
dedup, dedup,
queue, queue,
event_bus,
} }
} }
3
@@ -214,6 +219,7 @@ impl Bot {
Ok(true) => { Ok(true) => {
metrics::task_skipped(event_type); metrics::task_skipped(event_type);
info!("Review already done, skipping"); 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 { if let Err(err) = self.queue.remove(job.id).await {
error!(%err, "failed to remove job"); error!(%err, "failed to remove job");
@@ -224,9 +230,11 @@ impl Bot {
Err(err) => error!(%err, "Dedup lookup failed, reviewing anyway"), 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 payload = job.payload.0.clone();
let started = std::time::Instant::now(); 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 duration_ms = started.elapsed().as_millis() as u64;
let (ok, outcome) = match result { let (ok, outcome) = match result {
@@ -243,6 +251,8 @@ impl Bot {
} }
}; };
let comments = outcome.comments as u64;
let record = ReviewRecord { let record = ReviewRecord {
event_id: job.event_id, event_id: job.event_id,
repo: job.repo.clone(), repo: job.repo.clone(),
qpismont marked this conversation as resolved
Review

[bug] Même quand exec échoue, le code enregistre une Review avec ok = false, puis supprime le job. Un webhook de retry sera ensuite considéré comme déjà traité par is_reviewed et ignoré : une panne temporaire de Gitea, du sandbox ou d'OpenRouter devient donc un échec définitif sans possibilité de retry. Les jobs échoués devraient rester en file (avec backoff/retry) ou être distingués des reviews effectivement publiées.

**[bug]** Même quand `exec` échoue, le code enregistre une `Review` avec `ok = false`, puis supprime le job. Un webhook de retry sera ensuite considéré comme déjà traité par `is_reviewed` et ignoré : une panne temporaire de Gitea, du sandbox ou d'OpenRouter devient donc un échec définitif sans possibilité de retry. Les jobs échoués devraient rester en file (avec backoff/retry) ou être distingués des reviews effectivement publiées.
@@ -251,7 +261,7 @@ impl Bot {
model: self.open_router_model.clone(), model: self.open_router_model.clone(),
markdown: outcome.markdown, markdown: outcome.markdown,
qpismont marked this conversation as resolved
Review

[bug] Si mark_reviewed échoue, l’erreur est seulement journalisée, puis le job est supprimé. Une panne temporaire de la base peut donc faire perdre définitivement l’événement sans enregistrer son résultat. Ne supprimez le job qu’après l’enregistrement réussi, ou conservez-le pour reprise.

**[bug]** Si `mark_reviewed` échoue, l’erreur est seulement journalisée, puis le job est supprimé. Une panne temporaire de la base peut donc faire perdre définitivement l’événement sans enregistrer son résultat. Ne supprimez le job qu’après l’enregistrement réussi, ou conservez-le pour reprise.
cost: outcome.cost, cost: outcome.cost,
comments: outcome.comments as u64, comments,
duration_ms, duration_ms,
}; };
@@ -259,13 +269,20 @@ impl Bot {
error!(%err, "failed to record review"); 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 { if let Err(err) = self.queue.remove(job.id).await {
error!(%err, "failed to remove job"); error!(%err, "failed to remove job");
} }
} }
#[instrument(skip(self, webhook), fields(repo, pr))] #[instrument(skip(self, webhook, reporter), fields(repo, pr))]
pub async fn exec(&self, webhook: WebhookType) -> anyhow::Result<ReviewOutcome> { pub async fn exec(
&self,
webhook: WebhookType,
reporter: &Reporter,
) -> anyhow::Result<ReviewOutcome> {
match &webhook { match &webhook {
WebhookType::Review(p) => { WebhookType::Review(p) => {
tracing::Span::current().record("repo", &p.repository.full_name); tracing::Span::current().record("repo", &p.repository.full_name);
@@ -284,6 +301,7 @@ impl Bot {
&self.sandbox, &self.sandbox,
tools, tools,
review_payload, review_payload,
reporter,
) )
.await .await
} }
+14 -1
View File
@@ -5,6 +5,7 @@ use tracing::{info, instrument, warn};
use crate::{ use crate::{
bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide}, bot::{ReviewItem, ReviewResult, ReviewSeverity, ReviewSide},
consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT}, consts::{BOT_PROCESS_MSG, REVIEW_PROMPT, SANDBOX_SYSTEM_PROMPT},
event_bus::Reporter,
gitea::{GiteaAPI, PullRequestFile, ReviewPayload}, gitea::{GiteaAPI, PullRequestFile, ReviewPayload},
metrics, metrics,
open_router::{OpenRouterClient, Tool}, 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( pub async fn exec_review(
gitea_api: &GiteaAPI, gitea_api: &GiteaAPI,
open_router_client: &OpenRouterClient, open_router_client: &OpenRouterClient,
@@ -36,6 +44,7 @@ pub async fn exec_review(
sandbox_config: &SandboxConfig, sandbox_config: &SandboxConfig,
tools: Vec<Tool>, tools: Vec<Tool>,
review_payload: ReviewPayload, review_payload: ReviewPayload,
reporter: &Reporter,
) -> anyhow::Result<ReviewOutcome> { ) -> anyhow::Result<ReviewOutcome> {
tracing::info!( tracing::info!(
repo = %review_payload.repository.full_name, repo = %review_payload.repository.full_name,
@@ -86,6 +95,7 @@ pub async fn exec_review(
tools, tools,
&review_payload, &review_payload,
&bot_request, &bot_request,
reporter,
) )
.await?; .await?;
@@ -144,12 +154,14 @@ async fn run_sandboxed_review(
tools: Vec<Tool>, tools: Vec<Tool>,
review_payload: &ReviewPayload, review_payload: &ReviewPayload,
bot_request: &str, bot_request: &str,
reporter: &Reporter,
) -> anyhow::Result<(ReviewResult, Option<f64>)> { ) -> anyhow::Result<(ReviewResult, Option<f64>)> {
let sandbox = Sandbox::create( let sandbox = Sandbox::create(
&sandbox_config.runtime, &sandbox_config.runtime,
&review_payload.repository.clone_url, &review_payload.repository.clone_url,
gitea_api.token(), gitea_api.token(),
review_payload.pull_request.number, review_payload.pull_request.number,
reporter,
) )
.await?; .await?;
@@ -160,6 +172,7 @@ async fn run_sandboxed_review(
SANDBOX_SYSTEM_PROMPT, SANDBOX_SYSTEM_PROMPT,
bot_request, bot_request,
sandbox_config.max_iterations, sandbox_config.max_iterations,
reporter,
) )
.await; .await;
+7
View File
@@ -70,6 +70,13 @@ impl Dedup {
Ok(()) 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?;
qpismont marked this conversation as resolved
Review

[performance] Review::all() charge toutes les reviews et leurs champs, notamment le Markdown, à chaque rafraîchissement du dashboard. Cette requête et la réponse JSON grossissent sans limite avec l’historique. Ajoutez une pagination ou une limite et ne chargez que les champs nécessaires à la liste.

**[performance]** `Review::all()` charge toutes les reviews et leurs champs, notamment le Markdown, à chaque rafraîchissement du dashboard. Cette requête et la réponse JSON grossissent sans limite avec l’historique. Ajoutez une pagination ou une limite et ne chargez que les champs nécessaires à la liste.
Ok(reviews)
}
} }
#[cfg(test)] #[cfg(test)]
+17
View File
@@ -1,5 +1,6 @@
use axum::response::IntoResponse; use axum::response::IntoResponse;
use reqwest::StatusCode; use reqwest::StatusCode;
use sentry::capture_error;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum AppError { pub enum AppError {
@@ -27,9 +28,15 @@ pub enum AppError {
#[error("Already processed")] #[error("Already processed")]
AlreadyProcessedErr, AlreadyProcessedErr,
#[error("Job {0} is not running")]
JobNotFoundErr(u64),
#[error(transparent)] #[error(transparent)]
BadJsonStructErr(#[from] serde_json::Error), BadJsonStructErr(#[from] serde_json::Error),
#[error("Other toasty error")]
OtherToastyErr(#[from] toasty::Error),
#[error(transparent)] #[error(transparent)]
Other(#[from] anyhow::Error), Other(#[from] anyhow::Error),
} }
@@ -61,6 +68,9 @@ impl IntoResponse for AppError {
"WebHook sig header is invalid".to_string(), "WebHook sig header is invalid".to_string(),
), ),
AppError::AlreadyProcessedErr => (StatusCode::OK, "Already processed".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) => { AppError::EnqueueErr(err) => {
sentry_anyhow::capture_anyhow(&err); sentry_anyhow::capture_anyhow(&err);
( (
@@ -75,6 +85,13 @@ impl IntoResponse for AppError {
"Internal server error".to_string(), "Internal server error".to_string(),
) )
} }
AppError::OtherToastyErr(err) => {
capture_error(&err);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
)
}
} }
.into_response() .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(),
Review

[performance] Contrairement aux arguments (tronqués à 200 caractères), output est conservé en entier, jusqu'à 32 Ko par appel d'outil. L'historique garde 256 événements par job, soit environ 8 Mo par job actif. Chaque nouvel abonné SSE reçoit ensuite tout cet historique. Il faut tronquer l'aperçu envoyé au dashboard (comme pour MAX_EVENT_ARGUMENTS), ce qui limite aussi ce qui fuit sur une route publique.

**[performance]** Contrairement aux arguments (tronqués à 200 caractères), `output` est conservé en entier, jusqu'à 32 Ko par appel d'outil. L'historique garde 256 événements par job, soit environ 8 Mo par job actif. Chaque nouvel abonné SSE reçoit ensuite tout cet historique. Il faut tronquer l'aperçu envoyé au dashboard (comme pour `MAX_EVENT_ARGUMENTS`), ce qui limite aussi ce qui fuit sur une route publique.
});
}
}
#[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 } Self { db }
} }
/// Enfile une nouvelle review. pub async fn push(&self, webhook: WebhookType) -> toasty::Result<Job> {
///
/// L'unicité de `event_id` fait échouer l'insertion d'un doublon.
pub async fn push(&self, webhook: WebhookType) -> toasty::Result<()> {
let event_id = webhook.event_id(); let event_id = webhook.event_id();
let repo = webhook.repository_full_name().to_string(); let repo = webhook.repository_full_name().to_string();
let pr = webhook.pull_request_number(); let pr = webhook.pull_request_number();
let mut db = self.db.clone(); let mut db = self.db.clone();
toasty::create!(Job { let job = toasty::create!(Job {
event_id, event_id,
repo, repo,
pr, pr,
@@ -43,14 +40,9 @@ impl JobQueue {
.exec(&mut db) .exec(&mut db)
.await?; .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> { pub fn pending(&self) -> impl Stream<Item = Job> {
let db = self.db.clone(); let db = self.db.clone();
3
@@ -103,6 +95,13 @@ impl JobQueue {
Ok(()) 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)] #[cfg(test)]
+8 -3
View File
@@ -1,8 +1,8 @@
use std::time::Duration; use std::time::Duration;
use crate::{ use crate::{
bot::Bot, dedup::Dedup, gitea::GiteaAPI, job_queue::JobQueue, open_router::OpenRouterClient, bot::Bot, dedup::Dedup, event_bus::EventBus, gitea::GiteaAPI, job_queue::JobQueue,
sandbox::SandboxConfig, state::AppState, open_router::OpenRouterClient, sandbox::SandboxConfig, state::AppState,
}; };
use dotenvy::dotenv; use dotenvy::dotenv;
@@ -19,6 +19,7 @@ mod db;
mod dedup; mod dedup;
mod env; mod env;
mod errors; mod errors;
mod event_bus;
mod gitea; mod gitea;
mod job_queue; mod job_queue;
mod metrics; mod metrics;
@@ -126,6 +127,7 @@ async fn run() -> anyhow::Result<()> {
let db = db::connect(&config.database_url).await?; let db = db::connect(&config.database_url).await?;
let dedup = Dedup::new(db.clone()); let dedup = Dedup::new(db.clone());
let queue = JobQueue::new(db); let queue = JobQueue::new(db);
let event_bus = EventBus::new();
let bot = Bot::new( let bot = Bot::new(
gitea_api, gitea_api,
@@ -135,13 +137,16 @@ async fn run() -> anyhow::Result<()> {
sandbox, sandbox,
dedup.clone(), dedup.clone(),
queue.clone(), queue.clone(),
event_bus.clone(),
); );
let app_state = AppState { let app_state = AppState {
queue, queue,
dedup, dedup,
event_bus,
bot_name: gitea_user.login.clone(), bot_name: gitea_user.login.clone(),
config, config,
shutdown: shutdown.clone(),
}; };
let signal = async { let signal = async {
@@ -166,7 +171,7 @@ async fn run() -> anyhow::Result<()> {
result = async { result = async {
tokio::try_join!( tokio::try_join!(
bot.start(shutdown.clone()), bot.start(shutdown.clone()),
api::start(app_state, shutdown.clone()), api::start(app_state),
signal signal
) )
} => { } => {
+6 -11
View File
@@ -13,6 +13,7 @@ use serde_json::Value;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::{ use crate::{
event_bus::Reporter,
open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice}, open_router::{Message, OpenRouterClient, Role, Tool, ToolCall, ToolChoice},
sandbox::{Sandbox, tools}, sandbox::{Sandbox, tools},
text::{excerpt, truncate_bytes}, text::{excerpt, truncate_bytes},
@@ -61,17 +62,6 @@ pub struct AgentResult<A> {
pub iterations: usize, 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>( pub async fn run<A>(
open_router: &OpenRouterClient, open_router: &OpenRouterClient,
sandbox: &Sandbox, sandbox: &Sandbox,
@@ -79,6 +69,7 @@ pub async fn run<A>(
system_prompt: &str, system_prompt: &str,
user_prompt: &str, user_prompt: &str,
max_iterations: usize, max_iterations: usize,
reporter: &Reporter,
) -> anyhow::Result<AgentResult<A>> ) -> anyhow::Result<AgentResult<A>>
where where
A: FromStr, A: FromStr,
@@ -177,9 +168,13 @@ where
)); ));
for call in &response.tool_calls { for call in &response.tool_calls {
reporter.tool_call(iteration, call.name(), call.arguments_json());
let started = Instant::now(); let started = Instant::now();
let (content, truncated) = execute(sandbox, call).await; let (content, truncated) = execute(sandbox, call).await;
reporter.tool_result(iteration, call.name(), truncated, &content);
info!( info!(
iteration, iteration,
tool = call.name(), tool = call.name(),
@@ -11,6 +11,8 @@ use devcontainer_rs::{Container, ContainerRuntime, ExecOutput};
use tempfile::TempDir; use tempfile::TempDir;
use tracing::{info, instrument}; use tracing::{info, instrument};
use crate::event_bus::Reporter;
/// Devcontainer locations recognized within a repository, in priority order. /// Devcontainer locations recognized within a repository, in priority order.
const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"]; const DEVCONTAINER_PATHS: [&str; 2] = [".devcontainer/devcontainer.json", ".devcontainer.json"];
@@ -43,6 +45,7 @@ impl Sandbox {
clone_url: &str, clone_url: &str,
token: &str, token: &str,
pull_request_number: u64, pull_request_number: u64,
reporter: &Reporter,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?; let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?;
let repo_dir = workspace.path().join("repo"); let repo_dir = workspace.path().join("repo");
+4 -2
View File
@@ -1,14 +1,16 @@
use jiff::Timestamp; use jiff::Timestamp;
use serde::Serialize;
use crate::gitea::WebhookType; 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 { pub enum JobStatus {
Pending, Pending,
Processing, Processing,
} }
#[derive(Debug, toasty::Model)] #[derive(Debug, toasty::Model, Clone, Serialize)]
pub struct Job { pub struct Job {
#[key] #[key]
#[auto] #[auto]
+2 -1
View File
@@ -1,6 +1,7 @@
use jiff::Timestamp; use jiff::Timestamp;
use serde::Serialize;
#[derive(Debug, toasty::Model)] #[derive(Debug, toasty::Model, Serialize, Clone)]
pub struct Review { pub struct Review {
#[key] #[key]
#[auto] #[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)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub queue: JobQueue, pub queue: JobQueue,
pub dedup: Dedup, pub dedup: Dedup,
pub event_bus: EventBus,
pub bot_name: String, pub bot_name: String,
pub config: EnvConfig, pub config: EnvConfig,
/// Jeton annulé au démarrage de l'arrêt gracieux : il sert à clore les flux SSE.
pub shutdown: CancellationToken,
} }
+366
View File
@@ -0,0 +1,366 @@
/* Dashboard Steep — reprend le design system du site public
(mêmes jetons de couleur, mêmes composants d'en-tête/pied) et ajoute les
styles propres au tableau de bord. */
:root {
color-scheme: dark;
--paper: #0b1520;
--surface: #111f2d;
--ink: #edf4f7;
--ink-soft: #c2cfd9;
--muted: #94a8b8;
--line: #263b4d;
--green: #087b5b;
--green-dark: #5bd4a7;
--green-pale: #153a32;
--blue-pale: #1a3044;
--shadow: 0 18px 50px rgba(0, 0, 0, 0.24);
--button-hover: #066449;
--brand-mark-bg: #149b72;
--brand-mark-fg: #07111b;
--step-line: #23604c;
--setup-bg: #07111b;
--radius: 20px;
--content: 1160px;
--red: #d1667f;
--amber: #d7a35b;
}
:root[data-theme="light"] {
color-scheme: light;
--paper: #f7f8f5;
--surface: #ffffff;
--ink: #102f50;
--ink-soft: #38536c;
--muted: #718092;
--line: #e1e8e5;
--green: #149b72;
--green-dark: #087b5b;
--green-pale: #e8f5ef;
--blue-pale: #edf3f8;
--shadow: 0 18px 50px rgba(16, 47, 80, 0.08);
--button-hover: #087b5b;
--brand-mark-bg: #102f50;
--brand-mark-fg: #ffffff;
--step-line: #cce8da;
--setup-bg: #102f50;
--red: #b04a63;
--amber: #a9741f;
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 90px; }
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: inherit; }
img { display: block; max-width: 100%; }
.container { width: min(var(--content), calc(100% - 48px)); margin-inline: auto; }
.mono { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
.muted { color: var(--muted); }
.site-header {
position: sticky;
z-index: 5;
top: 0;
border-bottom: 1px solid var(--line);
background: var(--paper);
backdrop-filter: blur(16px);
}
.nav {
min-height: 76px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 28px;
}
.wordmark {
display: inline-flex;
align-items: center;
gap: 11px;
color: var(--ink);
text-decoration: none;
font-size: 18px;
font-weight: 850;
letter-spacing: 0.13em;
}
.wordmark-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 11px;
background: var(--brand-mark-bg);
color: var(--brand-mark-fg);
font-size: 17px;
letter-spacing: 0;
}
.nav-links { display: flex; align-items: center; gap: 22px; }
.nav-links a {
color: var(--ink-soft);
font-size: 14px;
font-weight: 650;
text-decoration: none;
transition: color 160ms ease;
}
.nav-links a:hover { color: var(--green-dark); }
.nav-links .nav-cta {
padding: 10px 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink);
}
.nav-links .nav-cta:hover { border-color: var(--green); color: var(--green-dark); }
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
color: var(--ink-soft);
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
}
.theme-toggle:hover { border-color: var(--green-dark); color: var(--green-dark); }
.theme-icon { color: var(--green-dark); font-size: 16px; line-height: 1; }
.section { padding: 46px 0 82px; }
.section-heading { max-width: 720px; margin-bottom: 26px; }
.section-heading h1 {
margin-bottom: 12px;
font-size: clamp(30px, 4vw, 44px);
line-height: 1.12;
letter-spacing: -0.045em;
}
.section-heading p { margin-bottom: 0; color: var(--muted); font-size: 17px; }
.eyebrow {
display: inline-flex;
align-items: center;
gap: 9px;
margin: 0 0 16px;
color: var(--green-dark);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.eyebrow::before {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
content: "";
}
.button {
display: inline-flex;
min-height: 42px;
align-items: center;
justify-content: center;
gap: 9px;
padding: 9px 16px;
border: 1px solid transparent;
border-radius: 999px;
background: var(--green);
color: white;
font: inherit;
font-size: 14px;
font-weight: 750;
text-decoration: none;
cursor: pointer;
transition: transform 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.button:hover { transform: translateY(-2px); background: var(--button-hover); box-shadow: 0 8px 20px rgba(20, 155, 114, 0.2); }
.button-secondary { border-color: var(--line); background: var(--surface); color: var(--ink); }
.button-secondary:hover { border-color: #b8d8ca; background: var(--green-pale); color: var(--green-dark); box-shadow: none; }
.arrow { font-size: 17px; line-height: 1; }
/* ---- Tableau de bord ---- */
.toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 22px;
}
.board {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 18px;
}
.panel {
padding: 22px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: 0 5px 20px rgba(16, 47, 80, 0.025);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.panel-head h2 { margin: 0; font-size: 17px; letter-spacing: -0.02em; }
.count {
min-width: 26px;
padding: 3px 9px;
border-radius: 999px;
background: var(--blue-pale);
color: var(--ink);
font-size: 12px;
font-weight: 850;
text-align: center;
}
.detail-panel { margin-bottom: 18px; }
steep-job-list { display: grid; gap: 10px; }
.job {
border: 1px solid var(--line);
border-radius: 14px;
background: var(--paper);
transition: border-color 140ms ease, background 140ms ease;
}
.job:hover { border-color: var(--green); }
.job[data-selected="true"] { border-color: var(--green); background: var(--green-pale); }
.job-head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: baseline;
gap: 10px;
padding: 13px 15px;
cursor: pointer;
list-style: none;
}
.job-head::-webkit-details-marker { display: none; }
.job-repo { font-weight: 700; font-size: 14px; overflow-wrap: anywhere; }
.job-pr { color: var(--green-dark); font-weight: 800; font-size: 13px; white-space: nowrap; }
.job-info { display: grid; gap: 12px; padding: 0 15px 15px; }
.job-info dl { display: grid; gap: 6px; margin: 0; }
.job-info dl > div { display: grid; grid-template-columns: 74px 1fr; gap: 10px; }
.job-info dt { color: var(--muted); font-size: 12px; }
.job-info dd { margin: 0; font-size: 12px; overflow-wrap: anywhere; }
.job-watch { justify-self: start; }
/* Chevron partagé par tous les <details> repliables. */
.chev::before { color: var(--muted); content: "▸"; font-size: 11px; }
details[open] .chev::before { content: "▾"; }
.empty { margin: 0; padding: 14px 2px; color: var(--muted); font-size: 14px; }
.pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
text-transform: lowercase;
}
.pill.done { background: var(--green-pale); color: var(--green-dark); }
.pill.failed { background: rgba(209, 102, 127, 0.16); color: var(--red); }
.reviews { overflow-x: auto; }
.reviews-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.reviews-table th {
padding: 0 12px 10px 0;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
text-align: left;
}
.reviews-table td { padding: 11px 12px 11px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
.reviews-table tr:last-child td { border-bottom: 0; }
/* Journal d'activité d'un job */
.log {
max-height: 360px;
overflow-y: auto;
padding: 4px 2px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12.5px;
line-height: 1.6;
}
.log-entry { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); }
.log-entry:last-child { border-bottom: 0; }
/* Résultat d'outil replié par défaut : le résumé tient sur une ligne, le corps
complet s'affiche au dépliage. */
.log-result { padding: 6px 0; border-bottom: 1px solid var(--line); }
.log-result:last-child { border-bottom: 0; }
.log-result > summary {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 10px;
align-items: baseline;
cursor: pointer;
list-style: none;
}
.log-result > summary::-webkit-details-marker { display: none; }
.log-result > .log-body { padding-top: 6px; }
.log-preview { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.log-tag {
align-self: start;
padding: 2px 8px;
border-radius: 7px;
background: var(--blue-pale);
color: var(--ink);
font-size: 11px;
font-weight: 800;
white-space: nowrap;
}
.log-entry[data-kind="tool_call"] .log-tag { background: var(--green-pale); color: var(--green-dark); }
.log-entry[data-kind="finished"] .log-tag { background: var(--green-pale); color: var(--green-dark); }
.log-entry[data-kind="gap"] .log-tag { background: rgba(215, 163, 91, 0.18); color: var(--amber); }
.log-body { overflow-wrap: anywhere; white-space: pre-wrap; }
.log-args { color: var(--muted); }
.log-truncated { color: var(--amber); font-style: italic; }
footer { padding: 26px 0; background: #0b233b; color: #b6c5d3; font-size: 13px; }
.footer-inner { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
footer a { color: white; text-decoration: none; }
footer a:hover { color: #8be0bc; }
.footer-brand { color: white; font-weight: 800; letter-spacing: 0.1em; }
:focus-visible { outline: 3px solid #35bb8e; outline-offset: 4px; }
@media (max-width: 820px) {
.board { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.container { width: min(var(--content), calc(100% - 32px)); }
.nav { min-height: 66px; gap: 12px; }
.nav-links { gap: 12px; }
.nav-links .nav-cta { display: none; }
.section { padding: 30px 0 62px; }
.toolbar { flex-direction: column; align-items: stretch; }
.footer-inner { align-items: flex-start; flex-direction: column; gap: 6px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0b1520">
<link rel="icon" type="image/png" sizes="512x512" href="/favicon.png">
<link rel="apple-touch-icon" href="/favicon.png">
<meta name="description" content="Steep dashboard — pending jobs, work in progress, and completed reviews, live over SSE.">
<title>Steep — Dashboard</title>
<link rel="stylesheet" href="/css/dashboard.css">
<script>
// Applique le thème enregistré avant le premier rendu, pour éviter un flash.
try {
const savedTheme = localStorage.getItem("steep-theme");
if (savedTheme === "light" || savedTheme === "dark") {
document.documentElement.dataset.theme = savedTheme;
}
} catch (_) {}
</script>
</head>
<body>
<header class="site-header">
<nav class="nav container" aria-label="Main navigation">
<a class="wordmark" href="/" aria-label="Steep dashboard"><span class="wordmark-mark">S</span> STEEP</a>
<div class="nav-links">
<a class="nav-cta" href="https://gitea.qpismont.fr/qpismont/herald">View source <span class="arrow" aria-hidden="true">↗</span></a>
<steep-theme-toggle></steep-theme-toggle>
</div>
</nav>
</header>
<main id="top" class="container">
<steep-dashboard></steep-dashboard>
</main>
<footer>
<div class="container footer-inner">
<span><span class="footer-brand">STEEP</span> · Let your code steep.</span>
<span>Built with Rust · <a href="https://gitea.qpismont.fr/qpismont/herald">Source on Gitea</a></span>
</div>
</footer>
<script type="module" src="/js/dashboard.js"></script>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
// Accès aux routes exposées par le serveur axum et petits utilitaires partagés
// par les web components du dashboard.
export const endpoints = {
jobs: "/jobs",
reviews: "/dedup",
lifecycle: "/events",
jobEvents: (id) => `/jobs/${id}/events`,
};
async function getJson(url) {
const response = await fetch(url, { headers: { accept: "application/json" } });
if (!response.ok) {
throw new Error(`${url} → ${response.status}`);
}
return response.json();
}
export const fetchJobs = () => getJson(endpoints.jobs);
export const fetchReviews = () => getJson(endpoints.reviews);
// Ouvre un flux SSE et route les évènements nommés vers les callbacks fournis.
// Les évènements du serveur sont nommés (`lifecycle`, `progress`, `gap`), donc
// `addEventListener("message")` ne suffit pas : il faut écouter par nom.
export function subscribe(url, handlers = {}) {
const source = new EventSource(url);
const onData = (name, handler) => {
if (handler) {
source.addEventListener(name, (event) => handler(JSON.parse(event.data)));
}
};
if (handlers.onOpen) source.addEventListener("open", () => handlers.onOpen());
if (handlers.onError) source.addEventListener("error", () => handlers.onError());
onData("lifecycle", handlers.onLifecycle);
onData("progress", handlers.onProgress);
if (handlers.onGap) {
source.addEventListener("gap", (event) => handlers.onGap(Number(event.data)));
}
return source;
}
export function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
// Horodatage relatif (« il y a 3 min »), tolérant sur les valeurs absentes.
export function relativeTime(value) {
if (!value) return "—";
const then = Date.parse(value);
if (Number.isNaN(then)) return "—";
const seconds = Math.round((then - Date.now()) / 1000);
const steps = [
["year", 60 * 60 * 24 * 365],
["month", 60 * 60 * 24 * 30],
["day", 60 * 60 * 24],
["hour", 60 * 60],
["minute", 60],
["second", 1],
];
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
for (const [unit, size] of steps) {
if (Math.abs(seconds) >= size || unit === "second") {
return formatter.format(Math.round(seconds / size), unit);
}
}
return "—";
}
+212
View File
@@ -0,0 +1,212 @@
// <steep-dashboard> : assemble le tableau de bord.
//
// Au montage, il charge l'état initial via REST (`/jobs`, `/dedup`), puis
// s'abonne au flux global `/events` pour tenir les listes à jour en direct.
// La sélection d'un job ouvre son flux fin via <steep-job-detail>.
import {
endpoints,
escapeHtml,
fetchJobs,
fetchReviews,
relativeTime,
subscribe,
} from "./api.js";
import "./theme-toggle.js";
import "./job-list.js";
import "./job-detail.js";
class Dashboard extends HTMLElement {
connectedCallback() {
this._jobs = { pending: [], processing: [] };
this._reviews = [];
this._selectedId = null;
this.render();
this.refresh();
qpismont marked this conversation as resolved
Review

[bug] refresh() est lancé sans attendre avant l’abonnement SSE. Si un événement arrive pendant le chargement, la réponse REST peut ensuite écraser l’état plus récent fourni par SSE ; aucun nouvel événement n’est garanti pour corriger la liste. Abonnez-vous avant de charger l’état, ou réconciliez la réponse avec les événements reçus entre-temps.

**[bug]** `refresh()` est lancé sans attendre avant l’abonnement SSE. Si un événement arrive pendant le chargement, la réponse REST peut ensuite écraser l’état plus récent fourni par SSE ; aucun nouvel événement n’est garanti pour corriger la liste. Abonnez-vous avant de charger l’état, ou réconciliez la réponse avec les événements reçus entre-temps.
this.connect();
}
disconnectedCallback() {
this._source?.close();
if (this._refreshTimer) clearTimeout(this._refreshTimer);
}
// ---- Chargement et flux -------------------------------------------------
async refresh() {
try {
const [jobs, reviews] = await Promise.all([fetchJobs(), fetchReviews()]);
this._jobs = {
pending: jobs.pending ?? [],
processing: jobs.processing ?? [],
};
this._reviews = reviews.reviews ?? [];
this.update();
} catch (error) {
console.error("failed to refresh the dashboard", error);
}
}
connect() {
this._source = subscribe(endpoints.lifecycle, {
onLifecycle: (event) => this.applyLifecycle(event),
// En cas de gap (évènements ratés), on recharge l'état depuis le REST.
onGap: () => this.refresh(),
Review

[bug] Le flux /events reconnecte automatiquement (EventSource), mais les événements émis pendant la coupure (redémarrage serveur, réseau) sont perdus, sans événement gap. Le dashboard garde alors un état périmé (jobs fantômes dans Pending/In progress). Il faut brancher onOpen pour appeler refresh() à chaque (re)connexion, y compris la première.

**[bug]** Le flux `/events` reconnecte automatiquement (EventSource), mais les événements émis pendant la coupure (redémarrage serveur, réseau) sont perdus, sans événement `gap`. Le dashboard garde alors un état périmé (jobs fantômes dans Pending/In progress). Il faut brancher `onOpen` pour appeler `refresh()` à chaque (re)connexion, y compris la première.
});
}
// Recharge la liste des reviews peu après une fin de job, pour récupérer les
// champs que l'évènement ne porte pas (markdown, coût, modèle).
scheduleRefresh() {
if (this._refreshTimer) clearTimeout(this._refreshTimer);
this._refreshTimer = setTimeout(() => this.refresh(), 400);
}
applyLifecycle(event) {
switch (event.type) {
case "queued":
if (!this._jobs.pending.some((job) => job.id === event.id)) {
this._jobs.pending.push(this.jobFromEvent(event));
}
break;
case "started": {
// On garde le job déjà connu s'il existe, pour conserver les champs que
// l'évènement `started` ne porte pas (event_id, created_at).
const known = this._jobs.pending.find((job) => job.id === event.id);
this.removeJob(event.id);
this._jobs.processing.push(known ?? this.jobFromEvent(event));
break;
}
case "review_done":
case "failed":
this.removeJob(event.id);
this.scheduleRefresh();
break;
case "skipped":
this.removeJob(event.id);
break;
default:
return;
}
this.update();
}
removeJob(id) {
this._jobs.pending = this._jobs.pending.filter((job) => job.id !== id);
this._jobs.processing = this._jobs.processing.filter((job) => job.id !== id);
}
// L'évènement de cycle de vie porte assez de champs pour l'affichage ; le
// rafraîchissement REST fournira le reste (created_at, payload…).
jobFromEvent(event) {
return {
id: event.id,
event_id: event.event_id,
repo: event.repo,
pr: event.pr,
created_at: event.created_at,
};
}
// ---- Rendu --------------------------------------------------------------
render() {
this.innerHTML = `
<section class="section">
<div class="section-heading">
<p class="eyebrow">Live operations</p>
<h1>Review dashboard</h1>
<p>Pending jobs, work in progress, and completed reviews — kept in sync over SSE.</p>
</div>
<div class="toolbar">
<button class="button button-secondary" id="refresh" type="button">Refresh</button>
</div>
<div class="board">
<section class="panel">
<header class="panel-head"><h2>Pending</h2><span class="count" id="pending-count">0</span></header>
<steep-job-list id="pending" empty="No pending job."></steep-job-list>
</section>
<section class="panel">
<header class="panel-head"><h2>In progress</h2><span class="count" id="processing-count">0</span></header>
<steep-job-list id="processing" empty="No job is running."></steep-job-list>
</section>
</div>
<section class="panel detail-panel">
<header class="panel-head"><h2>Activity</h2></header>
<steep-job-detail id="detail"></steep-job-detail>
</section>
<section class="panel">
<header class="panel-head"><h2>Completed reviews</h2><span class="count" id="reviews-count">0</span></header>
<div class="reviews" id="reviews"></div>
</section>
</section>`;
this._pending = this.querySelector("#pending");
this._processing = this.querySelector("#processing");
this._detail = this.querySelector("#detail");
this._reviewsEl = this.querySelector("#reviews");
this.querySelector("#refresh").addEventListener("click", () => this.refresh());
this.addEventListener("job-select", (event) => this.select(event.detail.id));
this.update();
}
update() {
this._pending.jobs = this._jobs.pending;
this._pending.selected = this._selectedId;
this._processing.jobs = this._jobs.processing;
this._processing.selected = this._selectedId;
this.querySelector("#pending-count").textContent = String(this._jobs.pending.length);
this.querySelector("#processing-count").textContent = String(this._jobs.processing.length);
this.querySelector("#reviews-count").textContent = String(this._reviews.length);
this._reviewsEl.innerHTML = this.reviewsHtml();
}
select(id) {
this._selectedId = id;
this._detail.jobId = id;
this.update();
this._detail.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
reviewsHtml() {
if (this._reviews.length === 0) {
return `<p class="empty">No review completed yet.</p>`;
}
const rows = [...this._reviews]
.sort((a, b) => String(b.completed_at ?? "").localeCompare(String(a.completed_at ?? "")))
.map(
(review) => `
<tr>
<td><span class="pill ${review.ok ? "done" : "failed"}">${review.ok ? "done" : "failed"}</span></td>
<td class="mono">${escapeHtml(review.repo)}#${review.pr}</td>
<td>${review.comments}</td>
<td>${review.duration_ms} ms</td>
<td class="mono muted">${escapeHtml(review.model ?? "—")}</td>
<td class="muted">${relativeTime(review.completed_at)}</td>
</tr>`,
)
.join("");
return `
<table class="reviews-table">
<thead>
<tr>
<th>Status</th><th>Pull request</th><th>Comments</th><th>Duration</th><th>Model</th><th>Completed</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
}
customElements.define("steep-dashboard", Dashboard);
+61
View File
@@ -0,0 +1,61 @@
// Carte d'un job. Le détail (id, event, date) est replié par défaut ; le
// bouton « Watch activity » remonte un évènement `job-select` que le dashboard
// écoute pour ouvrir le flux d'activité du job.
import { escapeHtml, relativeTime } from "./api.js";
class JobCard extends HTMLElement {
set job(value) {
this._job = value;
this.render();
}
get job() {
return this._job;
}
set selected(value) {
this._selected = Boolean(value);
this.render();
}
connectedCallback() {
this.render();
}
render() {
if (!this._job) return;
const job = this._job;
// Préserve l'état replié/déplié à travers les re-rendus (mises à jour SSE).
const open = this._details?.open ? " open" : "";
this.innerHTML = `
<details class="job" data-selected="${this._selected ? "true" : "false"}"${open}>
<summary class="job-head">
<span class="chev" aria-hidden="true"></span>
<span class="job-repo">${escapeHtml(job.repo)}</span>
<span class="job-pr">#${job.pr}</span>
</summary>
<div class="job-info">
<dl>
<div><dt>Job</dt><dd>${job.id}</dd></div>
${job.event_id != null ? `<div><dt>Event</dt><dd>${job.event_id}</dd></div>` : ""}
<div><dt>Created</dt><dd>${escapeHtml(relativeTime(job.created_at))}</dd></div>
</dl>
<button class="button button-secondary job-watch" type="button">Watch activity</button>
Review

[bug] Le bouton « Watch activity » est affiché aussi pour les jobs pending. Or /jobs/{id}/events renvoie 404 tant que le job n'a pas démarré (le canal n'est créé que par EventBus::start). Le panneau affiche alors « stream closed » sans explication. Il faut masquer ou désactiver le bouton pour les jobs en attente, ou ré-ouvrir le flux au passage en started.

**[bug]** Le bouton « Watch activity » est affiché aussi pour les jobs `pending`. Or `/jobs/{id}/events` renvoie 404 tant que le job n'a pas démarré (le canal n'est créé que par `EventBus::start`). Le panneau affiche alors « stream closed » sans explication. Il faut masquer ou désactiver le bouton pour les jobs en attente, ou ré-ouvrir le flux au passage en `started`.
</div>
</details>`;
this._details = this.querySelector("details");
this.querySelector(".job-watch").addEventListener("click", () => this.select());
}
select() {
this.dispatchEvent(
new CustomEvent("job-select", { detail: { id: this._job.id }, bubbles: true }),
);
}
}
customElements.define("steep-job-card", JobCard);
+150
View File
@@ -0,0 +1,150 @@
// Panneau d'activité d'un job : ouvre un flux SSE vers `/jobs/{id}/events`,
// rejoue l'historique renvoyé par le serveur puis affiche le direct.
import { endpoints, escapeHtml, subscribe } from "./api.js";
class JobDetail extends HTMLElement {
set jobId(value) {
const id = value ?? null;
if (this._jobId === id) return;
this._jobId = id;
this.open();
}
connectedCallback() {
if (!this._jobId) this.renderPlaceholder();
}
disconnectedCallback() {
this.close();
}
close() {
if (this._source) {
this._source.close();
this._source = null;
}
}
open() {
this.close();
this._finished = false;
if (!this._jobId) {
this.renderPlaceholder();
return;
}
this.innerHTML = `
<div class="log" id="log" role="log" aria-live="polite"></div>`;
this._log = this.querySelector("#log");
this._source = subscribe(endpoints.jobEvents(this._jobId), {
onProgress: (event) => {
this.append(event);
if (event.type === "finished") {
this._finished = true;
this.close();
}
},
onGap: (skipped) =>
this.append({ type: "gap", note: `missed ${skipped} events, refresh to resync` }),
// Un `error` survient aussi quand le serveur ferme le flux (job terminé).
// On ferme alors la source pour éviter la boucle de reconnexion (le job
// n'est plus en cours, la route renverrait 404).
onError: () => {
qpismont marked this conversation as resolved
Review

[bug] Le gestionnaire ferme l’EventSource à la première erreur, y compris lors d’une coupure réseau temporaire pour laquelle EventSource aurait normalement réessayé. L’utilisateur perd alors les mises à jour du job. Fermez-le seulement quand la fin du job est confirmée ou quand le serveur indique que le job n’existe plus.

**[bug]** Le gestionnaire ferme l’EventSource à la première erreur, y compris lors d’une coupure réseau temporaire pour laquelle EventSource aurait normalement réessayé. L’utilisateur perd alors les mises à jour du job. Fermez-le seulement quand la fin du job est confirmée ou quand le serveur indique que le job n’existe plus.
if (this._finished) return;
this.append({ type: "gap", note: "stream closed" });
this.close();
},
});
}
renderPlaceholder() {
this.innerHTML = `<p class="empty">Select a job in progress to watch the agent work.</p>`;
}
append(event) {
if (!this._log) return;
this._log.append(this.entry(event));
this._log.scrollTop = this._log.scrollHeight;
}
entry(event) {
return event.type === "tool_result" ? this.resultEntry(event) : this.lineEntry(event);
}
// Ligne simple : appel d'outil, fin de review, gap.
lineEntry(event) {
const row = document.createElement("div");
row.className = "log-entry";
row.dataset.kind = event.type;
const { tag, body } = this.describe(event);
const tagEl = document.createElement("span");
tagEl.className = "log-tag";
tagEl.textContent = tag;
const bodyEl = document.createElement("span");
bodyEl.className = "log-body";
bodyEl.innerHTML = body;
row.append(tagEl, bodyEl);
return row;
}
// Résultat d'outil : replié par défaut, avec un aperçu sur une ligne.
resultEntry(event) {
const details = document.createElement("details");
details.className = "log-result";
const summary = document.createElement("summary");
const chev = document.createElement("span");
chev.className = "chev";
chev.setAttribute("aria-hidden", "true");
const tag = document.createElement("span");
tag.className = "log-tag";
tag.textContent = "result";
const preview = document.createElement("span");
preview.className = "log-preview";
preview.textContent = this.preview(event.output);
summary.append(chev, tag, preview);
const body = document.createElement("div");
body.className = "log-body";
const truncated = event.truncated
? ' <span class="log-truncated">(truncated)</span>'
: "";
body.innerHTML = `${escapeHtml(event.output)}${truncated}`;
details.append(summary, body);
return details;
}
preview(text) {
const line = String(text ?? "").replace(/\s+/g, " ").trim();
return line.length > 80 ? `${line.slice(0, 80)}…` : line;
}
describe(event) {
switch (event.type) {
case "tool_call":
return {
tag: "call",
body: `${escapeHtml(event.name)} <span class="log-args">${escapeHtml(event.arguments)}</span>`,
};
case "finished":
return {
tag: "done",
body: event.ok ? "review finished" : "review failed",
};
case "gap":
return { tag: "gap", body: escapeHtml(event.note ?? "gap") };
default:
return { tag: escapeHtml(event.type ?? "event"), body: "" };
}
}
}
customElements.define("steep-job-detail", JobDetail);
+58
View File
@@ -0,0 +1,58 @@
// Liste de jobs (une colonne du tableau). Reçoit un tableau de jobs via la
// propriété `jobs` et l'id sélectionné via `selected`.
import "./job-card.js";
class JobList extends HTMLElement {
set jobs(value) {
this._jobs = Array.isArray(value) ? value : [];
this.render();
}
get jobs() {
return this._jobs ?? [];
}
set selected(value) {
this._selected = value;
this.render();
}
connectedCallback() {
if (this._mounted) return;
this._mounted = true;
this.render();
}
render() {
const jobs = this.jobs;
if (jobs.length === 0) {
this.replaceChildren();
const empty = document.createElement("p");
empty.className = "empty";
empty.textContent = this.getAttribute("empty") || "Nothing here.";
this.append(empty);
return;
}
// Réconciliation par id : on réutilise les cartes existantes pour préserver
// l'état replié/déplié de chacune à travers les mises à jour.
const known = new Map();
for (const card of this.querySelectorAll("steep-job-card")) {
if (card.job) known.set(card.job.id, card);
}
for (const job of jobs) {
const card = known.get(job.id) ?? document.createElement("steep-job-card");
known.delete(job.id);
card.job = job;
card.selected = job.id === this._selected;
this.append(card);
}
for (const card of known.values()) card.remove();
Review

[bug] Dans la branche « liste vide », render() ajoute un <p class="empty">, mais la branche non vide ne le retire jamais. Après la première apparition d'un job (liste vide puis job), le message « No pending job. » reste affiché au-dessus ou au-dessous des cartes. Il faut supprimer l'élément .empty dans la branche non vide, ou faire un replaceChildren avant de ré-attacher les cartes.

**[bug]** Dans la branche « liste vide », `render()` ajoute un `<p class="empty">`, mais la branche non vide ne le retire jamais. Après la première apparition d'un job (liste vide puis job), le message « No pending job. » reste affiché au-dessus ou au-dessous des cartes. Il faut supprimer l'élément `.empty` dans la branche non vide, ou faire un `replaceChildren` avant de ré-attacher les cartes.
}
}
customElements.define("steep-job-list", JobList);
+59
View File
@@ -0,0 +1,59 @@
// Bascule de thème, identique à celle du site public (même clé localStorage,
// même rendu), empaquetée en web component.
const STORAGE_KEY = "steep-theme";
export function currentTheme() {
return document.documentElement.dataset.theme === "light" ? "light" : "dark";
}
export function applyTheme(theme, persist = true) {
document.documentElement.dataset.theme = theme;
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.content = theme === "dark" ? "#0b1520" : "#f7f8f5";
if (persist) {
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch (_) {
// stockage indisponible : on garde juste le thème en mémoire.
}
}
window.dispatchEvent(new CustomEvent("steep:theme", { detail: theme }));
}
class ThemeToggle extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<button class="theme-toggle" type="button" role="switch" aria-label="Toggle color theme">
<span class="theme-icon" aria-hidden="true"></span><span class="theme-label"></span>
</button>`;
this._button = this.querySelector("button");
this._icon = this.querySelector(".theme-icon");
this._label = this.querySelector(".theme-label");
this._onTheme = () => this.render();
this._button.addEventListener("click", () => {
applyTheme(currentTheme() === "dark" ? "light" : "dark");
});
window.addEventListener("steep:theme", this._onTheme);
this.render();
}
disconnectedCallback() {
window.removeEventListener("steep:theme", this._onTheme);
}
render() {
const theme = currentTheme();
this._icon.textContent = theme === "dark" ? "☾" : "☀";
this._label.textContent = theme === "dark" ? "Dark theme" : "Light theme";
this._button.setAttribute("aria-checked", String(theme === "dark"));
}
}
customElements.define("steep-theme-toggle", ThemeToggle);
+4 -2
View File
@@ -519,9 +519,11 @@
<ul class="roadmap-list"> <ul class="roadmap-list">
<li><span class="version">1.2</span><span>Sandboxed pull-request reviews.</span></li> <li><span class="version">1.2</span><span>Sandboxed pull-request reviews.</span></li>
<li><span class="version">1.2.1</span><span>A default Dev Container when a repository has no configuration.</span></li> <li><span class="version">1.2.1</span><span>A default Dev Container when a repository has no configuration.</span></li>
<li><span class="version">1.3</span><span>Type-safe routing between review and implementation requests.</span></li> <li><span class="version">1.3</span><span>Durable reviews: persist jobs in PostgreSQL, list them by state (running, pending, review done), and stream live updates over SSE.</span></li>
<li><span class="version">2.0</span><span>Implement requested changes from issues or existing PRs, then create or update a PR automatically.</span></li> <li><span class="version">1.4</span><span>Configuration from a YAML file, replacing part of the environment variables.</span></li>
<li><span class="version">2.0</span><span>Type-safe action routing between reviews and implementation requests, implement requested changes from issues or existing PRs, then create or update a PR automatically.</span></li>
<li><span class="version">2.1</span><span>Project instructions from <code>AGENTS.md</code> and separate review/implementation models.</span></li> <li><span class="version">2.1</span><span>Project instructions from <code>AGENTS.md</code> and separate review/implementation models.</span></li>
<li><span class="version">2.2</span><span>Sub-agents that take on well-scoped parts of a task and run in parallel.</span></li>
<li><span class="version">3.0</span><span>Automatically investigate and fix project errors reported by Sentry.</span></li> <li><span class="version">3.0</span><span>Automatically investigate and fix project errors reported by Sentry.</span></li>
</ul> </ul>
</div> </div>
1
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ -f "${ROOT_DIR}/.env" ]; then
set -a
# shellcheck disable=SC1091
. "${ROOT_DIR}/.env"
set +a
fi
cd "${ROOT_DIR}"
if [ -x "${ROOT_DIR}/steep-server" ]; then
exec "${ROOT_DIR}/steep-server" migration apply "$@"
else
exec cargo run -p steep-server -- migration apply "$@"
fi
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ -f "${ROOT_DIR}/.env" ]; then
set -a
. "${ROOT_DIR}/.env"
set +a
fi
cd "${ROOT_DIR}"
if [ "$#" -eq 0 ]; then
echo "Usage: $0 <nom_de_la_migration>" >&2
echo "Exemple: $0 add_users_table" >&2
exit 1
fi
NAME="$1"
exec cargo run -p steep-server -- migration generate --name "${NAME}"