From 9c2031385f73d77b08f589ed7812618fb12bfdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Audiger?= Date: Wed, 1 Jul 2026 17:39:26 +0200 Subject: [PATCH 1/2] refactor(reporter): split JobsComponent rendering into smaller pieces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jérémy Audiger --- crates/brioche-core/src/reporter/console.rs | 242 ++++++++++++-------- 1 file changed, 151 insertions(+), 91 deletions(-) diff --git a/crates/brioche-core/src/reporter/console.rs b/crates/brioche-core/src/reporter/console.rs index 03f8faaf..f3196df4 100644 --- a/crates/brioche-core/src/reporter/console.rs +++ b/crates/brioche-core/src/reporter/console.rs @@ -6,6 +6,9 @@ use std::{ use bstr::ByteSlice as _; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; +use superconsole::Direction; +use superconsole::components::Split; +use superconsole::components::splitting::SplitKind; use superconsole::style::Stylize as _; use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::SubscriberInitExt as _}; @@ -547,65 +550,25 @@ fn print_job_content(jobs: &HashMap, stream: &JobOutputStream, conte const JOB_LABEL_WIDTH: usize = 7; -struct JobsComponent { - start: std::time::Instant, - jobs: HashMap, - contexts: HashMap, - job_outputs: OutputBuffer, +/// Maximum number of job rows to render at once. +const MAX_VISIBLE_JOBS: usize = 4; + +/// Renders the most recent N lines from the per-job output buffer, each +/// prefixed with a colored gutter showing the originating job's child +/// ID. `dimensions.height` caps the number of output lines produced. +struct OutputsComponent<'a> { + jobs: &'a HashMap, + job_outputs: &'a OutputBuffer, } -impl superconsole::Component for JobsComponent { +impl superconsole::Component for OutputsComponent<'_> { type Error = anyhow::Error; fn draw_unchecked( &self, dimensions: superconsole::Dimensions, - mode: superconsole::DrawMode, + _mode: superconsole::DrawMode, ) -> anyhow::Result { - let max_visible_jobs: usize = 4; - - let mut job_list: Vec<_> = self.jobs.iter().collect(); - job_list.sort_by(cmp_job_entries); - - let job_partition_point = job_list.partition_point(|&(_, job)| !job.is_complete()); - let (incomplete_jobs, complete_jobs) = job_list.split_at(job_partition_point); - - let num_incomplete_jobs = incomplete_jobs.len(); - let num_complete_jobs = complete_jobs.len(); - - // Ensure we show at least one complete job (if there are any) - let min_complete_jobs = std::cmp::min(num_complete_jobs, 1); - let max_incomplete_jobs = max_visible_jobs.saturating_sub(min_complete_jobs); - - let job_list = incomplete_jobs - .iter() - .take(max_incomplete_jobs) - .chain(complete_jobs) - .take(max_visible_jobs); - - let jobs_lines = job_list - .map(|(job_id, job)| { - let context = self.contexts.get(job_id); - JobComponent { - id: **job_id, - job, - context, - } - .draw( - superconsole::Dimensions { - width: dimensions.width, - height: 1, - }, - mode, - ) - }) - .collect::, _>>()?; - - let num_job_output_lines = dimensions - .height - .saturating_sub(jobs_lines.len()) - .saturating_sub(3); - let job_output_content_width = dimensions .width .saturating_sub(JOB_LABEL_WIDTH) @@ -626,7 +589,7 @@ impl superconsole::Component for JobsComponent { .flat_map(|line| line.chunks(job_output_content_width).rev()); lines_rev.map(|line| (*stream, bstr::BStr::new(line))) }) - .take(num_job_output_lines) + .take(dimensions.height) .collect::>(); let mut job_output_lines = vec![]; @@ -660,49 +623,146 @@ impl superconsole::Component for JobsComponent { last_job_id = Some(stream.job_id); } - let summary_line = match mode { - superconsole::DrawMode::Normal => { - let elapsed_span = superconsole::Span::new_unstyled_lossy( - lazy_format::lazy_format!("{:>6}", DisplayDuration(self.start.elapsed())), - ); - let running_jobs_span = superconsole::Span::new_colored_lossy( - &format!("{num_incomplete_jobs:3} running"), - if num_incomplete_jobs > 0 { - superconsole::style::Color::Blue - } else { - superconsole::style::Color::Grey - }, - ); - let complete_jobs_span = superconsole::Span::new_colored_lossy( - &format!("{num_complete_jobs:3} complete"), - if num_complete_jobs > 0 { - superconsole::style::Color::Green - } else { - superconsole::style::Color::Grey - }, - ); - let line = superconsole::Line::from_iter([ - elapsed_span, - superconsole::Span::new_unstyled_lossy(" "), - complete_jobs_span, - superconsole::Span::new_unstyled_lossy(" "), - running_jobs_span, - ]); - Some(line) - } - superconsole::DrawMode::Final => { - // Don't show the summary line on the final draw. The final - // summary will be written outside the reporter, since we also - // want to show the summary when not using SuperConsole - None + Ok(superconsole::Lines::from_iter(job_output_lines)) + } +} + +struct JobsComponent { + start: std::time::Instant, + jobs: HashMap, + contexts: HashMap, + job_outputs: OutputBuffer, +} + +impl JobsComponent { + /// Returns the sorted, truncated list of jobs to display: incomplete + /// jobs first, then complete jobs, with at most one complete job. + fn compute_visible_jobs(&self) -> Vec<(JobId, &Job)> { + let mut entries: Vec<(&JobId, &Job)> = self.jobs.iter().collect(); + entries.sort_by(cmp_job_entries); + + let partition_point = entries.partition_point(|&(_, job)| !job.is_complete()); + let (incomplete, complete) = entries.split_at(partition_point); + + let min_complete = complete.len().min(1); + let max_incomplete = MAX_VISIBLE_JOBS.saturating_sub(min_complete); + + incomplete + .iter() + .take(max_incomplete) + .chain(complete.iter()) + .take(MAX_VISIBLE_JOBS) + .map(|(id, job)| (**id, *job)) + .collect() + } + + fn render_summary( + &self, + mode: superconsole::DrawMode, + num_incomplete: usize, + num_complete: usize, + ) -> Option { + if mode == superconsole::DrawMode::Final { + return None; + } + + let elapsed_span = superconsole::Span::new_unstyled_lossy(lazy_format::lazy_format!( + "{:>6}", + DisplayDuration(self.start.elapsed()) + )); + let running_jobs_span = superconsole::Span::new_colored_lossy( + &format!("{num_incomplete:3} running"), + if num_incomplete > 0 { + superconsole::style::Color::Blue + } else { + superconsole::style::Color::Grey + }, + ); + let complete_jobs_span = superconsole::Span::new_colored_lossy( + &format!("{num_complete:3} complete"), + if num_complete > 0 { + superconsole::style::Color::Green + } else { + superconsole::style::Color::Grey + }, + ); + let line = superconsole::Line::from_iter([ + elapsed_span, + superconsole::Span::new_unstyled_lossy(" "), + complete_jobs_span, + superconsole::Span::new_unstyled_lossy(" "), + running_jobs_span, + ]); + + Some(line) + } +} + +impl superconsole::Component for JobsComponent { + type Error = anyhow::Error; + + fn draw_unchecked( + &self, + dimensions: superconsole::Dimensions, + mode: superconsole::DrawMode, + ) -> anyhow::Result { + let mut num_incomplete = 0; + let mut num_complete = 0; + self.jobs.values().for_each(|job| { + if job.is_complete() { + num_complete += 1; + } else { + num_incomplete += 1; } - }; + }); + + let visible_jobs = self.compute_visible_jobs(); + let num_visible = visible_jobs.len(); - let lines = job_output_lines + // Reserve space for visible jobs, the summary line, and 2 lines + // of bottom padding. + let summary_height = usize::from(mode == superconsole::DrawMode::Normal); + let bottom_padding = 2; + let outputs_height = dimensions + .height + .saturating_sub(num_visible) + .saturating_sub(summary_height) + .saturating_sub(bottom_padding); + + let mut lines = OutputsComponent { + jobs: &self.jobs, + job_outputs: &self.job_outputs, + } + .draw( + superconsole::Dimensions { + width: dimensions.width, + height: outputs_height, + }, + mode, + )?; + + let job_components: Vec> = visible_jobs .into_iter() - .chain(jobs_lines.into_iter().flatten()) - .chain(summary_line) + .map(|(id, job)| JobComponent { + id, + job, + context: self.contexts.get(&id), + }) .collect(); + let jobs_lines = Split::new(job_components, Direction::Vertical, SplitKind::Adaptive) + .draw( + superconsole::Dimensions { + width: dimensions.width, + height: num_visible, + }, + mode, + )?; + lines.extend(jobs_lines); + + if let Some(summary_line) = self.render_summary(mode, num_incomplete, num_complete) { + lines.push(summary_line); + } + Ok(lines) } } From 8f80d4fe56e0ea10685216c844b12fba95ac8706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Audiger?= Date: Wed, 1 Jul 2026 17:39:30 +0200 Subject: [PATCH 2/2] fix(reporter): emit build summary after console shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jérémy Audiger --- crates/brioche/src/build.rs | 60 ++++++++++++++++++++--------------- crates/brioche/src/install.rs | 43 +++++++++++++++---------- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/crates/brioche/src/build.rs b/crates/brioche/src/build.rs index e813951b..39c02fa0 100644 --- a/crates/brioche/src/build.rs +++ b/crates/brioche/src/build.rs @@ -73,6 +73,7 @@ pub struct BuildArgs { display: super::DisplayMode, } +#[expect(clippy::print_stderr)] pub async fn build( js_platform: brioche_core::script::JsPlatform, args: BuildArgs, @@ -106,6 +107,7 @@ pub async fn build( let build_result = async { let mut error_result = None; + let build_outputs = Vec::new(); // Load projects and pair each ref with its resolved hash let mut load_cache: HashMap<_, _> = HashMap::new(); @@ -133,7 +135,7 @@ pub async fn build( // If any project failed to load, skip remaining phases if error_result.is_some() { - return anyhow::Ok(None); + return anyhow::Ok((error_result, build_outputs)); } // Lockfile handling @@ -176,7 +178,7 @@ pub async fn build( guard.shutdown_console().await; diagnostics.write(&brioche.vfs, &mut std::io::stdout())?; - return anyhow::Ok(None); + return anyhow::Ok((error_result, build_outputs)); } } } @@ -230,11 +232,12 @@ pub async fn build( &format!("Lazy: found all recipe inputs in cache in {elapsed}"), superconsole::style::ContentStyle::default(), )); - return Ok(None); + return Ok((error_result, build_outputs)); } } // Build loop + let mut build_hashes = Vec::new(); for (i, &(project_hash, ProjectRef { source, export })) in projects_resolved.iter().enumerate() { @@ -253,18 +256,42 @@ pub async fn build( let result = run_build_target(&brioche, js_platform, &projects, &reporter, &build_opts).await; - consolidate_result(&reporter, Some(&project_name), result, &mut error_result); + match result { + Ok(hash) => { + consolidate_result(&reporter, Some(&project_name), Ok(true), &mut error_result); + build_hashes.push(hash); + } + Err(err) => { + consolidate_result(&reporter, Some(&project_name), Err(err), &mut error_result); + } + } } brioche.wait_for_tasks().await; - anyhow::Ok(error_result) + anyhow::Ok((error_result, build_hashes)) } .instrument(tracing::info_span!("build")) .await; guard.shutdown_console().await; - let error_result = build_result?; + let (error_result, build_hashes) = build_result?; + + if !build_hashes.is_empty() { + let elapsed = DisplayDuration(reporter.elapsed()); + let num_jobs = reporter.num_jobs(); + let jobs_message = match num_jobs { + 0 => "(no new jobs)".to_string(), + 1 => "1 job".to_string(), + n => format!("{n} jobs"), + }; + let build_finished = format!("Build finished, completed {jobs_message} in {elapsed}"); + for hash in &build_hashes { + eprintln!("{build_finished}"); + eprintln!("Result: {hash}"); + } + } + let exit_code = error_result.map_or(ExitCode::SUCCESS, |()| ExitCode::FAILURE); Ok(exit_code) @@ -285,7 +312,7 @@ async fn run_build_target( projects: &Projects, reporter: &Reporter, options: &BuildTargetOptions<'_>, -) -> Result { +) -> Result { let recipe = brioche_core::script::evaluate::evaluate( brioche, js_platform, @@ -309,23 +336,6 @@ async fn run_build_target( let artifact_hash = artifact.value.hash(); let default_style = superconsole::style::ContentStyle::default(); - let elapsed = DisplayDuration(reporter.elapsed()); - let num_jobs = reporter.num_jobs(); - let jobs_message = match num_jobs { - 0 => "(no new jobs)".to_string(), - 1 => "1 job".to_string(), - n => format!("{n} jobs"), - }; - reporter.emit(superconsole::Lines::from_multiline_string( - &format!("Build finished, completed {jobs_message} in {elapsed}"), - default_style, - )); - - reporter.emit(superconsole::Lines::from_multiline_string( - &format!("Result: {artifact_hash}"), - default_style, - )); - if let Some(output) = options.output { if options.replace { fs_utils::try_remove(output) @@ -392,5 +402,5 @@ async fn run_build_target( )); } - Ok(true) + Ok(artifact_hash.to_string()) } diff --git a/crates/brioche/src/install.rs b/crates/brioche/src/install.rs index dcebe513..48dec73f 100644 --- a/crates/brioche/src/install.rs +++ b/crates/brioche/src/install.rs @@ -46,6 +46,7 @@ pub struct InstallArgs { display: super::DisplayMode, } +#[expect(clippy::print_stderr)] pub async fn install( js_platform: brioche_core::script::JsPlatform, args: InstallArgs, @@ -156,6 +157,7 @@ pub async fn install( } // Install loop + let mut num_installed = 0; for &(project_hash, ProjectRef { source, export }) in &projects_resolved { let project_name = source.to_string(); @@ -170,12 +172,34 @@ pub async fn install( ) .await; - consolidate_result(&reporter, Some(&project_name), result, &mut error_result); + match result { + Ok(()) => { + consolidate_result(&reporter, Some(&project_name), Ok(true), &mut error_result); + num_installed += 1; + } + Err(err) => { + consolidate_result(&reporter, Some(&project_name), Err(err), &mut error_result); + } + } } guard.shutdown_console().await; brioche.wait_for_tasks().await; + if num_installed > 0 { + let elapsed = DisplayDuration(reporter.elapsed()); + let num_jobs = reporter.num_jobs(); + let jobs_message = match num_jobs { + 0 => "(no new jobs)".to_string(), + 1 => "1 job".to_string(), + n => format!("{n} jobs"), + }; + let build_finished = format!("Build finished, completed {jobs_message} in {elapsed}"); + for _ in 0..num_installed { + eprintln!("{build_finished}"); + } + } + let exit_code = error_result.map_or(ExitCode::SUCCESS, |()| ExitCode::FAILURE); Ok(exit_code) @@ -189,7 +213,7 @@ async fn run_install( project_hash: ProjectHash, project_name: &str, export: &str, -) -> Result { +) -> Result<(), anyhow::Error> { async { let recipe = brioche_core::script::evaluate::evaluate( brioche, @@ -211,19 +235,6 @@ async fn run_install( .instrument(tracing::info_span!("bake")) .await?; - let elapsed = DisplayDuration(reporter.elapsed()); - let num_jobs = reporter.num_jobs(); - let jobs_message = match num_jobs { - 0 => "(no new jobs)".to_string(), - 1 => "1 job".to_string(), - n => format!("{n} jobs"), - }; - - reporter.emit(superconsole::Lines::from_multiline_string( - &format!("Build finished, completed {jobs_message} in {elapsed}"), - superconsole::style::ContentStyle::default(), - )); - // Ensure the artifact is a directory let mut directory = match artifact.value { brioche_core::recipe::Artifact::File(_) => { @@ -287,7 +298,7 @@ async fn run_install( )); } - Ok(true) + Ok(()) } .instrument(tracing::info_span!("run_install")) .await