diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dff506e..2dbce3e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,6 +19,9 @@ }, "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/steep,type=bind", "workspaceFolder": "/workspaces/steep", + "mounts": [ + "source=/run/user/1000/podman/podman.sock,target=/var/run/docker.sock,type=bind" + ], "runArgs": ["--userns=keep-id", "--security-opt", "label=disable"], "appPort": [3000] } diff --git a/Cargo.lock b/Cargo.lock index d04968b..49775d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1747,6 +1747,12 @@ dependencies = [ "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]] name = "httparse" version = "1.10.1" @@ -2536,6 +2542,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "minimal-lexical" version = "0.2.1" @@ -4274,6 +4290,7 @@ dependencies = [ "toasty", "toasty-cli", "tokio", + "tokio-stream", "tokio-util", "tower", "tower-http 0.7.1", @@ -4773,6 +4790,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -4885,10 +4903,19 @@ checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ "bitflags 2.13.1", "bytes", + "futures-core", + "futures-util", "http 1.5.0", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -5211,6 +5238,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index 2302f99..26e20b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ sentry = { version = "0.49", features = ["tower-axum-matched-path"] } sentry-anyhow = { version = "0.49", features = ["backtrace"] } dotenvy = "0.15" tower = "0.5" -tower-http = { version = "0.7", features = ["trace"] } +tower-http = { version = "0.7", features = ["trace", "fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } axum = "0.8" @@ -30,7 +30,7 @@ metrics = "0.24" metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener"] } toasty = { version = "0.11", features = ["postgresql", "serde", "jiff"] } toasty-cli = "0.11" -jiff = "0.2" +jiff = { version = "0.2", features = ["serde"] } [profile.release] debug = 1 diff --git a/Containerfile b/Containerfile index c2a086e..ea5eacf 100644 --- a/Containerfile +++ b/Containerfile @@ -23,7 +23,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app COPY --from=builder /app/target/release/steep-server ./steep-server +COPY front ./front + COPY crates/steep-server/toasty ./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"] diff --git a/README.md b/README.md index 62f20b1..45f19b5 100644 --- a/README.md +++ b/README.md @@ -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: ```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: ```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`. diff --git a/ROADMAP.md b/ROADMAP.md index 5cd299a..2bf276e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,15 +11,22 @@ - 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. -## 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`. - 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. - -## 2.0 — Implementation requests — planned - -- Use the typed action from 1.3 to route explicit implementation requests to an implementation workflow. +- Recognize implementation requests (for example, “@steep, rename method `XXX` to `YYY`”) and route them to the 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 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. @@ -31,6 +38,11 @@ - 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. +## 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 - Automatically discover project errors reported by Sentry, without waiting for a user comment. diff --git a/crates/steep-server/Cargo.toml b/crates/steep-server/Cargo.toml index bd12c40..e7e19a1 100644 --- a/crates/steep-server/Cargo.toml +++ b/crates/steep-server/Cargo.toml @@ -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 } diff --git a/crates/steep-server/src/api.rs b/crates/steep-server/src/api.rs index 4896016..158b5ff 100644 --- a/crates/steep-server/src/api.rs +++ b/crates/steep-server/src/api.rs @@ -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::>::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) -> Result { + let reviews = app_state.dedup.list_reviews().await?; + + Ok(Json(json!({ + "reviews": reviews + }))) +} + +async fn jobs_list(State(app_state): State) -> Result { + 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, +) -> Sse>> { + 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, + Path(id): Path, +) -> Result>>, 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(name: &'static str, payload: &T) -> Result { + Event::default().event(name).json_data(payload) +} + +fn sse_gap(skipped: u64) -> Result { + 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")) } diff --git a/crates/steep-server/src/bot.rs b/crates/steep-server/src/bot.rs index dadc2f2..c7172cd 100644 --- a/crates/steep-server/src/bot.rs +++ b/crates/steep-server/src/bot.rs @@ -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 { + #[instrument(skip(self, webhook, reporter), fields(repo, pr))] + pub async fn exec( + &self, + webhook: WebhookType, + reporter: &Reporter, + ) -> anyhow::Result { 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 } diff --git a/crates/steep-server/src/bot_actions/review.rs b/crates/steep-server/src/bot_actions/review.rs index e37cf08..3fe8148 100644 --- a/crates/steep-server/src/bot_actions/review.rs +++ b/crates/steep-server/src/bot_actions/review.rs @@ -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, review_payload: ReviewPayload, + reporter: &Reporter, ) -> anyhow::Result { 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, review_payload: &ReviewPayload, bot_request: &str, + reporter: &Reporter, ) -> anyhow::Result<(ReviewResult, Option)> { 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; diff --git a/crates/steep-server/src/dedup.rs b/crates/steep-server/src/dedup.rs index b3d83af..51be893 100644 --- a/crates/steep-server/src/dedup.rs +++ b/crates/steep-server/src/dedup.rs @@ -70,6 +70,13 @@ impl Dedup { Ok(()) } + + pub async fn list_reviews(&self) -> toasty::Result> { + let mut db = self.db.clone(); + let reviews = Review::all().exec(&mut db).await?; + + Ok(reviews) + } } #[cfg(test)] diff --git a/crates/steep-server/src/errors.rs b/crates/steep-server/src/errors.rs index b06bda2..2cee8af 100644 --- a/crates/steep-server/src/errors.rs +++ b/crates/steep-server/src/errors.rs @@ -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() } diff --git a/crates/steep-server/src/event_bus.rs b/crates/steep-server/src/event_bus.rs new file mode 100644 index 0000000..c9c051e --- /dev/null +++ b/crates/steep-server/src/event_bus.rs @@ -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, + history: Mutex>, +} + +impl JobChannel { + fn subscribe(&self) -> (Vec, broadcast::Receiver) { + 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, + pub live: broadcast::Receiver, +} + +#[derive(Clone)] +pub struct EventBus { + lifecycle: broadcast::Sender, + jobs: Arc>>>, +} + +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 { + self.lifecycle.subscribe() + } + + pub fn start(&self, id: u64, repo: impl Into, 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 { + 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, +} + +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()); + } +} diff --git a/crates/steep-server/src/job_queue.rs b/crates/steep-server/src/job_queue.rs index d09649d..1b65737 100644 --- a/crates/steep-server/src/job_queue.rs +++ b/crates/steep-server/src/job_queue.rs @@ -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 { 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 { let db = self.db.clone(); @@ -103,6 +95,13 @@ impl JobQueue { Ok(()) } + + pub async fn list(&self, status: JobStatus) -> toasty::Result> { + let mut db = self.db.clone(); + let jobs = Job::filter_by_status(status).exec(&mut db).await?; + + Ok(jobs) + } } #[cfg(test)] diff --git a/crates/steep-server/src/main.rs b/crates/steep-server/src/main.rs index 6d4231c..c639505 100644 --- a/crates/steep-server/src/main.rs +++ b/crates/steep-server/src/main.rs @@ -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 ) } => { diff --git a/crates/steep-server/src/sandbox/agent.rs b/crates/steep-server/src/sandbox/agent.rs index 7de2702..a2c1768 100644 --- a/crates/steep-server/src/sandbox/agent.rs +++ b/crates/steep-server/src/sandbox/agent.rs @@ -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 { 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( open_router: &OpenRouterClient, sandbox: &Sandbox, @@ -79,6 +69,7 @@ pub async fn run( system_prompt: &str, user_prompt: &str, max_iterations: usize, + reporter: &Reporter, ) -> anyhow::Result> 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(), diff --git a/crates/steep-server/src/sandbox/instance.rs b/crates/steep-server/src/sandbox/instance.rs index 880166c..8478c58 100644 --- a/crates/steep-server/src/sandbox/instance.rs +++ b/crates/steep-server/src/sandbox/instance.rs @@ -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 { let workspace = tempfile::tempdir().context("failed to create sandbox workspace")?; let repo_dir = workspace.path().join("repo"); diff --git a/crates/steep-server/src/schemas/job.rs b/crates/steep-server/src/schemas/job.rs index d36cf4a..174df5f 100644 --- a/crates/steep-server/src/schemas/job.rs +++ b/crates/steep-server/src/schemas/job.rs @@ -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] diff --git a/crates/steep-server/src/schemas/review.rs b/crates/steep-server/src/schemas/review.rs index 0539bfa..0715a07 100644 --- a/crates/steep-server/src/schemas/review.rs +++ b/crates/steep-server/src/schemas/review.rs @@ -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] diff --git a/crates/steep-server/src/state.rs b/crates/steep-server/src/state.rs index c66b4e1..263ffaf 100644 --- a/crates/steep-server/src/state.rs +++ b/crates/steep-server/src/state.rs @@ -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, } diff --git a/front/css/dashboard.css b/front/css/dashboard.css new file mode 100644 index 0000000..ec12438 --- /dev/null +++ b/front/css/dashboard.css @@ -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
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; } +} diff --git a/front/favicon.png b/front/favicon.png new file mode 100644 index 0000000..e7017a4 Binary files /dev/null and b/front/favicon.png differ diff --git a/front/index.html b/front/index.html new file mode 100644 index 0000000..02e90ff --- /dev/null +++ b/front/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + Steep — Dashboard + + + + + + +
+ +
+ +
+ +
+ + + + diff --git a/front/js/api.js b/front/js/api.js new file mode 100644 index 0000000..2f6ac45 --- /dev/null +++ b/front/js/api.js @@ -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) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[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 "—"; +} diff --git a/front/js/dashboard.js b/front/js/dashboard.js new file mode 100644 index 0000000..5829184 --- /dev/null +++ b/front/js/dashboard.js @@ -0,0 +1,212 @@ +// : 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 . + +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(); + 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(), + }); + } + + // 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 = ` +
+
+

Live operations

+

Review dashboard

+

Pending jobs, work in progress, and completed reviews — kept in sync over SSE.

+
+ +
+ +
+ +
+
+

Pending

0
+ +
+
+

In progress

0
+ +
+
+ +
+

Activity

+ +
+ +
+

Completed reviews

0
+
+
+
`; + + 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 `

No review completed yet.

`; + } + + const rows = [...this._reviews] + .sort((a, b) => String(b.completed_at ?? "").localeCompare(String(a.completed_at ?? ""))) + .map( + (review) => ` + + ${review.ok ? "done" : "failed"} + ${escapeHtml(review.repo)}#${review.pr} + ${review.comments} + ${review.duration_ms} ms + ${escapeHtml(review.model ?? "—")} + ${relativeTime(review.completed_at)} + `, + ) + .join(""); + + return ` + + + + + + + ${rows} +
StatusPull requestCommentsDurationModelCompleted
`; + } +} + +customElements.define("steep-dashboard", Dashboard); diff --git a/front/js/job-card.js b/front/js/job-card.js new file mode 100644 index 0000000..6853d4d --- /dev/null +++ b/front/js/job-card.js @@ -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 = ` +
+ + + ${escapeHtml(job.repo)} + #${job.pr} + +
+
+
Job
${job.id}
+ ${job.event_id != null ? `
Event
${job.event_id}
` : ""} +
Created
${escapeHtml(relativeTime(job.created_at))}
+
+ +
+
`; + + 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); diff --git a/front/js/job-detail.js b/front/js/job-detail.js new file mode 100644 index 0000000..abe149e --- /dev/null +++ b/front/js/job-detail.js @@ -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 = ` +
`; + 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: () => { + if (this._finished) return; + this.append({ type: "gap", note: "stream closed" }); + this.close(); + }, + }); + } + + renderPlaceholder() { + this.innerHTML = `

Select a job in progress to watch the agent work.

`; + } + + 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 + ? ' (truncated)' + : ""; + 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)} ${escapeHtml(event.arguments)}`, + }; + 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); diff --git a/front/js/job-list.js b/front/js/job-list.js new file mode 100644 index 0000000..f79221c --- /dev/null +++ b/front/js/job-list.js @@ -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(); + } +} + +customElements.define("steep-job-list", JobList); diff --git a/front/js/theme-toggle.js b/front/js/theme-toggle.js new file mode 100644 index 0000000..774ec53 --- /dev/null +++ b/front/js/theme-toggle.js @@ -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 = ` + `; + + 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); diff --git a/public/index.html b/public/index.html index a7670af..c29f29e 100644 --- a/public/index.html +++ b/public/index.html @@ -519,9 +519,11 @@
  • 1.2Sandboxed pull-request reviews.
  • 1.2.1A default Dev Container when a repository has no configuration.
  • -
  • 1.3Type-safe routing between review and implementation requests.
  • -
  • 2.0Implement requested changes from issues or existing PRs, then create or update a PR automatically.
  • +
  • 1.3Durable reviews: persist jobs in PostgreSQL, list them by state (running, pending, review done), and stream live updates over SSE.
  • +
  • 1.4Configuration from a YAML file, replacing part of the environment variables.
  • +
  • 2.0Type-safe action routing between reviews and implementation requests, implement requested changes from issues or existing PRs, then create or update a PR automatically.
  • 2.1Project instructions from AGENTS.md and separate review/implementation models.
  • +
  • 2.2Sub-agents that take on well-scoped parts of a task and run in parallel.
  • 3.0Automatically investigate and fix project errors reported by Sentry.
diff --git a/scripts/migrate-apply.sh b/scripts/migrate-apply.sh new file mode 100755 index 0000000..5dff092 --- /dev/null +++ b/scripts/migrate-apply.sh @@ -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 diff --git a/scripts/migrate-generate.sh b/scripts/migrate-generate.sh new file mode 100755 index 0000000..bbce364 --- /dev/null +++ b/scripts/migrate-generate.sh @@ -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 " >&2 + echo "Exemple: $0 add_users_table" >&2 + exit 1 +fi + +NAME="$1" + +exec cargo run -p steep-server -- migration generate --name "${NAME}"